2535 lines
95 KiB
Dart
2535 lines
95 KiB
Dart
import 'package:em2rp/utils/debug_log.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/models/container_model.dart';
|
|
import 'package:em2rp/models/event_model.dart';
|
|
import 'package:em2rp/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
|
|
enum SelectionType { equipment, container }
|
|
|
|
/// Statut de conflit pour un conteneur
|
|
enum ContainerConflictStatus {
|
|
none, // Aucun conflit
|
|
partial, // Au moins un enfant en conflit
|
|
complete, // Tous les enfants en conflit
|
|
}
|
|
|
|
/// Informations sur les conflits d'un conteneur
|
|
class ContainerConflictInfo {
|
|
final ContainerConflictStatus status;
|
|
final List<String> conflictingEquipmentIds;
|
|
final int totalChildren;
|
|
|
|
ContainerConflictInfo({
|
|
required this.status,
|
|
required this.conflictingEquipmentIds,
|
|
required this.totalChildren,
|
|
});
|
|
|
|
String get description {
|
|
if (status == ContainerConflictStatus.none) return '';
|
|
if (status == ContainerConflictStatus.complete) {
|
|
return 'Tous les équipements sont déjà utilisés';
|
|
}
|
|
return '${conflictingEquipmentIds.length}/$totalChildren équipement(s) déjà utilisé(s)';
|
|
}
|
|
}
|
|
|
|
/// Item sélectionné (équipement ou conteneur)
|
|
class SelectedItem {
|
|
final String id;
|
|
final String name;
|
|
final SelectionType type;
|
|
final int quantity; // Pour consommables/câbles
|
|
|
|
SelectedItem({
|
|
required this.id,
|
|
required this.name,
|
|
required this.type,
|
|
this.quantity = 1,
|
|
});
|
|
|
|
SelectedItem copyWith({int? quantity}) {
|
|
return SelectedItem(
|
|
id: id,
|
|
name: name,
|
|
type: type,
|
|
quantity: quantity ?? this.quantity,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Dialog complet de sélection de matériel pour un événement
|
|
class EquipmentSelectionDialog extends StatefulWidget {
|
|
final DateTime startDate;
|
|
final DateTime endDate;
|
|
final List<EventEquipment> alreadyAssigned;
|
|
final List<String> alreadyAssignedContainers;
|
|
final String? excludeEventId;
|
|
|
|
const EquipmentSelectionDialog({
|
|
super.key,
|
|
required this.startDate,
|
|
required this.endDate,
|
|
this.alreadyAssigned = const [],
|
|
this.alreadyAssignedContainers = const [],
|
|
this.excludeEventId,
|
|
});
|
|
|
|
@override
|
|
State<EquipmentSelectionDialog> createState() => _EquipmentSelectionDialogState();
|
|
}
|
|
|
|
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll
|
|
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
|
final DataService _dataService = DataService(apiService);
|
|
|
|
EquipmentCategory? _selectedCategory;
|
|
|
|
Map<String, SelectedItem> _selectedItems = {};
|
|
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
|
final Map<String, int> _availableQuantities = {}; // Pour consommables
|
|
final Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
|
final Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
|
final Map<String, ContainerConflictInfo> _containerConflicts = {}; // Conflits des conteneurs
|
|
final 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 _isLoadingConflicts = false;
|
|
String _searchQuery = '';
|
|
|
|
// Nouvelles options d'affichage
|
|
bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit
|
|
|
|
// NOUVEAU : Lazy loading et pagination
|
|
SelectionType _displayType = SelectionType.equipment; // Type affiché (équipements OU containers)
|
|
bool _isLoadingMore = false;
|
|
bool _hasMoreEquipments = true;
|
|
bool _hasMoreContainers = true;
|
|
String? _lastEquipmentId;
|
|
String? _lastContainerId;
|
|
final List<EquipmentModel> _paginatedEquipments = [];
|
|
final List<ContainerModel> _paginatedContainers = [];
|
|
|
|
// Cache pour éviter les rebuilds inutiles
|
|
final List<ContainerModel> _cachedContainers = [];
|
|
final List<EquipmentModel> _cachedEquipment = [];
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// Ajouter le listener de scroll pour lazy loading
|
|
_scrollController.addListener(_onScroll);
|
|
|
|
// Charger immédiatement les données de manière asynchrone
|
|
_initializeData();
|
|
}
|
|
|
|
/// Gestion du scroll pour lazy loading
|
|
void _onScroll() {
|
|
if (_isLoadingMore) return;
|
|
|
|
if (_scrollController.hasClients &&
|
|
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
|
// Charger la page suivante selon le type affiché
|
|
if (_displayType == SelectionType.equipment && _hasMoreEquipments) {
|
|
_loadNextEquipmentPage();
|
|
} else if (_displayType == SelectionType.container && _hasMoreContainers) {
|
|
_loadNextContainerPage();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Initialise toutes les données nécessaires
|
|
Future<void> _initializeData() async {
|
|
try {
|
|
// 1. Charger les conflits (batch optimisé)
|
|
await _loadEquipmentConflicts();
|
|
|
|
// 2. Initialiser la sélection avec le matériel déjà assigné
|
|
await _initializeAlreadyAssigned();
|
|
|
|
// 3. Charger la première page selon le type sélectionné
|
|
if (_displayType == SelectionType.equipment) {
|
|
await _loadNextEquipmentPage();
|
|
} else {
|
|
await _loadNextContainerPage();
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error initializing data', e);
|
|
}
|
|
}
|
|
|
|
/// Initialise la sélection avec le matériel déjà assigné
|
|
Future<void> _initializeAlreadyAssigned() async {
|
|
final Map<String, SelectedItem> initialSelection = {};
|
|
|
|
// Ajouter les équipements déjà assignés
|
|
for (var eq in widget.alreadyAssigned) {
|
|
initialSelection[eq.equipmentId] = SelectedItem(
|
|
id: eq.equipmentId,
|
|
name: eq.equipmentId,
|
|
type: SelectionType.equipment,
|
|
quantity: eq.quantity,
|
|
);
|
|
}
|
|
|
|
// Ajouter les conteneurs déjà assignés
|
|
if (widget.alreadyAssignedContainers.isNotEmpty) {
|
|
try {
|
|
// Pour les conteneurs déjà assignés, on va les chercher via l'API si nécessaire
|
|
// ou créer des conteneurs temporaires
|
|
for (var containerId in widget.alreadyAssignedContainers) {
|
|
// Chercher dans le cache ou créer un conteneur temporaire
|
|
final container = _cachedContainers.firstWhere(
|
|
(c) => c.id == containerId,
|
|
orElse: () => ContainerModel(
|
|
id: containerId,
|
|
name: 'Conteneur $containerId',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
initialSelection[containerId] = SelectedItem(
|
|
id: containerId,
|
|
name: container.name,
|
|
type: SelectionType.container,
|
|
);
|
|
|
|
// Charger le cache des enfants
|
|
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
|
|
|
|
// Ajouter les enfants comme sélectionnés aussi
|
|
for (var equipmentId in container.equipmentIds) {
|
|
if (!initialSelection.containsKey(equipmentId)) {
|
|
initialSelection[equipmentId] = SelectedItem(
|
|
id: equipmentId,
|
|
name: equipmentId,
|
|
type: SelectionType.equipment,
|
|
quantity: 1,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e);
|
|
}
|
|
}
|
|
|
|
// Mettre à jour la sélection et notifier
|
|
if (mounted && initialSelection.isNotEmpty) {
|
|
setState(() {
|
|
_selectedItems = initialSelection;
|
|
});
|
|
_selectionChangeNotifier.value++;
|
|
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
|
}
|
|
}
|
|
|
|
/// Charge la page suivante d'équipements (lazy loading)
|
|
Future<void> _loadNextEquipmentPage() async {
|
|
if (_isLoadingMore || !_hasMoreEquipments) return;
|
|
|
|
setState(() => _isLoadingMore = true);
|
|
|
|
try {
|
|
final result = await _dataService.getEquipmentsPaginated(
|
|
limit: 25,
|
|
startAfter: _lastEquipmentId,
|
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
|
category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null,
|
|
sortBy: 'id',
|
|
sortOrder: 'asc',
|
|
);
|
|
|
|
final newEquipments = (result['equipments'] as List<dynamic>)
|
|
.map((data) {
|
|
final map = data as Map<String, dynamic>;
|
|
final id = map['id'] as String;
|
|
return EquipmentModel.fromMap(map, id);
|
|
})
|
|
.toList();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_paginatedEquipments.addAll(newEquipments);
|
|
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
|
_lastEquipmentId = result['lastVisible'] as String?;
|
|
_isLoadingMore = false;
|
|
});
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
|
|
|
// Charger les quantités pour les consommables/câbles de cette page
|
|
await _loadAvailableQuantities(newEquipments);
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e);
|
|
if (mounted) {
|
|
setState(() => _isLoadingMore = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Charge la page suivante de containers (lazy loading)
|
|
Future<void> _loadNextContainerPage() async {
|
|
if (_isLoadingMore || !_hasMoreContainers) return;
|
|
|
|
setState(() => _isLoadingMore = true);
|
|
|
|
try {
|
|
final result = await _dataService.getContainersPaginated(
|
|
limit: 25,
|
|
startAfter: _lastContainerId,
|
|
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
|
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
|
sortBy: 'id',
|
|
sortOrder: 'asc',
|
|
);
|
|
|
|
final containersData = result['containers'] as List<dynamic>;
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Raw containers data received: ${containersData.length} containers');
|
|
|
|
// D'abord, extraire TOUS les équipements
|
|
final List<EquipmentModel> allEquipmentsToCache = [];
|
|
for (var data in containersData) {
|
|
final map = data as Map<String, dynamic>;
|
|
final containerId = map['id'] as String;
|
|
|
|
// Debug: vérifier si le champ 'equipment' existe
|
|
final hasEquipmentField = map.containsKey('equipment');
|
|
final equipmentData = map['equipment'];
|
|
DebugLog.info('[EquipmentSelectionDialog] Container $containerId: hasEquipmentField=$hasEquipmentField, equipmentData type=${equipmentData?.runtimeType}, count=${equipmentData is List ? equipmentData.length : 0}');
|
|
|
|
final equipmentList = (map['equipment'] as List<dynamic>?)
|
|
?.map((eqData) {
|
|
final eqMap = eqData as Map<String, dynamic>;
|
|
final eqId = eqMap['id'] as String;
|
|
DebugLog.info('[EquipmentSelectionDialog] - Equipment found: $eqId');
|
|
return EquipmentModel.fromMap(eqMap, eqId);
|
|
})
|
|
.toList() ?? [];
|
|
allEquipmentsToCache.addAll(equipmentList);
|
|
}
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Total equipments extracted from containers: ${allEquipmentsToCache.length}');
|
|
|
|
// Créer les containers
|
|
final newContainers = containersData
|
|
.map((data) {
|
|
final map = data as Map<String, dynamic>;
|
|
final id = map['id'] as String;
|
|
return ContainerModel.fromMap(map, id);
|
|
})
|
|
.toList();
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
// Ajouter tous les équipements au cache DANS le setState
|
|
for (var eq in allEquipmentsToCache) {
|
|
if (!_cachedEquipment.any((e) => e.id == eq.id)) {
|
|
_cachedEquipment.add(eq);
|
|
}
|
|
}
|
|
|
|
_paginatedContainers.addAll(newContainers);
|
|
_hasMoreContainers = result['hasMore'] as bool? ?? false;
|
|
_lastContainerId = result['lastVisible'] as String?;
|
|
_isLoadingMore = false;
|
|
});
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers');
|
|
DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
|
|
|
// Mettre à jour les statuts de conflit pour les nouveaux containers
|
|
await _updateContainerConflictStatus();
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e);
|
|
if (mounted) {
|
|
setState(() => _isLoadingMore = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Recharge depuis le début (appelé lors d'un changement de filtre/recherche)
|
|
Future<void> _reloadData() async {
|
|
setState(() {
|
|
_paginatedEquipments.clear();
|
|
_paginatedContainers.clear();
|
|
_lastEquipmentId = null;
|
|
_lastContainerId = null;
|
|
_hasMoreEquipments = true;
|
|
_hasMoreContainers = true;
|
|
});
|
|
|
|
if (_displayType == SelectionType.equipment) {
|
|
await _loadNextEquipmentPage();
|
|
} else {
|
|
await _loadNextContainerPage();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_scrollController.dispose(); // Nettoyer le ScrollController
|
|
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
|
|
super.dispose();
|
|
}
|
|
|
|
/// Charge les quantités disponibles pour les consommables/câbles d'une liste d'équipements
|
|
Future<void> _loadAvailableQuantities(List<EquipmentModel> equipments) async {
|
|
if (!mounted) return;
|
|
|
|
try {
|
|
final consumables = equipments.where((eq) =>
|
|
eq.category == EquipmentCategory.consumable ||
|
|
eq.category == EquipmentCategory.cable);
|
|
|
|
for (var eq in consumables) {
|
|
// Ne recharger que si on n'a pas déjà la quantité
|
|
if (!_availableQuantities.containsKey(eq.id)) {
|
|
final available = await _availabilityService.getAvailableQuantity(
|
|
equipment: eq,
|
|
startDate: widget.startDate,
|
|
endDate: widget.endDate,
|
|
excludeEventId: widget.excludeEventId,
|
|
);
|
|
_availableQuantities[eq.id] = available;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('Error loading quantities', e);
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
|
|
|
|
final startTime = DateTime.now();
|
|
|
|
// 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,
|
|
);
|
|
|
|
final endTime = DateTime.now();
|
|
final duration = endTime.difference(startTime);
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
|
|
|
|
// 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>? ?? {};
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
|
|
DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_conflictingEquipmentIds = conflictingEquipmentIds;
|
|
_conflictingContainerIds = conflictingContainerIds;
|
|
_conflictDetails = conflictDetails;
|
|
_equipmentQuantities = equipmentQuantities;
|
|
|
|
// Convertir conflictDetails en equipmentConflicts pour l'affichage détaillé
|
|
_equipmentConflicts.clear();
|
|
conflictDetails.forEach((itemId, conflicts) {
|
|
final conflictList = (conflicts as List<dynamic>).map((conflict) {
|
|
final conflictMap = conflict as Map<String, dynamic>;
|
|
|
|
// Créer un EventModel minimal pour le conflit
|
|
final conflictEvent = EventModel(
|
|
id: conflictMap['eventId'] as String,
|
|
name: conflictMap['eventName'] as String,
|
|
description: '',
|
|
startDateTime: DateTime.parse(conflictMap['startDate'] as String),
|
|
endDateTime: DateTime.parse(conflictMap['endDate'] as String),
|
|
basePrice: 0.0,
|
|
installationTime: 0,
|
|
disassemblyTime: 0,
|
|
eventTypeId: '',
|
|
customerId: '',
|
|
address: '',
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
workforce: const [],
|
|
documents: const [],
|
|
options: const [],
|
|
status: EventStatus.confirmed,
|
|
assignedEquipment: const [],
|
|
assignedContainers: const [],
|
|
);
|
|
|
|
// Calculer les jours de chevauchement
|
|
final conflictStart = DateTime.parse(conflictMap['startDate'] as String);
|
|
final conflictEnd = DateTime.parse(conflictMap['endDate'] as String);
|
|
final overlapStart = widget.startDate.isAfter(conflictStart) ? widget.startDate : conflictStart;
|
|
final overlapEnd = widget.endDate.isBefore(conflictEnd) ? widget.endDate : conflictEnd;
|
|
final overlapDays = overlapEnd.difference(overlapStart).inDays + 1;
|
|
|
|
return AvailabilityConflict(
|
|
equipmentId: itemId,
|
|
equipmentName: '', // Sera résolu lors de l'affichage
|
|
conflictingEvent: conflictEvent,
|
|
overlapDays: overlapDays.clamp(1, 999),
|
|
);
|
|
}).toList();
|
|
_equipmentConflicts[itemId] = conflictList;
|
|
});
|
|
});
|
|
}
|
|
|
|
// Mettre à jour les statuts de conteneurs
|
|
await _updateContainerConflictStatus();
|
|
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e);
|
|
} finally {
|
|
if (mounted) setState(() => _isLoadingConflicts = false);
|
|
}
|
|
}
|
|
|
|
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
|
Future<void> _updateContainerConflictStatus() async {
|
|
if (!mounted) return;
|
|
|
|
try {
|
|
// Utiliser les containers paginés chargés
|
|
for (var container in _paginatedContainers) {
|
|
// Vérifier si le conteneur lui-même est en conflit
|
|
if (_conflictingContainerIds.contains(container.id)) {
|
|
_containerConflicts[container.id] = ContainerConflictInfo(
|
|
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();
|
|
|
|
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,
|
|
);
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
|
|
}
|
|
}
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
|
|
|
// Déclencher un rebuild pour afficher les changements visuels
|
|
if (mounted) {
|
|
setState(() {});
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
|
|
}
|
|
}
|
|
|
|
/// 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',
|
|
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 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
|
|
/// NOTE: Désactivé avec le lazy loading - on ne charge pas tous les containers d'un coup
|
|
Future<void> _findRecommendedContainers(String equipmentId) async {
|
|
// Désactivé pour le moment avec le lazy loading
|
|
// On pourrait implémenter une API dédiée si nécessaire
|
|
return;
|
|
}
|
|
|
|
/// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache
|
|
List<ContainerModel> _getParentContainers(String equipmentId) {
|
|
return _recommendedContainers[equipmentId] ?? [];
|
|
}
|
|
|
|
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 && _conflictingEquipmentIds.contains(id)) {
|
|
// Demander confirmation pour forcer
|
|
final shouldForce = await _showForceConfirmationDialog(id);
|
|
if (shouldForce == true) {
|
|
_toggleSelection(id, name, type, maxQuantity: maxQuantity, force: true);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (_selectedItems.containsKey(id)) {
|
|
// Désélectionner
|
|
DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id');
|
|
DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
|
|
|
|
if (type == SelectionType.container) {
|
|
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
|
|
await _deselectContainerChildren(id);
|
|
}
|
|
|
|
// Mise à jour avec setState pour garantir le rebuild
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedItems.remove(id);
|
|
});
|
|
}
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
|
|
DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
|
|
|
|
// Notifier le changement
|
|
_selectionChangeNotifier.value++;
|
|
} else {
|
|
// Sélectionner
|
|
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
|
|
|
|
// Mise à jour avec setState pour garantir le rebuild
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedItems[id] = SelectedItem(
|
|
id: id,
|
|
name: name,
|
|
type: type,
|
|
quantity: 1,
|
|
);
|
|
});
|
|
}
|
|
|
|
// Si c'est un équipement, chercher les conteneurs recommandés
|
|
if (type == SelectionType.equipment) {
|
|
_findRecommendedContainers(id);
|
|
}
|
|
|
|
// Si c'est un conteneur, sélectionner ses enfants en cascade
|
|
if (type == SelectionType.container) {
|
|
await _selectContainerChildren(id);
|
|
}
|
|
|
|
// Notifier le changement
|
|
_selectionChangeNotifier.value++;
|
|
}
|
|
}
|
|
|
|
/// Sélectionner tous les enfants d'un conteneur
|
|
Future<void> _selectContainerChildren(String containerId) async {
|
|
try {
|
|
// Chercher le container dans les données paginées ou le cache
|
|
final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
|
|
(c) => c.id == containerId,
|
|
orElse: () => ContainerModel(
|
|
id: containerId,
|
|
name: 'Inconnu',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
// Mettre à jour le cache
|
|
_containerEquipmentCache[containerId] = List.from(container.equipmentIds);
|
|
|
|
// Sélectionner chaque enfant (sans bloquer, car ils sont "composés")
|
|
for (var equipmentId in container.equipmentIds) {
|
|
if (!_selectedItems.containsKey(equipmentId)) {
|
|
// Chercher l'équipement dans les données paginées ou le cache
|
|
final eq = [..._paginatedEquipments, ..._cachedEquipment].firstWhere(
|
|
(e) => e.id == equipmentId,
|
|
orElse: () => EquipmentModel(
|
|
id: equipmentId,
|
|
name: 'Inconnu',
|
|
category: EquipmentCategory.other,
|
|
status: EquipmentStatus.available,
|
|
maintenanceIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedItems[equipmentId] = SelectedItem(
|
|
id: equipmentId,
|
|
name: eq.id,
|
|
type: SelectionType.equipment,
|
|
quantity: 1,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Selected container $containerId with ${container.equipmentIds.length} children');
|
|
} catch (e) {
|
|
DebugLog.error('Error selecting container children', e);
|
|
}
|
|
}
|
|
|
|
/// Désélectionner tous les enfants d'un conteneur
|
|
Future<void> _deselectContainerChildren(String containerId) async {
|
|
try {
|
|
// Chercher le container dans les données paginées ou le cache
|
|
final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
|
|
(c) => c.id == containerId,
|
|
orElse: () => ContainerModel(
|
|
id: containerId,
|
|
name: 'Inconnu',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
// Retirer les enfants de _selectedItems
|
|
for (var equipmentId in container.equipmentIds) {
|
|
_selectedItems.remove(equipmentId);
|
|
}
|
|
|
|
// Nettoyer le cache
|
|
_containerEquipmentCache.remove(containerId);
|
|
|
|
// Retirer de la liste des conteneurs expandés
|
|
_expandedContainers.remove(containerId);
|
|
});
|
|
}
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
|
|
} catch (e) {
|
|
DebugLog.error('Error deselecting container children', e);
|
|
}
|
|
}
|
|
|
|
/// Affiche un dialog pour confirmer le forçage d'un équipement en conflit
|
|
Future<bool?> _showForceConfirmationDialog(String equipmentId) async {
|
|
final conflicts = _equipmentConflicts[equipmentId] ?? [];
|
|
|
|
return showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.warning, color: Colors.orange),
|
|
SizedBox(width: 8),
|
|
Text('Équipement déjà utilisé'),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Cet équipement est déjà utilisé sur ${conflicts.length} événement(s) :'),
|
|
const SizedBox(height: 12),
|
|
...conflicts.map((conflict) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.event, size: 16, color: Colors.orange),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
conflict.conflictingEvent.name,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
Text(
|
|
'Chevauchement : ${conflict.overlapDays} jour(s)',
|
|
style: const TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Forcer quand même'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenSize = MediaQuery.of(context).size;
|
|
final dialogWidth = screenSize.width * 0.9;
|
|
final dialogHeight = screenSize.height * 0.85;
|
|
|
|
return Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: SizedBox(
|
|
width: dialogWidth.clamp(600.0, 1200.0),
|
|
height: dialogHeight.clamp(500.0, 900.0),
|
|
child: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
_buildSearchAndFilters(),
|
|
Expanded(
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Liste principale
|
|
Expanded(
|
|
flex: 2,
|
|
child: _buildMainList(),
|
|
),
|
|
|
|
// Panneau latéral : sélection + recommandations
|
|
Container(
|
|
width: 320,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
border: Border(
|
|
left: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Expanded(
|
|
child: ValueListenableBuilder<int>(
|
|
valueListenable: _selectionChangeNotifier,
|
|
builder: (context, _, __) => _buildSelectionPanel(),
|
|
),
|
|
),
|
|
if (_hasRecommendations)
|
|
Container(
|
|
height: 200,
|
|
decoration: BoxDecoration(
|
|
border: Border(
|
|
top: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
),
|
|
child: _buildRecommendationsPanel(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
_buildFooter(),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge,
|
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.add_circle, color: Colors.white, size: 28),
|
|
const SizedBox(width: 12),
|
|
const Expanded(
|
|
child: Text(
|
|
'Ajouter du matériel',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSearchAndFilters() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border(bottom: BorderSide(color: Colors.grey.shade300)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// Barre de recherche
|
|
TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Rechercher du matériel ou des boîtes...',
|
|
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
setState(() => _searchQuery = '');
|
|
},
|
|
)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
),
|
|
onChanged: (value) {
|
|
setState(() => _searchQuery = value.toLowerCase());
|
|
// Recharger depuis le début avec le nouveau filtre
|
|
_reloadData();
|
|
},
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Filtres par catégorie (pour les équipements)
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildFilterChip('Tout', null),
|
|
const SizedBox(width: 8),
|
|
...EquipmentCategory.values.map((category) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: _buildFilterChip(category.label, category),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Checkbox pour afficher les équipements en conflit
|
|
Row(
|
|
children: [
|
|
Checkbox(
|
|
value: _showConflictingItems,
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_showConflictingItems = value ?? false;
|
|
});
|
|
},
|
|
activeColor: AppColors.rouge,
|
|
),
|
|
const Text(
|
|
'Afficher les équipements déjà utilisés',
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: 'Afficher les équipements et boîtes qui sont déjà utilisés durant ces dates',
|
|
child: Icon(
|
|
Icons.info_outline,
|
|
size: 18,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
// Chip pour switcher entre Équipements et Containers
|
|
Row(
|
|
children: [
|
|
const Text(
|
|
'Afficher :',
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ChoiceChip(
|
|
label: const Text('Équipements'),
|
|
selected: _displayType == SelectionType.equipment,
|
|
onSelected: (selected) {
|
|
if (selected && _displayType != SelectionType.equipment) {
|
|
setState(() {
|
|
_displayType = SelectionType.equipment;
|
|
});
|
|
_reloadData();
|
|
}
|
|
},
|
|
selectedColor: AppColors.rouge,
|
|
labelStyle: TextStyle(
|
|
color: _displayType == SelectionType.equipment ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ChoiceChip(
|
|
label: const Text('Containers'),
|
|
selected: _displayType == SelectionType.container,
|
|
onSelected: (selected) {
|
|
if (selected && _displayType != SelectionType.container) {
|
|
setState(() {
|
|
_displayType = SelectionType.container;
|
|
});
|
|
_reloadData();
|
|
}
|
|
},
|
|
selectedColor: AppColors.rouge,
|
|
labelStyle: TextStyle(
|
|
color: _displayType == SelectionType.container ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip(String label, EquipmentCategory? category) {
|
|
final isSelected = _selectedCategory == category;
|
|
|
|
return FilterChip(
|
|
label: Text(label),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
setState(() {
|
|
_selectedCategory = selected ? category : null;
|
|
});
|
|
// Recharger depuis le début avec le nouveau filtre
|
|
_reloadData();
|
|
},
|
|
selectedColor: AppColors.rouge,
|
|
checkmarkColor: Colors.white,
|
|
labelStyle: TextStyle(
|
|
color: isSelected ? Colors.white : Colors.black87,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMainList() {
|
|
// Afficher un indicateur de chargement si les données sont en cours de chargement
|
|
if (_isLoadingConflicts) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const CircularProgressIndicator(color: AppColors.rouge),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Vérification de la disponibilité...',
|
|
style: TextStyle(color: Colors.grey.shade600),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Vue hiérarchique unique : Boîtes en haut, TOUS les équipements en bas
|
|
return _buildHierarchicalList();
|
|
}
|
|
|
|
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
|
|
Widget _buildHierarchicalList() {
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _selectionChangeNotifier,
|
|
builder: (context, _, __) {
|
|
// Filtrer les données paginées selon le type affiché
|
|
List<Widget> itemWidgets = [];
|
|
|
|
if (_displayType == SelectionType.equipment) {
|
|
// Filtrer côté client pour "Afficher équipements déjà utilisés"
|
|
final filteredEquipments = _paginatedEquipments.where((eq) {
|
|
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}).toList();
|
|
|
|
itemWidgets = filteredEquipments.map((equipment) {
|
|
return _buildEquipmentCard(equipment, key: ValueKey('equipment_${equipment.id}'));
|
|
}).toList();
|
|
} else {
|
|
// Containers
|
|
final filteredContainers = _paginatedContainers.where((container) {
|
|
if (!_showConflictingItems) {
|
|
// Vérifier si le container lui-même est en conflit
|
|
if (_conflictingContainerIds.contains(container.id)) {
|
|
return false;
|
|
}
|
|
|
|
// Vérifier si le container a des équipements enfants en conflit
|
|
final hasConflictingChildren = container.equipmentIds.any(
|
|
(eqId) => _conflictingEquipmentIds.contains(eqId),
|
|
);
|
|
|
|
if (hasConflictingChildren) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}).toList();
|
|
|
|
itemWidgets = filteredContainers.map((container) {
|
|
return _buildContainerCard(container, key: ValueKey('container_${container.id}'));
|
|
}).toList();
|
|
}
|
|
|
|
return ListView(
|
|
controller: _scrollController,
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Header
|
|
_buildSectionHeader(
|
|
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
|
|
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
|
|
itemWidgets.length,
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Items
|
|
...itemWidgets,
|
|
|
|
// Indicateur de chargement en bas
|
|
if (_isLoadingMore)
|
|
const Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Center(
|
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
|
),
|
|
),
|
|
|
|
// Message si fin de liste
|
|
if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers))
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Center(
|
|
child: Text(
|
|
'Fin de la liste',
|
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Message si rien trouvé
|
|
if (itemWidgets.isEmpty && !_isLoadingMore)
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
children: [
|
|
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Aucun résultat trouvé',
|
|
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Header de section (version simple, gardée pour compatibilité)
|
|
Widget _buildSectionHeader(String title, IconData icon, int count) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(icon, color: AppColors.rouge, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.rouge,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'$count',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEquipmentCard(EquipmentModel equipment, {Key? key}) {
|
|
final isSelected = _selectedItems.containsKey(equipment.id);
|
|
final isConsumable = equipment.category == EquipmentCategory.consumable ||
|
|
equipment.category == EquipmentCategory.cable;
|
|
final availableQty = _availableQuantities[equipment.id];
|
|
final selectedItem = _selectedItems[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;
|
|
|
|
return RepaintBoundary(
|
|
key: key,
|
|
child: Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
elevation: isSelected ? 4 : 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
side: isSelected
|
|
? const BorderSide(color: AppColors.rouge, width: 2)
|
|
: hasConflict
|
|
? BorderSide(color: Colors.orange.shade300, width: 1)
|
|
: BorderSide.none,
|
|
),
|
|
child: InkWell(
|
|
onTap: canSelect
|
|
? () => _toggleSelection(
|
|
equipment.id,
|
|
equipment.id,
|
|
SelectionType.equipment,
|
|
maxQuantity: availableQty,
|
|
)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
decoration: hasConflict && !isSelected
|
|
? BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: null,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
// Checkbox
|
|
Checkbox(
|
|
value: isSelected,
|
|
onChanged: canSelect
|
|
? (value) => _toggleSelection(
|
|
equipment.id,
|
|
equipment.id,
|
|
SelectionType.equipment,
|
|
maxQuantity: availableQty,
|
|
)
|
|
: null,
|
|
activeColor: AppColors.rouge,
|
|
),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
// Icône
|
|
equipment.category.getIcon(size: 32, color: equipment.category.color),
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
// Infos
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
equipment.id,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
if (hasConflict)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade700,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.warning, size: 14, color: Colors.white),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'Déjà utilisé',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (equipment.brand != null || equipment.model != null)
|
|
Text(
|
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(),
|
|
style: TextStyle(
|
|
color: Colors.grey.shade700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
// Affichage des boîtes parentes
|
|
if (_getParentContainers(equipment.id).isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Wrap(
|
|
spacing: 4,
|
|
runSpacing: 4,
|
|
children: _getParentContainers(equipment.id).map((container) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade50,
|
|
border: Border.all(color: Colors.blue.shade300),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.inventory, size: 12, color: Colors.blue.shade700),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
container.name,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.blue.shade700,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
if (isConsumable)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: _buildQuantityInfo(equipment),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Sélecteur de quantité pour consommables (toujours affiché)
|
|
if (isConsumable && availableQty != null)
|
|
_buildQuantitySelector(
|
|
equipment.id,
|
|
selectedItem ?? SelectedItem(
|
|
id: equipment.id,
|
|
name: equipment.id,
|
|
type: SelectionType.equipment,
|
|
quantity: 0, // Quantité 0 si non sélectionné
|
|
),
|
|
availableQty,
|
|
isSelected: isSelected, // Passer l'état de sélection
|
|
),
|
|
],
|
|
),
|
|
|
|
// Affichage des conflits
|
|
if (hasConflict)
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 12),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.orange.shade300),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.info_outline, size: 16, color: Colors.orange.shade900),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Utilisé sur ${conflictDetails.length} événement(s) :',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.orange.shade900,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
...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 (conflictDetails.length > 2)
|
|
Padding(
|
|
padding: const EdgeInsets.only(left: 22, top: 4),
|
|
child: Text(
|
|
'... et ${conflictDetails.length - 2} autre(s)',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.orange.shade800,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
),
|
|
if (!isSelected)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: TextButton.icon(
|
|
onPressed: () => _toggleSelection(
|
|
equipment.id,
|
|
equipment.id,
|
|
SelectionType.equipment,
|
|
maxQuantity: availableQty,
|
|
force: true,
|
|
),
|
|
icon: const Icon(Icons.warning, size: 16),
|
|
label: const Text('Forcer quand même', style: TextStyle(fontSize: 12)),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: Colors.orange.shade900,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Widget pour le sélecteur de quantité
|
|
/// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1
|
|
Widget _buildQuantitySelector(
|
|
String equipmentId,
|
|
SelectedItem selectedItem,
|
|
int maxQuantity, {
|
|
required bool isSelected,
|
|
}) {
|
|
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
|
|
|
return SizedBox(
|
|
width: 120,
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.remove_circle_outline),
|
|
onPressed: isSelected && selectedItem.quantity > 1
|
|
? () {
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
|
|
});
|
|
_selectionChangeNotifier.value++; // Notifier le changement
|
|
}
|
|
}
|
|
: null,
|
|
iconSize: 20,
|
|
color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey,
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
'$displayQuantity',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: isSelected ? Colors.black : Colors.grey,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.add_circle_outline),
|
|
onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected
|
|
? () {
|
|
if (!isSelected) {
|
|
// Premier clic : sélectionner avec quantité 1
|
|
_toggleSelection(
|
|
equipmentId,
|
|
selectedItem.name,
|
|
SelectionType.equipment,
|
|
maxQuantity: maxQuantity,
|
|
);
|
|
} else {
|
|
// Item déjà sélectionné : incrémenter
|
|
if (mounted) {
|
|
setState(() {
|
|
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
|
|
});
|
|
_selectionChangeNotifier.value++; // Notifier le changement
|
|
}
|
|
}
|
|
}
|
|
: null,
|
|
iconSize: 20,
|
|
color: AppColors.rouge,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
Widget _buildContainerCard(ContainerModel container, {Key? key}) {
|
|
final isSelected = _selectedItems.containsKey(container.id);
|
|
final isExpanded = _expandedContainers.contains(container.id);
|
|
final conflictInfo = _containerConflicts[container.id];
|
|
final hasConflict = conflictInfo != null;
|
|
final isCompleteConflict = conflictInfo?.status == ContainerConflictStatus.complete;
|
|
|
|
// Bloquer la sélection si tous les enfants sont en conflit (sauf si déjà sélectionné)
|
|
final canSelect = !isCompleteConflict || isSelected;
|
|
|
|
return RepaintBoundary(
|
|
key: key,
|
|
child: Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
elevation: isSelected ? 4 : 1,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
side: isSelected
|
|
? const BorderSide(color: AppColors.rouge, width: 2)
|
|
: hasConflict
|
|
? BorderSide(
|
|
color: isCompleteConflict ? Colors.red.shade300 : Colors.orange.shade300,
|
|
width: 1,
|
|
)
|
|
: BorderSide.none,
|
|
),
|
|
child: Container(
|
|
decoration: hasConflict && !isSelected
|
|
? BoxDecoration(
|
|
color: isCompleteConflict ? Colors.red.shade50 : Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
)
|
|
: null,
|
|
child: Column(
|
|
children: [
|
|
InkWell(
|
|
onTap: canSelect
|
|
? () => _toggleSelection(
|
|
container.id,
|
|
container.name,
|
|
SelectionType.container,
|
|
)
|
|
: null,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
// Checkbox
|
|
Checkbox(
|
|
value: isSelected,
|
|
onChanged: canSelect
|
|
? (value) => _toggleSelection(
|
|
container.id,
|
|
container.name,
|
|
SelectionType.container,
|
|
)
|
|
: null,
|
|
activeColor: AppColors.rouge,
|
|
),
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
// Icône du conteneur
|
|
Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: container.type.getIcon(size: 28, color: AppColors.rouge),
|
|
),
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
// Infos principales
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
container.id,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
),
|
|
// Badge de statut de conflit
|
|
if (hasConflict)
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: isCompleteConflict
|
|
? Colors.red.shade700
|
|
: Colors.orange.shade700,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isCompleteConflict ? Icons.block : Icons.warning,
|
|
size: 14,
|
|
color: Colors.white,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
isCompleteConflict ? 'Indisponible' : 'Partiellement utilisée',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
container.name,
|
|
style: TextStyle(
|
|
color: Colors.grey.shade700,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
Icons.inventory_2,
|
|
size: 14,
|
|
color: Colors.blue.shade700,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${container.itemCount} équipement(s)',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.blue,
|
|
),
|
|
),
|
|
if (hasConflict) ...[
|
|
const SizedBox(width: 8),
|
|
Icon(
|
|
Icons.warning,
|
|
size: 14,
|
|
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
conflictInfo.description,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isCompleteConflict ? Colors.red.shade700 : Colors.orange.shade700,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Bouton pour déplier/replier
|
|
IconButton(
|
|
icon: Icon(
|
|
isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
color: AppColors.rouge,
|
|
),
|
|
onPressed: () {
|
|
if (isExpanded) {
|
|
_expandedContainers.remove(container.id);
|
|
} else {
|
|
_expandedContainers.add(container.id);
|
|
}
|
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
|
},
|
|
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
|
|
),
|
|
],
|
|
),
|
|
|
|
// Avertissement pour conteneur complètement indisponible
|
|
if (isCompleteConflict && !isSelected)
|
|
Container(
|
|
margin: const EdgeInsets.only(top: 12),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.red.shade300),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.block, size: 20, color: Colors.red.shade900),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Cette boîte ne peut pas être sélectionnée car tous ses équipements sont déjà utilisés.',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.red.shade900,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// Liste des enfants (si déplié)
|
|
if (isExpanded)
|
|
_buildContainerChildren(container, conflictInfo),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Widget pour afficher les équipements enfants d'un conteneur
|
|
Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) {
|
|
// Utiliser les équipements paginés et le cache
|
|
final allEquipment = [..._paginatedEquipments, ..._cachedEquipment];
|
|
final childEquipments = allEquipment
|
|
.where((eq) => container.equipmentIds.contains(eq.id))
|
|
.toList();
|
|
|
|
DebugLog.info('[EquipmentSelectionDialog] Building container children for ${container.id}: ${container.equipmentIds.length} IDs, found ${childEquipments.length} equipment(s) in cache (total cache: ${_cachedEquipment.length})');
|
|
|
|
if (container.equipmentIds.isNotEmpty && childEquipments.isEmpty) {
|
|
DebugLog.error('[EquipmentSelectionDialog] Container ${container.id} has ${container.equipmentIds.length} equipment IDs but found 0 equipment in cache!');
|
|
DebugLog.info('[EquipmentSelectionDialog] Looking for IDs: ${container.equipmentIds.take(5).join(", ")}...');
|
|
DebugLog.info('[EquipmentSelectionDialog] Cache contains IDs: ${_cachedEquipment.take(5).map((e) => e.id).join(", ")}...');
|
|
}
|
|
|
|
if (childEquipments.isEmpty) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Aucun équipement dans ce conteneur',
|
|
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.list, size: 16, color: Colors.grey.shade700),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Contenu de la boîte :',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.grey.shade700,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
...childEquipments.map((eq) {
|
|
final hasConflict = _equipmentConflicts.containsKey(eq.id);
|
|
final conflicts = _equipmentConflicts[eq.id] ?? [];
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: hasConflict ? Colors.orange.shade50 : Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: hasConflict ? Colors.orange.shade300 : Colors.grey.shade300,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
// Flèche de hiérarchie
|
|
Icon(
|
|
Icons.subdirectory_arrow_right,
|
|
size: 16,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
const SizedBox(width: 8),
|
|
|
|
// Icône de l'équipement
|
|
eq.category.getIcon(size: 20, color: eq.category.color),
|
|
const SizedBox(width: 12),
|
|
|
|
// Nom de l'équipement
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
eq.id,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade800,
|
|
),
|
|
),
|
|
if (eq.brand != null || eq.model != null)
|
|
Text(
|
|
'${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Indicateur de conflit
|
|
if (hasConflict) ...[
|
|
const SizedBox(width: 8),
|
|
Tooltip(
|
|
message: 'Utilisé sur ${conflicts.length} événement(s)',
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade700,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const Icon(Icons.warning, size: 12, color: Colors.white),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${conflicts.length}',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectionPanel() {
|
|
// Compter uniquement les conteneurs et équipements "racine" (pas enfants de conteneurs)
|
|
final selectedContainers = _selectedItems.entries
|
|
.where((e) => e.value.type == SelectionType.container)
|
|
.toList();
|
|
|
|
// Collecter tous les IDs d'équipements qui sont enfants de conteneurs sélectionnés
|
|
final Set<String> equipmentIdsInContainers = {};
|
|
for (var containerEntry in selectedContainers) {
|
|
final childrenIds = _getContainerEquipmentIds(containerEntry.key);
|
|
equipmentIdsInContainers.addAll(childrenIds);
|
|
}
|
|
|
|
// Équipements qui ne sont PAS enfants d'un conteneur sélectionné
|
|
final selectedStandaloneEquipment = _selectedItems.entries
|
|
.where((e) => e.value.type == SelectionType.equipment)
|
|
.where((e) => !equipmentIdsInContainers.contains(e.key))
|
|
.toList();
|
|
|
|
final containerCount = selectedContainers.length;
|
|
final standaloneEquipmentCount = selectedStandaloneEquipment.length;
|
|
final totalDisplayed = containerCount + standaloneEquipmentCount;
|
|
|
|
return Column(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.check_circle, color: Colors.white),
|
|
const SizedBox(width: 8),
|
|
const Text(
|
|
'Sélection',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'$totalDisplayed',
|
|
style: const TextStyle(
|
|
color: AppColors.rouge,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
Expanded(
|
|
child: totalDisplayed == 0
|
|
? const Center(
|
|
child: Text(
|
|
'Aucune sélection',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
)
|
|
: ListView(
|
|
padding: const EdgeInsets.all(8),
|
|
children: [
|
|
if (containerCount > 0) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Text(
|
|
'Boîtes ($containerCount)',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
...selectedContainers.map((e) => _buildSelectedContainerTile(e.key, e.value)),
|
|
],
|
|
if (standaloneEquipmentCount > 0) ...[
|
|
Padding(
|
|
padding: const EdgeInsets.all(8),
|
|
child: Text(
|
|
'Équipements ($standaloneEquipmentCount)',
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 14,
|
|
),
|
|
),
|
|
),
|
|
...selectedStandaloneEquipment.map((e) => _buildSelectedItemTile(e.key, e.value)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
|
|
/// Récupère les IDs des équipements d'un conteneur (depuis le cache)
|
|
List<String> _getContainerEquipmentIds(String containerId) {
|
|
// On doit récupérer le conteneur depuis le provider de manière synchrone
|
|
// Pour cela, on va maintenir un cache local
|
|
return _containerEquipmentCache[containerId] ?? [];
|
|
}
|
|
|
|
// Cache local pour les équipements des conteneurs
|
|
final Map<String, List<String>> _containerEquipmentCache = {};
|
|
|
|
Widget _buildSelectedContainerTile(String id, SelectedItem item) {
|
|
final isExpanded = _expandedContainers.contains(id);
|
|
final childrenIds = _getContainerEquipmentIds(id);
|
|
final childrenCount = childrenIds.length;
|
|
|
|
return Column(
|
|
children: [
|
|
ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
Icons.inventory,
|
|
size: 20,
|
|
color: AppColors.rouge,
|
|
),
|
|
title: Text(
|
|
item.name,
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(
|
|
'$childrenCount équipement(s)',
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (childrenCount > 0)
|
|
IconButton(
|
|
icon: Icon(
|
|
isExpanded ? Icons.expand_less : Icons.expand_more,
|
|
size: 18,
|
|
),
|
|
onPressed: () {
|
|
if (isExpanded) {
|
|
_expandedContainers.remove(id);
|
|
} else {
|
|
_expandedContainers.add(id);
|
|
}
|
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
|
},
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: () => _toggleSelection(id, item.name, item.type),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (isExpanded && childrenCount > 0)
|
|
...childrenIds.map((equipmentId) {
|
|
final childItem = _selectedItems[equipmentId];
|
|
if (childItem != null) {
|
|
return _buildSelectedChildEquipmentTile(equipmentId, childItem);
|
|
}
|
|
return const SizedBox.shrink();
|
|
}),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectedChildEquipmentTile(String id, SelectedItem item) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(left: 40),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
Icons.subdirectory_arrow_right,
|
|
size: 16,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
title: Text(
|
|
item.name,
|
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade700),
|
|
),
|
|
subtitle: item.quantity > 1
|
|
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 10))
|
|
: null,
|
|
// PAS de bouton de suppression pour les enfants
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSelectedItemTile(String id, SelectedItem item) {
|
|
return ListTile(
|
|
dense: true,
|
|
leading: Icon(
|
|
Icons.inventory_2,
|
|
size: 20,
|
|
color: AppColors.rouge,
|
|
),
|
|
title: Text(
|
|
item.name,
|
|
style: const TextStyle(fontSize: 13),
|
|
),
|
|
subtitle: item.quantity > 1
|
|
? Text('Qté: ${item.quantity}', style: const TextStyle(fontSize: 11))
|
|
: null,
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.close, size: 18),
|
|
onPressed: () => _toggleSelection(id, item.name, item.type),
|
|
),
|
|
);
|
|
}
|
|
|
|
bool get _hasRecommendations => _recommendedContainers.isNotEmpty;
|
|
|
|
Widget _buildRecommendationsPanel() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.shade700,
|
|
),
|
|
child: const Row(
|
|
children: [
|
|
Icon(Icons.lightbulb, color: Colors.white, size: 20),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Boîtes recommandées',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(8),
|
|
children: _recommendedContainers.values
|
|
.expand((list) => list)
|
|
.toSet() // Enlever les doublons
|
|
.map((container) => _buildRecommendedContainerTile(container))
|
|
.toList(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRecommendedContainerTile(ContainerModel container) {
|
|
final isAlreadySelected = _selectedItems.containsKey(container.id);
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: ListTile(
|
|
dense: true,
|
|
leading: container.type.getIcon(size: 24, color: Colors.blue.shade700),
|
|
title: Text(
|
|
container.name,
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
subtitle: Text(
|
|
'${container.itemCount} équipement(s)',
|
|
style: const TextStyle(fontSize: 11),
|
|
),
|
|
trailing: isAlreadySelected
|
|
? const Icon(Icons.check_circle, color: Colors.green)
|
|
: IconButton(
|
|
icon: const Icon(Icons.add_circle_outline, color: Colors.blue),
|
|
onPressed: () => _toggleSelection(
|
|
container.id,
|
|
container.name,
|
|
SelectionType.container,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFooter() {
|
|
return ValueListenableBuilder<int>(
|
|
valueListenable: _selectionChangeNotifier,
|
|
builder: (context, _, __) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.grey.withValues(alpha: 0.2),
|
|
spreadRadius: 1,
|
|
blurRadius: 5,
|
|
offset: const Offset(0, -2),
|
|
),
|
|
],
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
'${_selectedItems.length} élément(s) sélectionné(s)',
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
const Spacer(),
|
|
OutlinedButton(
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
child: const Text('Annuler'),
|
|
),
|
|
const SizedBox(width: 12),
|
|
ElevatedButton(
|
|
onPressed: _selectedItems.isEmpty
|
|
? null
|
|
: () => Navigator.of(context).pop(_selectedItems),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
|
),
|
|
child: const Text('Valider la sélection'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|