Compare commits

...

2 Commits

27 changed files with 2863 additions and 593 deletions

View File

@@ -41,6 +41,12 @@
"firebase.json",
"**/.*",
"**/node_modules/**"
],
"rewrites": [
{
"source": "**",
"destination": "/index.html"
}
]
}
}

View File

@@ -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';

View File

@@ -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<String, dynamic>;
final event = args['event'] as EventModel;
return AuthGuard(
child: EventPreparationPage(eventId: eventId),
child: EventPreparationPage(
initialEvent: event,
),
);
},
},

View File

@@ -13,7 +13,6 @@ String eventStatusToString(EventStatus status) {
case EventStatus.canceled:
return 'CANCELED';
case EventStatus.waitingForApproval:
default:
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<EventEquipment> assignedEquipment;
final List<String> 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,
};
}

View File

@@ -1,9 +1,11 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/equipment_status_calculator.dart';
class EquipmentProvider extends ChangeNotifier {
final EquipmentService _service = EquipmentService();
final EquipmentStatusCalculator _statusCalculator = EquipmentStatusCalculator();
List<EquipmentModel> _equipment = [];
List<String> _models = [];
@@ -179,6 +181,16 @@ class EquipmentProvider extends ChangeNotifier {
}
}
/// Calculer le statut réel d'un équipement (asynchrone)
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
return await _statusCalculator.calculateRealStatus(equipment);
}
/// Invalider le cache du calculateur de statut
void invalidateStatusCache() {
_statusCalculator.invalidateCache();
}
// === FILTRES ===
/// Définir la catégorie sélectionnée

View File

@@ -1,5 +1,6 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/services/equipment_status_calculator.dart';
import '../models/event_model.dart';
class EventProvider with ChangeNotifier {
@@ -120,6 +121,10 @@ class EventProvider with ChangeNotifier {
try {
await _firestore.collection('events').doc(eventId).delete();
_events.removeWhere((event) => event.id == eventId);
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
notifyListeners();
} catch (e) {
print('Error deleting event: $e');

View File

@@ -0,0 +1,235 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/event_model.dart';
/// Service pour calculer dynamiquement le statut réel d'un équipement
/// basé sur les événements en cours
class EquipmentStatusCalculator {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
/// Cache des événements pour éviter de multiples requêtes
List<EventModel>? _cachedEvents;
DateTime? _cacheTime;
static const _cacheDuration = Duration(minutes: 1);
/// Instance statique pour permettre l'invalidation depuis n'importe où
static final EquipmentStatusCalculator _instance = EquipmentStatusCalculator._internal();
factory EquipmentStatusCalculator() {
return _instance;
}
EquipmentStatusCalculator._internal();
/// Calcule le statut réel d'un équipement basé sur les événements
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async {
print('[StatusCalculator] Calculating status for: ${equipment.id}');
// Si l'équipement est marqué comme perdu ou HS, on garde ce statut
// car c'est une information métier importante
if (equipment.status == EquipmentStatus.lost ||
equipment.status == EquipmentStatus.outOfService) {
print('[StatusCalculator] ${equipment.id} is lost/outOfService -> keeping status');
return equipment.status;
}
// Charger les événements (avec cache)
await _loadEventsIfNeeded();
print('[StatusCalculator] Events loaded: ${_cachedEvents?.length ?? 0}');
// Vérifier si l'équipement est utilisé dans un événement en cours
final isInUse = await _isEquipmentInUse(equipment.id);
print('[StatusCalculator] ${equipment.id} isInUse: $isInUse');
if (isInUse) {
return EquipmentStatus.inUse;
}
// Vérifier si l'équipement est en maintenance
if (equipment.status == EquipmentStatus.maintenance) {
// On pourrait vérifier si la maintenance est toujours valide
// Pour l'instant on garde le statut
return EquipmentStatus.maintenance;
}
// Vérifier si l'équipement est loué
if (equipment.status == EquipmentStatus.rented) {
// On pourrait vérifier une date de retour prévue
// Pour l'instant on garde le statut
return EquipmentStatus.rented;
}
// Par défaut, l'équipement est disponible
print('[StatusCalculator] ${equipment.id} -> AVAILABLE');
return EquipmentStatus.available;
}
/// Calcule les statuts pour une liste d'équipements (optimisé)
Future<Map<String, EquipmentStatus>> calculateMultipleStatuses(
List<EquipmentModel> equipments,
) async {
await _loadEventsIfNeeded();
final statuses = <String, EquipmentStatus>{};
// Trouver tous les équipements en cours d'utilisation
final equipmentIdsInUse = <String>{};
final containerIdsInUse = <String>{};
for (var event in _cachedEvents ?? []) {
// Un équipement est "en prestation" dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
// Ajouter les équipements directs
for (var eq in event.assignedEquipment) {
equipmentIdsInUse.add(eq.equipmentId);
}
// Ajouter les conteneurs
containerIdsInUse.addAll(event.assignedContainers);
}
}
// Récupérer les équipements dans les conteneurs en cours d'utilisation
if (containerIdsInUse.isNotEmpty) {
final containersSnapshot = await _firestore
.collection('containers')
.where(FieldPath.documentId, whereIn: containerIdsInUse.toList())
.get();
for (var doc in containersSnapshot.docs) {
final data = doc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
equipmentIdsInUse.addAll(equipmentIds);
}
}
// Calculer le statut pour chaque équipement
for (var equipment in equipments) {
// Si perdu ou HS, on garde le statut
if (equipment.status == EquipmentStatus.lost ||
equipment.status == EquipmentStatus.outOfService) {
statuses[equipment.id] = equipment.status;
continue;
}
// Si en cours d'utilisation
if (equipmentIdsInUse.contains(equipment.id)) {
statuses[equipment.id] = EquipmentStatus.inUse;
continue;
}
// Si en maintenance ou loué, on garde le statut
if (equipment.status == EquipmentStatus.maintenance ||
equipment.status == EquipmentStatus.rented) {
statuses[equipment.id] = equipment.status;
continue;
}
// Par défaut, disponible
statuses[equipment.id] = EquipmentStatus.available;
}
return statuses;
}
/// Vérifie si un équipement est actuellement en cours d'utilisation
Future<bool> _isEquipmentInUse(String equipmentId) async {
print('[StatusCalculator] Checking if $equipmentId is in use...');
// Vérifier dans les événements directs
for (var event in _cachedEvents ?? []) {
// Un équipement est "en prestation" dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (!isInProgress) continue;
print('[StatusCalculator] Event ${event.name} is IN PROGRESS (prepared and not returned)');
// Vérifier si l'équipement est directement assigné
if (event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId)) {
print('[StatusCalculator] $equipmentId found DIRECTLY in event ${event.name}');
return true;
}
// Vérifier si l'équipement est dans un conteneur assigné
if (event.assignedContainers.isNotEmpty) {
print('[StatusCalculator] Checking containers for event ${event.name}: ${event.assignedContainers}');
final containersSnapshot = await _firestore
.collection('containers')
.where(FieldPath.documentId, whereIn: event.assignedContainers)
.get();
for (var doc in containersSnapshot.docs) {
final data = doc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
print('[StatusCalculator] Container ${doc.id} contains: $equipmentIds');
if (equipmentIds.contains(equipmentId)) {
print('[StatusCalculator] $equipmentId found in CONTAINER ${doc.id}');
return true;
}
}
}
}
print('[StatusCalculator] $equipmentId is NOT in use');
return false;
}
/// Charge les événements si le cache est expiré
Future<void> _loadEventsIfNeeded() async {
if (_cachedEvents != null &&
_cacheTime != null &&
DateTime.now().difference(_cacheTime!) < _cacheDuration) {
return; // Cache encore valide
}
try {
final eventsSnapshot = await _firestore.collection('events').get();
_cachedEvents = eventsSnapshot.docs
.map((doc) {
try {
return EventModel.fromMap(doc.data(), doc.id);
} catch (e) {
print('[EquipmentStatusCalculator] Error parsing event ${doc.id}: $e');
return null;
}
})
.whereType<EventModel>()
.where((event) => event.status != EventStatus.canceled) // Ignorer les événements annulés
.toList();
_cacheTime = DateTime.now();
} catch (e) {
print('[EquipmentStatusCalculator] Error loading events: $e');
_cachedEvents = [];
}
}
/// Invalide le cache (à appeler après une modification d'événement)
void invalidateCache() {
_cachedEvents = null;
_cacheTime = null;
}
/// Invalide le cache de l'instance globale (méthode statique)
static void invalidateGlobalCache() {
_instance.invalidateCache();
}
}

View File

@@ -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<String>? 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 = <AvailabilityConflict>[];
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<Map<String, List<AvailabilityConflict>>> checkMultipleEquipmentAvailability({
required List<String> 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<List<AvailabilityConflict>> checkEquipmentAvailabilityWithQuantity({
required EquipmentModel equipment,
required int requestedQuantity,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
// Si équipement quantifiable (consommable/câble)
if (equipment.hasQuantity) {
final totalQuantity = equipment.totalQuantity ?? 0;
final availableQty = await getAvailableQuantity(
equipment: equipment,
startDate: startDate,
endDate: endDate,
excludeEventId: excludeEventId,
);
final reservedQty = totalQuantity - availableQty;
// ✅ Ne créer un conflit que si la quantité est VRAIMENT insuffisante
if (availableQty < requestedQuantity) {
// Trouver les événements qui réservent cette quantité
final 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<List<AvailabilityConflict>> checkContainerAvailability({
required ContainerModel container,
required List<EquipmentModel> containerEquipment,
required DateTime startDate,
required DateTime endDate,
String? excludeEventId,
}) async {
final conflicts = <AvailabilityConflict>[];
final conflictingChildrenIds = <String>[];
// 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;
}
}

View File

@@ -2,6 +2,7 @@ import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/services/equipment_status_calculator.dart';
class EventPreparationService {
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
@@ -9,7 +10,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 +30,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 = <String, dynamic>{
'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,10 +69,17 @@ class EventPreparationService {
'preparationStatus': preparationStatusToString(PreparationStatus.completed),
});
// Mettre à jour le statut des équipements à "inUse"
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
// Mettre à jour le statut des équipements à "inUse" (seulement pour les équipements qui existent)
for (var equipment in event.assignedEquipment) {
// 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');
rethrow;
@@ -85,9 +105,13 @@ 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)) {
// 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 completing preparation with missing: $e');
rethrow;
@@ -120,9 +144,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 = <String, dynamic>{
'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 +212,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,14 +220,24 @@ 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()),
});
}
}
}
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all return: $e');
rethrow;
@@ -218,29 +262,38 @@ class EventPreparationService {
// Mettre à jour le statut des équipements retournés à "available"
for (var equipment in event.assignedEquipment) {
if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) {
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) {
if (!equipmentDoc.exists) {
continue; // Passer cet équipement s'il n'existe pas
}
final equipmentData = EquipmentModel.fromMap(
equipmentDoc.data() as Map<String, dynamic>,
equipmentDoc.id,
);
if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) {
// 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()),
});
}
}
} else if (missingEquipmentIds.contains(equipment.equipmentId)) {
// Marquer comme perdu
// Marquer comme perdu uniquement pour les équipements non quantifiables
if (!equipmentData.hasQuantity) {
await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost);
}
}
}
} catch (e) {
print('Error completing return with missing: $e');
rethrow;
@@ -252,13 +305,20 @@ class EventPreparationService {
/// Mettre à jour le statut d'un équipement
Future<void> 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
}
}

