refactor: Passage à la pagination côté serveur pour les équipements et containers

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.
This commit is contained in:
ElPoyo
2026-01-18 12:40:23 +01:00
parent b79791ff7a
commit a182f1b922
21 changed files with 2069 additions and 1588 deletions

View File

@@ -156,212 +156,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// Charger les équipements et conteneurs
final containerProvider = context.read<ContainerProvider>();
final equipmentProvider = context.read<EquipmentProvider>();
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
// On enregistre directement la sélection
final allContainers = await containerProvider.containersStream.first;
final allEquipment = await equipmentProvider.equipmentStream.first;
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
final allConflicts = <String, List<AvailabilityConflict>>{};
// 1. Vérifier les conflits pour les équipements directs
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
for (var eq in newEquipment) {
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
final equipment = allEquipment.firstWhere(
(e) => e.id == eq.equipmentId,
orElse: () => EquipmentModel(
id: eq.equipmentId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
// Pour les équipements quantifiables (consommables/câbles)
if (equipment.hasQuantity) {
// Vérifier la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: equipment,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// ⚠️ Ne créer un conflit QUE si la quantité demandée est supérieure à la quantité disponible
if (eq.quantity > availableQty) {
// Il y a vraiment un conflit de quantité
final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity(
equipment: equipment,
requestedQuantity: eq.quantity,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// Ne garder que les conflits réels (quand il n'y a pas assez de stock)
if (conflicts.isNotEmpty) {
allConflicts[eq.equipmentId] = conflicts;
}
}
// ✅ Sinon, pas de conflit : il y a assez de stock disponible
} else {
// Pour les équipements non quantifiables (vérification classique)
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
if (conflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
allConflicts[eq.equipmentId] = conflicts;
} else {
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
}
}
}
// 2. Vérifier les conflits pour les boîtes et leur contenu
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
for (var containerId in newContainers) {
final container = allContainers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
// Récupérer les équipements de la boîte
final containerEquipment = container.equipmentIds
.map((eqId) => allEquipment.firstWhere(
(e) => e.id == eqId,
orElse: () => EquipmentModel(
id: eqId,
name: 'Inconnu',
category: EquipmentCategory.other,
status: EquipmentStatus.available,
maintenanceIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
))
.toList();
// Vérifier chaque équipement de la boîte individuellement
final containerConflicts = <AvailabilityConflict>[];
for (var equipment in containerEquipment) {
if (equipment.hasQuantity) {
// Pour les consommables/câbles, vérifier la quantité disponible
final availableQty = await _availabilityService.getAvailableQuantity(
equipment: equipment,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
// La boîte contient 1 unité de cet équipement
// Si la quantité disponible est insuffisante, créer un conflit
if (availableQty < 1) {
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
containerConflicts.addAll(conflicts);
}
} else {
// Pour les équipements non quantifiables
final conflicts = await _availabilityService.checkEquipmentAvailability(
equipmentId: equipment.id,
equipmentName: equipment.name,
startDate: widget.startDate!,
endDate: widget.endDate!,
excludeEventId: widget.eventId,
);
containerConflicts.addAll(conflicts);
}
}
if (containerConflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
allConflicts[containerId] = containerConflicts;
} else {
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
}
}
DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
if (allConflicts.isNotEmpty) {
DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
// Afficher le dialog de conflits
final action = await showDialog<String>(
context: context,
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
);
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
if (action == 'cancel') {
return; // Annuler l'ajout
} else if (action == 'force_removed') {
// Identifier quels équipements/conteneurs retirer
final removedIds = allConflicts.keys.toSet();
// Retirer les équipements directs en conflit
newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId));
// Retirer les boîtes en conflit
newContainers.removeWhere((containerId) => removedIds.contains(containerId));
// Informer l'utilisateur des boîtes retirées
for (var containerId in removedIds.where((id) => newContainers.contains(id))) {
if (mounted) {
final container = allContainers.firstWhere(
(c) => c.id == containerId,
orElse: () => ContainerModel(
id: containerId,
name: 'Inconnu',
type: ContainerType.flightCase,
status: EquipmentStatus.available,
equipmentIds: [],
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('La boîte "${container.name}" a été retirée en raison de conflits.'),
backgroundColor: Colors.orange,
duration: const Duration(seconds: 3),
),
);
}
}
}
// Si 'force_all', on garde tout
}
// Fusionner avec l'existant
final updatedEquipment = [...widget.assignedEquipment];
final updatedContainers = [...widget.assignedContainers];
@@ -398,7 +195,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Recharger le cache
await _loadEquipmentAndContainers();
}
}
void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment