Fix : écran d'ajout de materiel, correction des conflits pour les cables et consommables

This commit is contained in:
ElPoyo
2026-01-13 18:50:46 +01:00
parent 272b4bc9c9
commit 4545bdba81
2 changed files with 403 additions and 138 deletions

View File

@@ -6,6 +6,8 @@ import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/event_availability_service.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/colors.dart';
/// Type de sélection dans le dialog
@@ -87,16 +89,23 @@ class EquipmentSelectionDialog extends StatefulWidget {
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final TextEditingController _searchController = TextEditingController();
final EventAvailabilityService _availabilityService = EventAvailabilityService();
final DataService _dataService = DataService(apiService);
EquipmentCategory? _selectedCategory;
Map<String, SelectedItem> _selectedItems = {};
Map<String, int> _availableQuantities = {}; // Pour consommables
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
Set<String> _expandedContainers = {}; // Conteneurs dépliés dans la liste
// NOUVEAU : IDs en conflit récupérés en batch
Set<String> _conflictingEquipmentIds = {};
Set<String> _conflictingContainerIds = {};
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
bool _isLoadingQuantities = false;
bool _isLoadingConflicts = false;
String _searchQuery = '';
@@ -111,7 +120,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
_initializeAlreadyAssigned();
_loadAvailableQuantities();
_loadEquipmentConflicts();
_loadContainerConflicts();
});
}
@@ -224,59 +232,57 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
}
/// Charge les conflits de disponibilité pour tous les équipements
/// Charge les conflits de disponibilité pour tous les équipements et conteneurs
/// Version optimisée : un seul appel API au lieu d'un par équipement
Future<void> _loadEquipmentConflicts() async {
setState(() => _isLoadingConflicts = true);
try {
final equipmentProvider = context.read<EquipmentProvider>();
final equipment = await equipmentProvider.equipmentStream.first;
print('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
for (var eq in equipment) {
// Pour les consommables/câbles, vérifier avec gestion de quantité
if (eq.hasQuantity) {
// Récupérer la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: eq,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
final startTime = DateTime.now();
// Vérifier si un item de cet équipement est déjà sélectionné
final selectedItem = _selectedItems[eq.id];
final requestedQty = selectedItem?.quantity ?? 1;
// UN SEUL appel API pour récupérer TOUS les équipements en conflit
final result = await _dataService.getConflictingEquipmentIds(
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
installationTime: 0, // TODO: Récupérer depuis l'événement si nécessaire
disassemblyTime: 0,
);
// ✅ Ne créer un conflit QUE si la quantité demandée dépasse la quantité disponible
if (requestedQty > availableQty) {
final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity(
equipment: eq,
requestedQuantity: requestedQty,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
final endTime = DateTime.now();
final duration = endTime.difference(startTime);
if (conflicts.isNotEmpty) {
_equipmentConflicts[eq.id] = conflicts;
}
}
// Sinon, pas de conflit à afficher dans la liste
} else {
// Pour les équipements non quantifiables, vérification classique
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: eq.id,
equipmentName: eq.id,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
print('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
if (conflicts.isNotEmpty) {
_equipmentConflicts[eq.id] = conflicts;
}
}
// Extraire les IDs en conflit
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictingContainerIds = (result['conflictingContainerIds'] as List<dynamic>?)
?.map((e) => e.toString())
.toSet() ?? {};
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
print('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
print('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
if (mounted) {
setState(() {
_conflictingEquipmentIds = conflictingEquipmentIds;
_conflictingContainerIds = conflictingContainerIds;
_conflictDetails = conflictDetails;
_equipmentQuantities = equipmentQuantities;
});
}
// Mettre à jour les statuts de conteneurs
await _updateContainerConflictStatus();
} catch (e) {
print('[EquipmentSelectionDialog] Error loading conflicts: $e');
} finally {
@@ -284,90 +290,277 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}
}
/// Charge les conflits de disponibilité pour tous les conteneurs
Future<void> _loadContainerConflicts() async {
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
Future<void> _updateContainerConflictStatus() async {
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))
// Vérifier si le conteneur lui-même est en conflit
if (_conflictingContainerIds.contains(container.id)) {
_containerConflicts[container.id] = ContainerConflictInfo(
status: ContainerConflictStatus.complete,
conflictingEquipmentIds: [],
totalChildren: container.equipmentIds.length,
);
continue;
}
// Vérifier si des équipements enfants sont en conflit
final conflictingChildren = container.equipmentIds
.where((eqId) => _conflictingEquipmentIds.contains(eqId))
.toList();
final containerConflicts = await _availabilityService.checkContainerAvailability(
container: container,
containerEquipment: containerEquipment,
startDate: widget.startDate,
endDate: widget.endDate,
excludeEventId: widget.excludeEventId,
);
if (conflictingChildren.isNotEmpty) {
final status = conflictingChildren.length == container.equipmentIds.length
? ContainerConflictStatus.complete
: ContainerConflictStatus.partial;
if (containerConflicts.isNotEmpty) {
// Déterminer le statut en fonction du type de conflit
final hasFullConflict = containerConflicts.any(
(c) => c.type == ConflictType.containerFullyUsed,
_containerConflicts[container.id] = ContainerConflictInfo(
status: status,
conflictingEquipmentIds: conflictingChildren,
totalChildren: container.equipmentIds.length,
);
final conflictingChildren = containerConflicts
.where((c) => c.type != ConflictType.containerFullyUsed &&
c.type != ConflictType.containerPartiallyUsed)
.map((c) => c.equipmentId)
.toList();
final status = hasFullConflict
? ContainerConflictStatus.complete
: (conflictingChildren.isNotEmpty
? ContainerConflictStatus.partial
: ContainerConflictStatus.none);
if (status != ContainerConflictStatus.none) {
_containerConflicts[container.id] = ContainerConflictInfo(
status: status,
conflictingEquipmentIds: conflictingChildren,
totalChildren: container.equipmentIds.length,
);
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict');
}
} else {
// Vérifier chaque équipement enfant individuellement
final conflictingChildren = <String>[];
for (var equipmentId in container.equipmentIds) {
if (_equipmentConflicts.containsKey(equipmentId)) {
conflictingChildren.add(equipmentId);
}
}
if (conflictingChildren.isNotEmpty) {
final status = conflictingChildren.length == container.equipmentIds.length
? ContainerConflictStatus.complete
: ContainerConflictStatus.partial;
_containerConflicts[container.id] = ContainerConflictInfo(
status: status,
conflictingEquipmentIds: conflictingChildren,
totalChildren: container.equipmentIds.length,
);
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
}
}
print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
} catch (e) {
print('[EquipmentSelectionDialog] Error loading container conflicts: $e');
print('[EquipmentSelectionDialog] Error updating container conflicts: $e');
}
}
/// Récupère les détails des conflits pour un équipement/conteneur donné
List<Map<String, dynamic>> _getConflictDetailsFor(String id) {
final details = _conflictDetails[id];
if (details == null) return [];
if (details is List) {
return details.cast<Map<String, dynamic>>();
}
return [];
}
/// Construit l'affichage des quantités pour les câbles/consommables
Widget _buildQuantityInfo(EquipmentModel equipment) {
final quantityInfo = _equipmentQuantities[equipment.id] as Map<String, dynamic>?;
if (quantityInfo == null) {
// Pas d'info de quantité, utiliser l'ancien système (availableQuantities)
final availableQty = _availableQuantities[equipment.id];
if (availableQty == null) return const SizedBox.shrink();
return Text(
'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}',
style: TextStyle(
color: availableQty > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
);
}
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final reservedQuantity = quantityInfo['reservedQuantity'] as int? ?? 0;
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
return Row(
children: [
Text(
'Disponible : $availableQuantity/$totalQuantity $unit',
style: TextStyle(
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
if (reservations.isNotEmpty) ...[
const SizedBox(width: 6),
GestureDetector(
onTap: () => _showQuantityDetailsDialog(equipment, quantityInfo),
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
shape: BoxShape.circle,
),
child: Icon(
Icons.info_outline,
size: 16,
color: Colors.blue.shade700,
),
),
),
],
],
);
}
/// Affiche un dialog avec les détails des réservations de quantité
Future<void> _showQuantityDetailsDialog(EquipmentModel equipment, Map<String, dynamic> quantityInfo) async {
final reservations = quantityInfo['reservations'] as List<dynamic>? ?? [];
final totalQuantity = quantityInfo['totalQuantity'] as int? ?? 0;
final availableQuantity = quantityInfo['availableQuantity'] as int? ?? 0;
final unit = equipment.category == EquipmentCategory.cable ? "m" : "";
await showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Icon(Icons.inventory_2, color: Colors.blue.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
'Quantités - ${equipment.name}',
style: const TextStyle(fontSize: 18),
),
),
],
),
content: SizedBox(
width: 500,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Résumé
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Quantité totale :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$totalQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.blue.shade900,
),
),
],
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Disponible :',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade800,
),
),
Text(
'$availableQuantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: availableQuantity > 0 ? Colors.green.shade700 : Colors.red,
fontSize: 16,
),
),
],
),
],
),
),
const SizedBox(height: 16),
// Liste des réservations
if (reservations.isNotEmpty) ...[
Text(
'Utilisé sur ${reservations.length} événement(s) :',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 300),
child: SingleChildScrollView(
child: Column(
children: reservations.map((reservation) {
final res = reservation as Map<String, dynamic>;
final eventName = res['eventName'] as String? ?? 'Événement inconnu';
final quantity = res['quantity'] as int? ?? 0;
final viaContainer = res['viaContainer'] as String?;
final viaContainerName = res['viaContainerName'] as String?;
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: ListTile(
dense: true,
leading: CircleAvatar(
backgroundColor: Colors.orange.shade100,
child: Text(
'$quantity',
style: TextStyle(
color: Colors.orange.shade900,
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
eventName,
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: viaContainer != null
? Text(
'Via ${viaContainerName ?? viaContainer}',
style: const TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
),
)
: null,
trailing: Text(
'$quantity $unit',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.grey.shade700,
),
),
),
);
}).toList(),
),
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
),
);
}
/// Recherche les conteneurs recommandés pour un équipement
Future<void> _findRecommendedContainers(String equipmentId) async {
try {
@@ -398,7 +591,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
void _toggleSelection(String id, String name, SelectionType type, {int? maxQuantity, bool force = false}) async {
// Vérifier si l'équipement est en conflit
if (!force && type == SelectionType.equipment && _equipmentConflicts.containsKey(id)) {
if (!force && type == SelectionType.equipment && _conflictingEquipmentIds.contains(id)) {
// Demander confirmation pour forcer
final shouldForce = await _showForceConfirmationDialog(id);
if (shouldForce == true) {
@@ -822,6 +1015,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
final allContainers = containerSnapshot.data ?? [];
final allEquipment = equipmentSnapshot.data ?? [];
// Charger les conflits une seule fois après le chargement des données
if (!_isLoadingConflicts && _conflictingEquipmentIds.isEmpty && allEquipment.isNotEmpty) {
// Lancer le chargement des conflits en arrière-plan
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadEquipmentConflicts();
});
}
// Filtrage des boîtes
final filteredContainers = allContainers.where((container) {
if (_searchQuery.isNotEmpty) {
@@ -948,8 +1149,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
equipment.category == EquipmentCategory.cable;
final availableQty = _availableQuantities[equipment.id];
final selectedItem = _selectedItems[equipment.id];
final hasConflict = _equipmentConflicts.containsKey(equipment.id);
final conflicts = _equipmentConflicts[equipment.id] ?? [];
final hasConflict = _conflictingEquipmentIds.contains(equipment.id); // CORRECTION ICI !
final conflictDetails = _getConflictDetailsFor(equipment.id);
// Bloquer la sélection si en conflit et non forcé
final canSelect = !hasConflict || isSelected;
@@ -1094,17 +1295,10 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
}).toList(),
),
),
if (isConsumable && availableQty != null)
if (isConsumable)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}',
style: TextStyle(
color: availableQty > 0 ? Colors.green : Colors.red,
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
child: _buildQuantityInfo(equipment),
),
],
),
@@ -1134,7 +1328,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
Icon(Icons.info_outline, size: 16, color: Colors.orange.shade900),
const SizedBox(width: 6),
Text(
'Utilisé sur ${conflicts.length} événement(s) :',
'Utilisé sur ${conflictDetails.length} événement(s) :',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
@@ -1144,21 +1338,44 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
],
),
const SizedBox(height: 6),
...conflicts.take(2).map((conflict) => Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'${conflict.conflictingEvent.name} (${conflict.overlapDays} jour(s))',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
...conflictDetails.take(2).map((detail) {
final eventName = detail['eventName'] as String? ?? 'Événement inconnu';
final viaContainer = detail['viaContainer'] as String?;
final viaContainerName = detail['viaContainerName'] as String?;
return Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$eventName',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,
),
),
if (viaContainer != null)
Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'via ${viaContainerName ?? viaContainer}',
style: TextStyle(
fontSize: 10,
color: Colors.grey.shade600,
fontStyle: FontStyle.italic,
),
),
),
],
),
),
)),
if (conflicts.length > 2)
);
}),
if (conflictDetails.length > 2)
Padding(
padding: const EdgeInsets.only(left: 22, top: 4),
child: Text(
'... et ${conflicts.length - 2} autre(s)',
'... et ${conflictDetails.length - 2} autre(s)',
style: TextStyle(
fontSize: 11,
color: Colors.orange.shade800,