View File

@@ -0,0 +1,247 @@
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/equipment_status_calculator.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<void> 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 = <String, dynamic>{
'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<void> 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),
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all loading: $e');
rethrow;
}
}
// === DÉCHARGEMENT (UNLOADING) ===
/// Valider un équipement individuel pour le déchargement
Future<void> 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 = <String, dynamic>{
'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<void> 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),
});
// Invalider le cache des statuts d'équipement
EquipmentStatusCalculator.invalidateGlobalCache();
} catch (e) {
print('Error validating all unloading: $e');
rethrow;
}
}
// === PRÉPARATION + CHARGEMENT ===
/// Valider préparation ET chargement en même temps
Future<void> 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<void> validateAllUnloadingAndReturn(
String eventId,
Map<String, int>? 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<String, dynamic>,
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<void> _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<EventModel?> _getEvent(String eventId) async {
try {
final doc = await _eventsCollection.doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting event: $e');
rethrow;
}
}
}

View File

@@ -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é
}

View File

@@ -16,6 +16,7 @@ import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_notes_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_associated_events_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_current_events_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
@@ -107,11 +108,15 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
const SizedBox(height: 24),
],
// 4. Événements associés
// 4. Événements en cours
EquipmentCurrentEventsSection(equipment: widget.equipment),
const SizedBox(height: 24),
// 5. Événements passés / à venir
EquipmentAssociatedEventsSection(equipment: widget.equipment),
const SizedBox(height: 24),
// 5-7. Prix, Historique des maintenances, Dates en layout responsive
// 6-8. Prix, Historique des maintenances, Dates en layout responsive
if (isDesktop)
_buildDesktopTwoColumnLayout(hasManagePermission)
else

View File

