Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.
**Changements Backend (Cloud Functions) :**
- **Nouveaux Endpoints Paginés :**
- `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
- Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
- La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
- **Optimisation de `getContainersPaginated` :**
- Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
- **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
- **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.
**Changements Frontend (Flutter) :**
- **`EquipmentProvider` et `ContainerProvider` :**
- La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
- Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
- Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
- Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
- **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
- Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
- Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
- Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
- **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
- Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
- Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
- La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
- **Optimisations diverses :**
- Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
- Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.
**Correction mineure :**
- **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
2595 lines
97 KiB
Dart
2595 lines
97 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
|
|
Map<String, int> _availableQuantities = {}; // Pour consommables
|
|
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
|
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 _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;
|
|
List<EquipmentModel> _paginatedEquipments = [];
|
|
List<ContainerModel> _paginatedContainers = [];
|
|
|
|
// Cache pour éviter les rebuilds inutiles
|
|
List<ContainerModel> _cachedContainers = [];
|
|
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: Container(
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Header de section repliable
|
|
Widget _buildCollapsibleSectionHeader(
|
|
String title,
|
|
IconData icon,
|
|
int count,
|
|
bool isExpanded,
|
|
Function(bool) onToggle,
|
|
) {
|
|
return InkWell(
|
|
onTap: () => onToggle(!isExpanded),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: AppColors.rouge.withValues(alpha: 0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
isExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_right,
|
|
color: AppColors.rouge,
|
|
size: 24,
|
|
),
|
|
const SizedBox(width: 8),
|
|
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: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.rouge,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'$count',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 13,
|
|
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 Container(
|
|
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
|
|
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();
|
|
}).toList(),
|
|
],
|
|
);
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|