@@ -10,6 +10,7 @@ import 'package:em2rp/views/equipment_form_page.dart';
import 'package:em2rp/views/equipment_detail_page.dart';
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_list.dart';
@@ -428,9 +429,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
_cachedEquipment = items;
},
itemBuilder: (equipment) {
// Trier les équipements par nom
final sortedEquipment = List<EquipmentModel>.from(_cachedEquipment ?? [equipment]);
sortedEquipment.sort((a, b) => a.name.compareTo(b.name));
return _buildEquipmentCard(equipment);
},
);
@@ -444,7 +442,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
return Card(
margin: const EdgeInsets.only(bottom: 12),
color: isSelectionMode && isSelected
? AppColors.rouge.withOpacity(0.1)
? AppColors.rouge.withValues(alpha: 0.1)
: null,
child: ListTile(
leading: isSelectionMode
@@ -454,11 +452,10 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
activeColor: AppColors.rouge,
)
: CircleAvatar(
backgroundColor:
equipment.status.color.withOpacity(0.2),
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
child: equipment.category.getIcon(
size: 20,
color: Colors.black,
color: equipment.category.color,
),
),
title: Row(
@@ -469,10 +466,10 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
// Afficher le badge de statut calculé dynamiquement
if (equipment.category != EquipmentCategory.consumable &&
equipment.category != EquipmentCategory.cable)
_buildStatusBadge(equipment.status),
EquipmentStatusBadge(equipment: equipment),
],
),
subtitle: Column(
@@ -607,24 +604,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
);
}
Widget _buildStatusBadge(EquipmentStatus status) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status.color.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
}
// Actions
void _createNewEquipment() {

View File

@@ -1,382 +1,700 @@
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<EventPreparationPage> createState() => _EventPreparationPageState();
}
class _EventPreparationPageState extends State<EventPreparationPage> {
class _EventPreparationPageState extends State<EventPreparationPage> with SingleTickerProviderStateMixin {
final EventPreparationService _preparationService = EventPreparationService();
EventModel? _event;
Map<String, EquipmentModel> _equipmentMap = {};
Map<String, int> _returnedQuantities = {}; // Pour les quantités retournées (consommables)
bool _isLoading = true;
bool _isSaving = false;
final EventPreparationServiceExtended _extendedService = EventPreparationServiceExtended();
late AnimationController _animationController;
// 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;
Map<String, EquipmentModel> _equipmentCache = {};
Map<String, ContainerModel> _containerCache = {};
Map<String, int> _returnedQuantities = {};
// État local des validations (non sauvegardé jusqu'à la validation finale)
Map<String, bool> _localValidationState = {};
bool _isLoading = true;
bool _isValidating = false;
bool _showSuccessAnimation = false;
bool _loadSimultaneously = false; // Checkbox "charger en même temps"
// 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;
}
String get _pageTitle => _isReturnMode ? 'Retour matériel' : 'Préparation matériel';
// 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;
}
@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;
}
Future<void> _loadEventAndEquipment() async {
try {
// Charger l'événement
final eventProvider = context.read<EventProvider>();
final event = await eventProvider.getEvent(widget.eventId);
if (event == null) {
throw Exception('Événement non trouvé');
}
// Charger tous les équipements assignés
final equipmentProvider = context.read<EquipmentProvider>();
final Map<String, EquipmentModel> equipmentMap = {};
for (var assignedEq in event.assignedEquipment) {
final equipment = await equipmentProvider.getEquipmentById(assignedEq.equipmentId);
if (equipment != null) {
equipmentMap[assignedEq.equipmentId] = equipment;
// 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;
}
}
}
setState(() {
_event = event;
_equipmentMap = equipmentMap;
_isLoading = false;
});
_loadEquipmentAndContainers();
}
/// 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<void> _reloadEvent() async {
try {
final doc = await FirebaseFirestore.instance
.collection('events')
.doc(_currentEvent.id)
.get();
if (doc.exists) {
setState(() {
_currentEvent = EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
});
}
} catch (e) {
print('[EventPreparationPage] Error reloading event: $e');
}
}
Future<void> _loadEquipmentAndContainers() async {
setState(() => _isLoading = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
final equipment = await equipmentProvider.equipmentStream.first;
final containers = await containerProvider.containersStream.first;
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;
}
}
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<void> _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 = <String, dynamic>{
'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<void> _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<void> _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<void> _validatePreparation() async {
if (_event == null) return;
Future<void> _updateEquipmentStatuses(List<EventEquipment> 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 = <EquipmentModel>[];
final missingIds = <String>[];
if (doc.exists) {
final equipmentData = EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
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é !';
}
}
// Sinon, afficher le dialog des manquants
if (mounted) {
final result = await showDialog<String>(
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é';
}
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<void> _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<EquipmentModel>()
.toList();
final missingEventEquipment = _currentEvent.assignedEquipment
.where((eq) => missingEquipmentIds.contains(eq.equipmentId))
.toList();
final action = await showDialog<String>(
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<LocalUserProvider>();
final userId = userProvider.uid;
final hasManagePermission = userProvider.hasPermission('manage_events');
final allValidated = _isStepCompleted();
final stepTitle = _getStepTitle();
// 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.'),
appBar: AppBar(
title: Text(stepTitle),
backgroundColor: AppColors.bleuFonce,
),
);
}
return Scaffold(
appBar: CustomAppBar(title: _pageTitle),
body: _isLoading
body: Stack(
children: [
_isLoading
? const Center(child: CircularProgressIndicator())
: _event == null
? const Center(child: Text('Événement introuvable'))
: Column(
children: [
// En-tête avec info de l'événement
_buildEventHeader(),
// Bouton "Tout confirmer"
_buildQuickValidateButton(),
// Liste des équipements
Expanded(child: _buildEquipmentList()),
// Bouton de validation final
_buildValidateButton(),
],
),
);
}
Widget _buildEventHeader() {
return Container(
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColors.rouge.withOpacity(0.1),
border: Border(
bottom: BorderSide(color: Colors.grey.shade300),
),
),
color: Colors.grey.shade100,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
_event!.name,
_currentEvent.name,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: _getProgress(),
backgroundColor: Colors.grey.shade300,
valueColor: AlwaysStoppedAnimation<Color>(
allValidated ? Colors.green : AppColors.bleuFonce,
),
),
),
const SizedBox(width: 12),
Text(
'${_event!.assignedEquipment.length} équipement(s) assigné(s)',
style: TextStyle(color: Colors.grey.shade700),
'${_getValidatedCount()}/${_currentEvent.assignedEquipment.length}',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
],
),
);
}
const SizedBox(height: 12),
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),
// 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,
),
icon: const Icon(Icons.check_circle, color: Colors.white),
),
dense: true,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: allValidated ? null : _validateAll,
icon: const Icon(Icons.check_circle_outline),
label: Text(
_isReturnMode ? 'Tout confirmer comme retourné' : 'Tout confirmer comme préparé',
style: const TextStyle(color: Colors.white, fontSize: 16),
allValidated
? 'Tout est validé !'
: _getValidateAllButtonText(),
),
style: ElevatedButton.styleFrom(
backgroundColor: allValidated ? Colors.green : AppColors.bleuFonce,
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
);
}
Widget _buildEquipmentList() {
return ListView.builder(
],
),
),
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _event!.assignedEquipment.length,
itemCount: _currentEvent.assignedEquipment.length,
itemBuilder: (context, index) {
final assignedEq = _event!.assignedEquipment[index];
final equipment = _equipmentMap[assignedEq.equipmentId];
final eventEquipment = _currentEvent.assignedEquipment[index];
final equipment = _equipmentCache[eventEquipment.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) {
eventEquipment: eventEquipment,
step: _getChecklistStep(),
isValidated: _localValidationState[equipment.id] ?? false,
onToggle: () => _toggleEquipmentValidation(equipment.id),
onReturnedQuantityChanged: _currentStep == PreparationStep.return_ && equipment.hasQuantity
? (qty) {
setState(() {
_returnedQuantities[assignedEq.equipmentId] = value;
_returnedQuantities[equipment.id] = qty;
});
}
: null,
);
},
);
}
Widget _buildValidateButton() {
return Container(
),
),
],
),
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,
),
),
),
),
],
),
bottomNavigationBar: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.3),
spreadRadius: 1,
blurRadius: 5,
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 4,
offset: const Offset(0, -2),
),
],
),
child: ElevatedButton(
onPressed: _isSaving ? null : _validatePreparation,
onPressed: _isValidating ? null : _confirm,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
minimumSize: const Size(double.infinity, 50),
backgroundColor: allValidated ? Colors.green : AppColors.rouge,
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: _isSaving
? const CircularProgressIndicator(color: Colors.white)
),
child: _isValidating
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: Text(
_isReturnMode ? 'Finaliser le retour' : 'Finaliser la préparation',
style: const TextStyle(color: Colors.white, fontSize: 16),
'Confirmer ${_getStepTitle().toLowerCase()}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
),
);

View File

@@ -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(

View File

@@ -98,8 +98,8 @@ class _EventDetailsHeaderState extends State<EventDetailsHeader> {
_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<LocalUserProvider>(context, listen: false)

View File

@@ -0,0 +1,165 @@
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<EventPreparationButtons> createState() => _EventPreparationButtonsState();
}
class _EventPreparationButtonsState extends State<EventPreparationButtons> {
@override
Widget build(BuildContext context) {
// Écouter les changements de l'événement en temps réel
return StreamBuilder<DocumentSnapshot>(
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<String, dynamic>,
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)
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: 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, horizontal: 24),
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,
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -6,7 +6,6 @@ import 'package:em2rp/utils/colors.dart';
import 'package:intl/intl.dart';
enum EventFilter {
current, // Événements en cours (préparés mais pas encore retournés)
upcoming, // Événements à venir
past, // Événements passés
}
@@ -27,7 +26,7 @@ class EquipmentAssociatedEventsSection extends StatefulWidget {
class _EquipmentAssociatedEventsSectionState
extends State<EquipmentAssociatedEventsSection> {
EventFilter _selectedFilter = EventFilter.current;
EventFilter _selectedFilter = EventFilter.upcoming;
List<EventModel> _events = [];
bool _isLoading = true;
@@ -41,38 +40,80 @@ class _EquipmentAssociatedEventsSectionState
setState(() => _isLoading = true);
try {
// Récupérer TOUS les événements car on ne peut pas faire arrayContains sur un objet
final eventsSnapshot = await FirebaseFirestore.instance
.collection('events')
.where('assignedEquipment',
arrayContains: {'equipmentId': widget.equipment.id})
.get();
final events = eventsSnapshot.docs
.map((doc) => EventModel.fromMap(doc.data(), doc.id))
.toList();
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerDoc.id}: $e');
}
}
// Filtrer manuellement les événements qui contiennent cet équipement
for (var doc in eventsSnapshot.docs) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
(eq) => eq.equipmentId == widget.equipment.id,
);
// Vérifier si l'équipement est dans une boîte assignée à l'événement
final hasEquipmentInAssignedContainer = event.assignedContainers.any(
(containerId) => containersWithEquipment.contains(containerId),
);
if (hasEquipmentDirectly || hasEquipmentInAssignedContainer) {
events.add(event);
}
} catch (e) {
print('[EquipmentAssociatedEventsSection] Error parsing event ${doc.id}: $e');
}
}
// Filtrer selon le statut
final now = DateTime.now();
final filteredEvents = events.where((event) {
switch (_selectedFilter) {
case EventFilter.current:
// Événement en cours = préparation complétée ET retour pas encore complété
return (event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus ==
PreparationStatus.completedWithMissing) &&
(event.returnStatus == null ||
event.returnStatus == ReturnStatus.notStarted ||
event.returnStatus == ReturnStatus.inProgress);
// Un événement est EN COURS dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
return false; // Les événements en cours sont affichés dans une autre section
}
switch (_selectedFilter) {
case EventFilter.upcoming:
// Événements à venir = date de début dans le futur OU préparation pas encore faite
return event.startDateTime.isAfter(now) ||
event.preparationStatus == PreparationStatus.notStarted;
// Événements à venir = date de début dans le futur
return event.startDateTime.isAfter(now);
case EventFilter.past:
// Événements passés = retour complété
return event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
// Événements passés = date de fin passée
return event.endDateTime.isBefore(now);
}
}).toList();
@@ -98,8 +139,6 @@ class _EquipmentAssociatedEventsSectionState
String _getFilterLabel(EventFilter filter) {
switch (filter) {
case EventFilter.current:
return 'En cours';
case EventFilter.upcoming:
return 'À venir';
case EventFilter.past:
@@ -109,8 +148,6 @@ class _EquipmentAssociatedEventsSectionState
IconData _getFilterIcon(EventFilter filter) {
switch (filter) {
case EventFilter.current:
return Icons.play_circle;
case EventFilter.upcoming:
return Icons.upcoming;
case EventFilter.past:
@@ -134,7 +171,7 @@ class _EquipmentAssociatedEventsSectionState
const SizedBox(width: 8),
Expanded(
child: Text(
'Événements associés',
'Événements passés / à venir',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@@ -225,11 +262,28 @@ class _EquipmentAssociatedEventsSectionState
Widget _buildEventCard(EventModel event) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
final isInProgress = (event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing) &&
(event.returnStatus == null ||
event.returnStatus == ReturnStatus.notStarted ||
event.returnStatus == ReturnStatus.inProgress);
// Un événement est en cours dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
// Trouver la quantité utilisée pour les consommables/câbles
int? usedQuantity;
if (widget.equipment.hasQuantity) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == widget.equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty) {
usedQuantity = assignedEquipment.quantity;
}
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
@@ -290,16 +344,46 @@ class _EquipmentAssociatedEventsSectionState
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Text(
Expanded(
child: Text(
'${dateFormat.format(event.startDateTime)}${dateFormat.format(event.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
),
],
),
// Quantité utilisée pour consommables/câbles
if (usedQuantity != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory, size: 14, color: Colors.blue),
const SizedBox(width: 6),
Text(
'Quantité utilisée: $usedQuantity',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
],
const SizedBox(height: 8),
// Statuts de préparation et retour
@@ -316,31 +400,6 @@ class _EquipmentAssociatedEventsSectionState
),
],
),
// Boutons d'action
if (isInProgress && _selectedFilter == EventFilter.current) ...[
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
Navigator.pushNamed(
context,
'/event_preparation',
arguments: event.id,
);
},
icon: const Icon(Icons.logout, size: 16),
label: const Text('Check-out'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.rouge,
),
),
),
],
),
],
],
),
),

View File

@@ -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<int>? 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,
),
);
}
}

View File

@@ -0,0 +1,284 @@
import 'package:flutter/material.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/utils/colors.dart';
import 'package:intl/intl.dart';
/// Widget pour afficher les événements EN COURS utilisant cet équipement
class EquipmentCurrentEventsSection extends StatefulWidget {
final EquipmentModel equipment;
const EquipmentCurrentEventsSection({
super.key,
required this.equipment,
});
@override
State<EquipmentCurrentEventsSection> createState() =>
_EquipmentCurrentEventsSectionState();
}
class _EquipmentCurrentEventsSectionState
extends State<EquipmentCurrentEventsSection> {
List<EventModel> _events = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCurrentEvents();
}
Future<void> _loadCurrentEvents() async {
setState(() => _isLoading = true);
try {
// Récupérer TOUS les événements
final eventsSnapshot = await FirebaseFirestore.instance
.collection('events')
.get();
final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu
final containersSnapshot = await FirebaseFirestore.instance
.collection('containers')
.get();
final containersWithEquipment = <String>[];
for (var containerDoc in containersSnapshot.docs) {
try {
final data = containerDoc.data();
final equipmentIds = List<String>.from(data['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerDoc.id);
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing container ${containerDoc.id}: $e');
}
}
// Filtrer les événements en cours
for (var doc in eventsSnapshot.docs) {
try {
final event = EventModel.fromMap(doc.data(), doc.id);
// Vérifier si l'équipement est directement assigné
final hasEquipmentDirectly = event.assignedEquipment.any(
(eq) => eq.equipmentId == widget.equipment.id,
);
// Vérifier si l'équipement est dans une boîte assignée à l'événement
final hasEquipmentInAssignedContainer = event.assignedContainers.any(
(containerId) => containersWithEquipment.contains(containerId),
);
if (hasEquipmentDirectly || hasEquipmentInAssignedContainer) {
// Un événement est EN COURS dès que la préparation est complétée
// et jusqu'à ce que le retour soit complété
final isPrepared = event.preparationStatus == PreparationStatus.completed ||
event.preparationStatus == PreparationStatus.completedWithMissing;
final isReturned = event.returnStatus == ReturnStatus.completed ||
event.returnStatus == ReturnStatus.completedWithMissing;
final isInProgress = isPrepared && !isReturned;
if (isInProgress) {
events.add(event);
}
}
} catch (e) {
print('[EquipmentCurrentEventsSection] Error parsing event ${doc.id}: $e');
}
}
// Trier par date
events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
setState(() {
_events = events;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du chargement: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
@override
Widget build(BuildContext context) {
// Ne rien afficher si pas d'événements en cours
if (!_isLoading && _events.isEmpty) {
return const SizedBox.shrink();
}
return Card(
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête
Row(
children: [
const Icon(Icons.play_circle, color: AppColors.rouge),
const SizedBox(width: 8),
Expanded(
child: Text(
'Événements en cours utilisant ce matériel',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
const Divider(height: 24),
// Liste des événements
if (_isLoading)
const Center(
child: Padding(
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
)
else
Column(
children: _events.map((event) => _buildEventCard(event)).toList(),
),
],
),
),
);
}
Widget _buildEventCard(EventModel event) {
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
// Trouver la quantité utilisée pour les consommables/câbles
int? usedQuantity;
if (widget.equipment.hasQuantity) {
final assignedEquipment = event.assignedEquipment.firstWhere(
(eq) => eq.equipmentId == widget.equipment.id,
orElse: () => EventEquipment(equipmentId: ''),
);
if (assignedEquipment.equipmentId.isNotEmpty) {
usedQuantity = assignedEquipment.quantity;
}
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: AppColors.rouge, width: 2),
),
child: InkWell(
onTap: () {
// Navigation vers les détails de l'événement si nécessaire
},
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Titre de l'événement
Row(
children: [
Expanded(
child: Text(
event.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.rouge,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'EN COURS',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 8),
// Dates
Row(
children: [
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
const SizedBox(width: 4),
Expanded(
child: Text(
'${dateFormat.format(event.startDateTime)}${dateFormat.format(event.endDateTime)}',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
),
),
),
],
),
// Quantité utilisée pour consommables/câbles
if (usedQuantity != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.blue),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.inventory, size: 14, color: Colors.blue),
const SizedBox(width: 6),
Text(
'Quantité utilisée: $usedQuantity',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Colors.blue,
),
),
],
),
),
],
],
),
),
),
);
}
}

View File

@@ -40,7 +40,7 @@ class EquipmentHeaderSection extends StatelessWidget {
radius: 30,
child: equipment.category.getIcon(
size: 32,
color: AppColors.rouge,
color: equipment.category.color,
),
),
const SizedBox(width: 16),

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
/// Widget qui affiche le badge de statut d'un équipement
/// Le statut est calculé de manière asynchrone basé sur les événements en cours
class EquipmentStatusBadge extends StatelessWidget {
final EquipmentModel equipment;
const EquipmentStatusBadge({
super.key,
required this.equipment,
});
@override
Widget build(BuildContext context) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
print('[EquipmentStatusBadge] Building badge for ${equipment.id}');
return FutureBuilder<EquipmentStatus>(
// On calcule le statut réel de manière asynchrone
future: provider.calculateRealStatus(equipment),
// En attendant, on affiche le statut stocké
initialData: equipment.status,
builder: (context, snapshot) {
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
final status = snapshot.data ?? equipment.status;
print('[EquipmentStatusBadge] ${equipment.id} - Status: ${status.label} (hasData: ${snapshot.hasData}, connectionState: ${snapshot.connectionState})');
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: status.color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
},
);
}
}

View File

@@ -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<EquipmentModel> missingEquipment;
final List<EventEquipment> 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,
),
),
),
],
),
],
),
),
);
}
}

View File

@@ -24,8 +24,22 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
.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,13 +131,16 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
Row(
children: [
Icon(
Icons.inventory_2,
_getIconForConflict(firstConflict),
color: isRemoved ? Colors.grey : AppColors.rouge,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
firstConflict.equipmentName,
style: TextStyle(
fontWeight: FontWeight.bold,
@@ -131,6 +148,21 @@ class _EquipmentConflictDialogState extends State<EquipmentConflictDialog> {
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)
Container(

View File

@@ -213,13 +213,41 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
setState(() => _isLoadingConflicts = true);
try {
print('[EquipmentSelectionDialog] Loading equipment conflicts...');
final equipmentProvider = context.read<EquipmentProvider>();
final equipment = await equipmentProvider.equipmentStream.first;
print('[EquipmentSelectionDialog] Checking conflicts for ${equipment.length} equipments');
for (var eq in equipment) {
// Pour les consommables/câbles, vérifier avec gestion de quantité
if (eq.hasQuantity) {
// Récupérer la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: eq,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
// 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,
@@ -229,12 +257,10 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
);
if (conflicts.isNotEmpty) {
print('[EquipmentSelectionDialog] Found ${conflicts.length} conflict(s) for ${eq.id}');
_equipmentConflicts[eq.id] = conflicts;
}
}
print('[EquipmentSelectionDialog] Total equipments with conflicts: ${_equipmentConflicts.length}');
}
} catch (e) {
print('[EquipmentSelectionDialog] Error loading conflicts: $e');
} finally {
@@ -247,14 +273,57 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
try {
print('[EquipmentSelectionDialog] Loading container conflicts...');
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
final containers = await containerProvider.containersStream.first;
final allEquipment = await equipmentProvider.equipmentStream.first;
print('[EquipmentSelectionDialog] Checking conflicts for ${containers.length} containers');
for (var container in containers) {
// Vérifier d'abord si la boîte complète est utilisée ailleurs
final containerEquipment = allEquipment
.where((eq) => container.equipmentIds.contains(eq.id))
.toList();
final containerConflicts = await _availabilityService.checkContainerAvailability(
container: container,
containerEquipment: containerEquipment,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
if (containerConflicts.isNotEmpty) {
// Déterminer le statut en fonction du type de conflit
final hasFullConflict = containerConflicts.any(
(c) => c.type == ConflictType.containerFullyUsed,
);
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 = <String>[];
// Vérifier chaque équipement enfant
for (var equipmentId in container.equipmentIds) {
if (_equipmentConflicts.containsKey(equipmentId)) {
conflictingChildren.add(equipmentId);
@@ -275,6 +344,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
}
}
print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
} catch (e) {

View File

@@ -62,10 +62,6 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
setState(() => _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<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
@@ -73,20 +69,11 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final equipment = await equipmentProvider.equipmentStream.first;
final containers = await containerProvider.containersStream.first;
print('[EventAssignedEquipmentSection] Available equipment count: ${equipment.length}');
print('[EventAssignedEquipmentSection] Available containers count: ${containers.length}');
// Créer le cache des équipements
for (var eq in widget.assignedEquipment) {
print('[EventAssignedEquipmentSection] Looking for equipment: ${eq.equipmentId}');
final equipmentItem = equipment.firstWhere(
(e) {
print('[EventAssignedEquipmentSection] Comparing "${e.id}" with "${eq.equipmentId}"');
return e.id == eq.equipmentId;
},
orElse: () {
print('[EventAssignedEquipmentSection] Equipment NOT FOUND: ${eq.equipmentId}');
return EquipmentModel(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
id: eq.equipmentId,
name: 'Équipement inconnu',
category: EquipmentCategory.other,
@@ -95,24 +82,16 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
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(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Conteneur inconnu',
type: ContainerType.flightCase,
@@ -120,15 +99,10 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
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<EventAssignedEquipmentSe
}
}
// Charger les équipements des conteneurs pour vérifier les conflits
// Charger les équipements et conteneurs
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
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 = <String, String>{};
final allConflicts = <String, List<AvailabilityConflict>>{};
// 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<EventAssignedEquipmentSe
updatedAt: DateTime.now(),
),
);
equipmentNames[eq.equipmentId] = equipment.id;
// Pour les équipements quantifiables (consommables/câbles)
if (equipment.hasQuantity) {
// Vérifier la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: equipment,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// ⚠️ Ne créer un conflit QUE si la quantité demandée est supérieure à la quantité disponible
if (eq.quantity > availableQty) {
// Il y a vraiment un conflit de quantité
final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity(
equipment: equipment,
requestedQuantity: eq.quantity,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// Ne garder que les conflits réels (quand il n'y a pas assez de stock)
if (conflicts.isNotEmpty) {
allConflicts[eq.equipmentId] = conflicts;
}
}
// ✅ Sinon, pas de conflit : il y a assez de stock disponible
} else {
// Pour les équipements non quantifiables (vérification classique)
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
if (conflicts.isNotEmpty) {
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,15 +230,12 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
),
);
// Ajouter tous les équipements enfants pour vérification
for (var childEquipmentId in container.equipmentIds) {
if (!equipmentIds.contains(childEquipmentId)) {
equipmentIds.add(childEquipmentId);
final equipment = allEquipment.firstWhere(
(e) => e.id == childEquipmentId,
// Récupérer les équipements de la boîte
final containerEquipment = container.equipmentIds
.map((eqId) => allEquipment.firstWhere(
(e) => e.id == eqId,
orElse: () => EquipmentModel(
id: childEquipmentId,
id: eqId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
@@ -234,59 +244,91 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
equipmentNames[childEquipmentId] = '${equipment.id} (dans ${container.name})';
}
}
}
))
.toList();
// Vérifier les conflits pour TOUS les équipements (directs + enfants)
final conflicts = await _availabilityService.checkMultipleEquipmentAvailability(
equipmentIds: equipmentIds,
equipmentNames: equipmentNames,
// Vérifier chaque équipement de la boîte individuellement
final containerConflicts = <AvailabilityConflict>[];
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,
);
if (conflicts.isNotEmpty) {
// 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;
}
}
if (allConflicts.isNotEmpty) {
// Afficher le dialog de conflits
final action = await showDialog<String>(
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 = <String>[];
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<EventAssignedEquipmentSe
final updatedEquipment = [...widget.assignedEquipment];
final updatedContainers = [...widget.assignedContainers];
// Pour chaque nouvel équipement
for (var eq in newEquipment) {
if (!updatedEquipment.any((e) => 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<EventAssignedEquipmentSe
return true;
}).toList();
print('[EventAssignedEquipmentSection] Removing container $containerId');
if (container != null) {
print('[EventAssignedEquipmentSection] Removing ${container.equipmentIds.length} children: ${container.equipmentIds}');
}
print('[EventAssignedEquipmentSection] Equipment before: ${widget.assignedEquipment.length}, after: ${updatedEquipment.length}');
// Notifier le changement avec les deux listes mises à jour
widget.onChanged(updatedEquipment, updatedContainers);

View File

@@ -22,7 +22,6 @@ class UserCard extends StatefulWidget {
class _UserCardState extends State<UserCard> {
ImageProvider? _profileImage;
String? _lastUrl;
bool _isLoadingImage = false;
@override
@@ -44,7 +43,6 @@ class _UserCardState extends State<UserCard> {
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<UserCard> {
setState(() {
_profileImage = null;
_isLoadingImage = false;
_lastUrl = null;
});
}
}