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

@@ -1725,76 +1725,6 @@ exports.getEvents = onRequest(httpOptions, withCors(async (req, res) => {
} }
})); }));
// ============================================================================
// EQUIPMENTS - Read with permissions
// ============================================================================
exports.getEquipments = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canManage && !canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
const snapshot = await db.collection('equipments').get();
const equipments = snapshot.docs.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
res.status(200).json({ equipments });
} catch (error) {
logger.error("Error fetching equipments:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - Read with permissions
// ============================================================================
exports.getContainers = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
const snapshot = await db.collection('containers').get();
const containers = snapshot.docs.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt'])
};
});
res.status(200).json({ containers });
} catch (error) {
logger.error("Error fetching containers:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================ // ============================================================================
// MAINTENANCES - Read with permissions // MAINTENANCES - Read with permissions
// ============================================================================ // ============================================================================
@@ -3555,4 +3485,408 @@ exports.onAlertCreated = onDocumentCreated('alerts/{alertId}', async (event) =>
// const {onAlertCreated} = require('./onAlertCreated'); // const {onAlertCreated} = require('./onAlertCreated');
// exports.onAlertCreated = onAlertCreated; // exports.onAlertCreated = onAlertCreated;
// ============================================================================
// EQUIPMENTS - Pagination et filtrage avancé
// ============================================================================
/**
* Récupère les équipements avec pagination et filtrage côté serveur
*
* Paramètres de requête supportés:
* - limit: nombre d'éléments par page (défaut: 20, max: 100)
* - startAfter: ID du dernier élément de la page précédente (pour pagination)
* - category: filtre par catégorie
* - status: filtre par statut
* - searchQuery: recherche textuelle (nom, ID, modèle, marque)
* - sortBy: champ de tri (défaut: 'id')
* - sortOrder: 'asc' ou 'desc' (défaut: 'asc')
*/
exports.getEquipmentsPaginated = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canManage = await auth.hasPermission(decodedToken.uid, 'manage_equipment');
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canManage && !canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
// Récupérer les paramètres de la requête
const params = req.method === 'GET' ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
// Convertir en majuscules pour correspondre au format Firestore
const category = params.category ? params.category.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getEquipmentsPaginated] Params: limit=${limit}, startAfter=${startAfterId}, category=${category}, status=${status}, search=${searchQuery}`);
// Construire la requête Firestore
let query = db.collection('equipments');
// Si recherche textuelle, on augmente la limite pour filtrer ensuite
const queryLimit = searchQuery ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres
if (category) {
query = query.where('category', '==', category);
}
if (status) {
query = query.where('status', '==', status);
}
// Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document
// Cela garantit que TOUS les documents sont inclus, même sans champ 'id'
if (sortBy === 'id') {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
// Pagination
if (startAfterId) {
const startAfterDoc = await db.collection('equipments').doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
// Limiter les résultats
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
logger.info(`[getEquipmentsPaginated] Firestore returned ${rawDocCount} docs, hasMore=${hasMoreDocs}`);
let equipments = docsToProcess.map(doc => {
const data = doc.data();
// Masquer les prix si l'utilisateur n'a pas manage_equipment
if (!canManage) {
delete data.purchasePrice;
delete data.rentalPrice;
}
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['purchaseDate', 'nextMaintenanceDate', 'lastMaintenanceDate', 'createdAt', 'updatedAt'])
};
});
// Filtrage textuel côté serveur
if (searchQuery) {
equipments = equipments.filter(eq => {
const searchableText = [
eq.name || '',
eq.id || '',
eq.model || '',
eq.brand || '',
eq.subCategory || ''
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
}
// Pour la limite finale après filtrage textuel
const limitedEquipments = equipments.slice(0, limit);
const lastVisible = limitedEquipments.length > 0 ? limitedEquipments[limitedEquipments.length - 1].id : null;
// hasMore reste basé sur le nombre de docs Firestore, pas sur le filtrage textuel
logger.info(`[getEquipmentsPaginated] Returning ${limitedEquipments.length} equipments (filtered from ${equipments.length}), hasMore=${hasMoreDocs}`);
res.status(200).json({
equipments: limitedEquipments,
hasMore: hasMoreDocs,
lastVisible,
total: limitedEquipments.length
});
} catch (error) {
logger.error("Error fetching paginated equipments:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// CONTAINERS - Pagination et filtrage avancé
// ============================================================================
/**
* Récupère les containers avec pagination et filtrage côté serveur
*
* Paramètres similaires à getEquipmentsPaginated
*/
exports.getContainersPaginated = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canView) {
res.status(403).json({ error: 'Forbidden: Requires equipment permissions' });
return;
}
// Récupérer les paramètres de la requête
const params = req.method === 'GET' ? req.query : (req.body?.data || {});
const limit = Math.min(parseInt(params.limit) || 20, 100);
const startAfterId = params.startAfter || null;
// Convertir en majuscules pour correspondre au format Firestore
const type = params.type ? params.type.toUpperCase() : null;
const status = params.status ? params.status.toUpperCase() : null;
const searchQuery = params.searchQuery?.toLowerCase() || null;
const category = params.category ? params.category.toUpperCase() : null; // Filtre par catégorie d'équipements
const sortBy = params.sortBy || 'id';
const sortOrder = params.sortOrder === 'desc' ? 'desc' : 'asc';
logger.info(`[getContainersPaginated] Params: limit=${limit}, startAfter=${startAfterId}, type=${type}, status=${status}, category=${category}, search=${searchQuery}`);
// Construire la requête Firestore
let query = db.collection('containers');
// Si recherche textuelle ou filtre par catégorie, on augmente la limite pour filtrer ensuite
const queryLimit = (searchQuery || category) ? Math.min(limit * 10, 200) : limit;
// Appliquer les filtres sur les containers
if (type) {
query = query.where('type', '==', type);
}
if (status) {
query = query.where('status', '==', status);
}
// Tri : Utiliser FieldPath.documentId() pour trier par l'UID du document
if (sortBy === 'id') {
query = query.orderBy(admin.firestore.FieldPath.documentId(), sortOrder);
} else {
query = query.orderBy(sortBy, sortOrder);
}
// Pagination
if (startAfterId) {
const startAfterDoc = await db.collection('containers').doc(startAfterId).get();
if (startAfterDoc.exists) {
query = query.startAfter(startAfterDoc);
}
}
// Limiter les résultats
query = query.limit(queryLimit + 1);
const snapshot = await query.get();
// Déterminer hasMore basé sur le nombre de documents Firestore
const rawDocCount = snapshot.docs.length;
const hasMoreDocs = rawDocCount > queryLimit;
const docsToProcess = hasMoreDocs ? snapshot.docs.slice(0, queryLimit) : snapshot.docs;
let containers = docsToProcess.map(doc => {
const data = doc.data();
return {
id: doc.id,
...helpers.serializeTimestamps(data, ['createdAt', 'updatedAt'])
};
});
// Récupérer tous les équipements liés aux containers (pour population ET filtrage)
const allEquipmentIds = new Set();
containers.forEach(c => {
if (c.equipmentIds && Array.isArray(c.equipmentIds)) {
c.equipmentIds.forEach(id => allEquipmentIds.add(id));
}
});
// Charger les équipements en batch (max 30 par requête Firestore)
const equipmentMap = new Map();
if (allEquipmentIds.size > 0) {
const equipmentIdArray = Array.from(allEquipmentIds);
const batchSize = 30; // Limite Firestore pour les requêtes 'in'
for (let i = 0; i < equipmentIdArray.length; i += batchSize) {
const batch = equipmentIdArray.slice(i, i + batchSize);
const equipmentSnapshot = await db.collection('equipments')
.where(admin.firestore.FieldPath.documentId(), 'in', batch)
.get();
equipmentSnapshot.docs.forEach(doc => {
const equipmentData = doc.data();
equipmentMap.set(doc.id, {
id: doc.id,
...helpers.serializeTimestamps(equipmentData)
});
});
}
}
// Peupler les containers avec leurs équipements
containers = containers.map(container => ({
...container,
equipment: (container.equipmentIds || [])
.map(eqId => equipmentMap.get(eqId))
.filter(eq => eq !== undefined) // Retirer les équipements non trouvés
}));
// Filtrage par catégorie d'équipements
if (category) {
containers = containers.filter(c => {
// Garder le container s'il contient au moins un équipement de la catégorie demandée
return c.equipment.some(eq => eq.category === category);
});
}
// Filtrage textuel côté serveur
if (searchQuery) {
containers = containers.filter(c => {
const searchableText = [
c.name || '',
c.id || '',
...(c.equipment || []).map(eq => eq.name || '')
].join(' ').toLowerCase();
return searchableText.includes(searchQuery);
});
}
// Pour la limite finale après filtrage
const limitedContainers = containers.slice(0, limit);
const lastVisible = limitedContainers.length > 0 ? limitedContainers[limitedContainers.length - 1].id : null;
// Log pour debugging
const totalEquipmentCount = limitedContainers.reduce((sum, c) => sum + (c.equipment?.length || 0), 0);
logger.info(`[getContainersPaginated] Returning ${limitedContainers.length} containers with ${totalEquipmentCount} total equipment(s)`);
// Log détaillé pour chaque container
limitedContainers.forEach(c => {
logger.info(` - Container ${c.id}: ${c.equipment?.length || 0} equipment(s)`);
});
res.status(200).json({
containers: limitedContainers,
hasMore: containers.length > limit || hasMoreDocs,
lastVisible,
total: limitedContainers.length
});
} catch (error) {
logger.error("Error fetching paginated containers:", error);
res.status(500).json({ error: error.message });
}
}));
// ============================================================================
// SEARCH - Recherche unifiée avec autocomplétion
// ============================================================================
/**
* Recherche rapide d'équipements et containers pour l'autocomplétion
* Retourne un nombre limité de résultats pour des performances optimales
*/
exports.quickSearch = onRequest(httpOptions, withCors(async (req, res) => {
try {
const decodedToken = await auth.authenticateUser(req);
// Vérifier les permissions
const canView = await auth.hasPermission(decodedToken.uid, 'view_equipment');
if (!canView) {
res.status(403).json({ error: 'Forbidden: Requires view_equipment permission' });
return;
}
const params = req.method === 'GET' ? req.query : (req.body?.data || {});
const searchQuery = params.query?.toLowerCase() || '';
const limit = Math.min(parseInt(params.limit) || 10, 50);
const includeEquipments = params.includeEquipments !== 'false';
const includeContainers = params.includeContainers !== 'false';
if (!searchQuery || searchQuery.length < 2) {
res.status(200).json({ results: [] });
return;
}
const results = [];
// Rechercher dans les équipements
if (includeEquipments) {
const equipmentSnapshot = await db.collection('equipments')
.orderBy('id')
.limit(limit * 2) // Récupérer plus pour filtrer ensuite
.get();
equipmentSnapshot.docs.forEach(doc => {
const data = doc.data();
const searchableText = [
data.name || '',
doc.id || '',
data.model || '',
data.brand || ''
].join(' ').toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: 'equipment',
id: doc.id,
name: data.name,
category: data.category,
model: data.model,
brand: data.brand
});
}
});
}
// Rechercher dans les containers
if (includeContainers) {
const containerSnapshot = await db.collection('containers')
.orderBy('id')
.limit(limit * 2)
.get();
containerSnapshot.docs.forEach(doc => {
const data = doc.data();
const searchableText = [
data.name || '',
doc.id || ''
].join(' ').toLowerCase();
if (searchableText.includes(searchQuery)) {
results.push({
type: 'container',
id: doc.id,
name: data.name,
containerType: data.type
});
}
});
}
// Limiter et trier les résultats
const limitedResults = results
.sort((a, b) => {
// Prioriser les correspondances exactes au début
const aStarts = a.id.toLowerCase().startsWith(searchQuery);
const bStarts = b.id.toLowerCase().startsWith(searchQuery);
if (aStarts && !bStarts) return -1;
if (!aStarts && bStarts) return 1;
return 0;
})
.slice(0, limit);
res.status(200).json({ results: limitedResults });
} catch (error) {
logger.error("Error in quick search:", error);
res.status(500).json({ error: error.message });
}
}));

View File

@@ -0,0 +1,93 @@
/**
* Script de migration pour ajouter le champ 'id' aux équipements qui n'en ont pas
*
* Ce script parcourt tous les documents de la collection 'equipments' et ajoute
* le champ 'id' avec la valeur du document ID si ce champ est manquant.
*/
const admin = require('firebase-admin');
const serviceAccount = require('./serviceAccountKey.json');
// Initialiser Firebase Admin
admin.initializeApp({
credential: admin.credential.cert(serviceAccount)
});
const db = admin.firestore();
async function migrateEquipmentIds() {
console.log('🔧 Migration: Ajout du champ id aux équipements');
console.log('================================================\n');
try {
// Récupérer tous les équipements
const equipmentsSnapshot = await db.collection('equipments').get();
console.log(`📦 Total d'équipements: ${equipmentsSnapshot.size}`);
let missingIdCount = 0;
let updatedCount = 0;
let errorCount = 0;
const batch = db.batch();
let batchCount = 0;
for (const doc of equipmentsSnapshot.docs) {
const data = doc.data();
// Vérifier si le champ 'id' est manquant ou vide
if (!data.id || data.id === '') {
missingIdCount++;
console.log(`❌ Équipement ${doc.id} (${data.name || 'Sans nom'}) : champ 'id' manquant`);
// Ajouter au batch
batch.update(doc.ref, { id: doc.id });
batchCount++;
updatedCount++;
// Exécuter le batch tous les 500 documents (limite Firestore)
if (batchCount === 500) {
await batch.commit();
console.log(`✅ Batch de ${batchCount} documents mis à jour`);
batchCount = 0;
}
}
}
// Exécuter le dernier batch s'il reste des documents
if (batchCount > 0) {
await batch.commit();
console.log(`✅ Batch final de ${batchCount} documents mis à jour`);
}
console.log('\n================================================');
console.log('📊 RÉSUMÉ DE LA MIGRATION');
console.log('================================================');
console.log(`Total d'équipements: ${equipmentsSnapshot.size}`);
console.log(`Équipements avec 'id' manquant: ${missingIdCount}`);
console.log(`Équipements mis à jour: ${updatedCount}`);
console.log(`Erreurs: ${errorCount}`);
console.log('================================================\n');
if (missingIdCount === 0) {
console.log('✅ Tous les équipements ont déjà un champ id !');
} else if (updatedCount === missingIdCount) {
console.log('✅ Migration terminée avec succès !');
} else {
console.log('⚠️ Migration terminée avec des erreurs');
}
} catch (error) {
console.error('❌ Erreur lors de la migration:', error);
throw error;
}
}
// Exécuter la migration
migrateEquipmentIds()
.then(() => {
console.log('\n✅ Script terminé');
process.exit(0);
})
.catch(error => {
console.error('\n❌ Script échoué:', error);
process.exit(1);
});

View File

@@ -99,7 +99,7 @@ class MyApp extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return UpdateChecker( return UpdateChecker(
child: MaterialApp( child: MaterialApp(
title: 'EM2 ERP', title: 'EM2 Hub',
theme: ThemeData( theme: ThemeData(
primarySwatch: Colors.red, primarySwatch: Colors.red,
primaryColor: AppColors.noir, primaryColor: AppColors.noir,

View File

@@ -1,27 +1,48 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/container_service.dart'; import 'package:em2rp/services/container_service.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class ContainerProvider with ChangeNotifier { class ContainerProvider with ChangeNotifier {
final ContainerService _containerService = ContainerService(); final ContainerService _containerService = ContainerService();
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(FirebaseFunctionsApiService());
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<ContainerModel> _paginatedContainers = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour compatibilité
List<ContainerModel> _containers = []; List<ContainerModel> _containers = [];
// Filtres et recherche
ContainerType? _selectedType; ContainerType? _selectedType;
EquipmentStatus? _selectedStatus; EquipmentStatus? _selectedStatus;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false; bool _isLoading = false;
bool _isInitialized = false; bool _isInitialized = false;
List<ContainerModel> get containers => _containers; // Mode de chargement (pagination vs full)
bool _usePagination = false;
// Getters
List<ContainerModel> get containers => _usePagination ? _paginatedContainers : _containers;
ContainerType? get selectedType => _selectedType; ContainerType? get selectedType => _selectedType;
EquipmentStatus? get selectedStatus => _selectedStatus; EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery; String get searchQuery => _searchQuery;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les conteneurs sont chargés (charge si nécessaire) /// S'assure que les conteneurs sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async { Future<void> ensureLoaded() async {
@@ -31,19 +52,43 @@ class ContainerProvider with ChangeNotifier {
await loadContainers(); await loadContainers();
} }
/// Charger tous les containers via l'API /// Charger tous les containers via l'API (avec pagination automatique)
Future<void> loadContainers() async { Future<void> loadContainers() async {
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
try { try {
final containers = await _containerService.getContainers( _containers.clear();
type: _selectedType, String? lastVisible;
status: _selectedStatus, bool hasMore = true;
searchQuery: _searchQuery, int pageCount = 0;
);
// Charger toutes les pages en boucle
while (hasMore) {
pageCount++;
print('[ContainerProvider] Loading page $pageCount...');
final result = await _dataService.getContainersPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
type: _selectedType?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery,
);
final containers = (result['containers'] as List<dynamic>)
.map((data) => ContainerModel.fromMap(data, data['id'] as String))
.toList();
_containers.addAll(containers);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
print('[ContainerProvider] Loaded ${containers.length} containers, total: ${_containers.length}, hasMore: $hasMore');
}
_containers = containers;
_isLoading = false; _isLoading = false;
_isInitialized = true; _isInitialized = true;
notifyListeners(); notifyListeners();
@@ -80,22 +125,144 @@ class ContainerProvider with ChangeNotifier {
} }
/// Définir le type sélectionné /// Définir le type sélectionné
/// Définir le type sélectionné void setSelectedType(ContainerType? type) async {
void setSelectedType(ContainerType? type) { if (_selectedType == type) return;
_selectedType = type; _selectedType = type;
notifyListeners(); if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Définir le statut sélectionné /// Définir le statut sélectionné
void setSelectedStatus(EquipmentStatus? status) { void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status; _selectedStatus = status;
notifyListeners(); if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Définir la requête de recherche /// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) { void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query; _searchQuery = query;
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners();
}
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
}
// ============================================================================
// PAGINATION - Nouvelles méthodes
// ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[ContainerProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[ContainerProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[ContainerProvider] Loading first page...');
_paginatedContainers.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners(); notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[ContainerProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[ContainerProvider] Loading next page... (current: ${_paginatedContainers.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getContainersPaginated(
limit: 20,
startAfter: _lastVisible,
type: _selectedType != null ? containerTypeToString(_selectedType!) : null,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
sortBy: 'id',
sortOrder: 'asc',
);
final newContainers = (result['containers'] as List<dynamic>)
.map((data) {
final map = data as Map<String, dynamic>;
return ContainerModel.fromMap(map, map['id'] as String);
})
.toList();
_paginatedContainers.addAll(newContainers);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[ContainerProvider] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
} catch (e) {
DebugLog.error('[ContainerProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
Future<void> reload() async {
DebugLog.info('[ContainerProvider] Reloading with new filters...');
await loadFirstPage();
} }
/// Créer un nouveau container /// Créer un nouveau container

View File

@@ -1,109 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
import '../models/equipment_model.dart';
class ContainerProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService());
List<ContainerModel> _containers = [];
ContainerType? _selectedType;
EquipmentStatus? _selectedStatus;
String _searchQuery = '';
bool _isLoading = false;
// Getters
List<ContainerModel> get containers => _filteredContainers;
ContainerType? get selectedType => _selectedType;
EquipmentStatus? get selectedStatus => _selectedStatus;
String get searchQuery => _searchQuery;
bool get isLoading => _isLoading;
/// Charger tous les conteneurs via l'API
Future<void> loadContainers() async {
_isLoading = true;
notifyListeners();
try {
final containersData = await _dataService.getContainers();
_containers = containersData.map((data) {
return ContainerModel.fromMap(data, data['id'] as String);
}).toList();
_isLoading = false;
notifyListeners();
} catch (e) {
print('Error loading containers: $e');
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Obtenir les conteneurs filtrés
List<ContainerModel> get _filteredContainers {
var filtered = _containers;
if (_selectedType != null) {
filtered = filtered.where((c) => c.type == _selectedType).toList();
}
if (_selectedStatus != null) {
filtered = filtered.where((c) => c.status == _selectedStatus).toList();
}
if (_searchQuery.isNotEmpty) {
final query = _searchQuery.toLowerCase();
filtered = filtered.where((c) {
return c.name.toLowerCase().contains(query) ||
c.id.toLowerCase().contains(query);
}).toList();
}
return filtered;
}
/// Définir le filtre de type
void setSelectedType(ContainerType? type) {
_selectedType = type;
notifyListeners();
}
/// Définir le filtre de statut
void setSelectedStatus(EquipmentStatus? status) {
_selectedStatus = status;
notifyListeners();
}
/// Définir la requête de recherche
void setSearchQuery(String query) {
_searchQuery = query;
notifyListeners();
}
/// Réinitialiser tous les filtres
void clearFilters() {
_selectedType = null;
_selectedStatus = null;
_searchQuery = '';
notifyListeners();
}
/// Recharger les conteneurs
Future<void> refresh() async {
await loadContainers();
}
/// Obtenir un conteneur par ID
ContainerModel? getById(String id) {
try {
return _containers.firstWhere((c) => c.id == id);
} catch (e) {
return null;
}
}
}

View File

@@ -1,29 +1,43 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'dart:async';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
class EquipmentProvider extends ChangeNotifier { class EquipmentProvider extends ChangeNotifier {
final DataService _dataService = DataService(FirebaseFunctionsApiService()); final DataService _dataService = DataService(FirebaseFunctionsApiService());
// Timer pour le debouncing de la recherche
Timer? _searchDebounceTimer;
// Liste paginée pour la page de gestion
List<EquipmentModel> _paginatedEquipment = [];
bool _hasMore = true;
bool _isLoadingMore = false;
String? _lastVisible;
// Cache complet pour getEquipmentsByIds et compatibilité
List<EquipmentModel> _equipment = []; List<EquipmentModel> _equipment = [];
List<String> _models = []; List<String> _models = [];
List<String> _brands = []; List<String> _brands = [];
// Filtres et recherche
EquipmentCategory? _selectedCategory; EquipmentCategory? _selectedCategory;
EquipmentStatus? _selectedStatus; EquipmentStatus? _selectedStatus;
String? _selectedModel; String? _selectedModel;
String _searchQuery = ''; String _searchQuery = '';
bool _isLoading = false; bool _isLoading = false;
bool _isInitialized = false; // Flag pour savoir si les équipements ont été chargés bool _isInitialized = false;
// Mode de chargement (pagination vs full)
bool _usePagination = false;
// Constructeur - Ne charge PAS automatiquement
// Les équipements seront chargés à la demande (page de gestion ou via getEquipmentsByIds)
EquipmentProvider(); EquipmentProvider();
// Getters // Getters
List<EquipmentModel> get equipment => _filteredEquipment; List<EquipmentModel> get equipment => _usePagination ? _paginatedEquipment : _filteredEquipment;
List<EquipmentModel> get allEquipment => _equipment; // Tous les équipements sans filtre List<EquipmentModel> get allEquipment => _equipment;
List<String> get models => _models; List<String> get models => _models;
List<String> get brands => _brands; List<String> get brands => _brands;
EquipmentCategory? get selectedCategory => _selectedCategory; EquipmentCategory? get selectedCategory => _selectedCategory;
@@ -31,42 +45,86 @@ class EquipmentProvider extends ChangeNotifier {
String? get selectedModel => _selectedModel; String? get selectedModel => _selectedModel;
String get searchQuery => _searchQuery; String get searchQuery => _searchQuery;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore;
bool get isInitialized => _isInitialized; bool get isInitialized => _isInitialized;
bool get usePagination => _usePagination;
/// S'assure que les équipements sont chargés (charge si nécessaire) /// S'assure que les équipements sont chargés (charge si nécessaire)
Future<void> ensureLoaded() async { Future<void> ensureLoaded() async {
if (_isInitialized || _isLoading) { // Si déjà en train de charger, attendre
print('[EquipmentProvider] Equipment already loaded or loading, skipping...'); if (_isLoading) {
print('[EquipmentProvider] Equipment loading in progress, waiting...');
return; return;
} }
// Si initialisé MAIS _equipment est vide, forcer le rechargement
if (_isInitialized && _equipment.isEmpty) {
print('[EquipmentProvider] Equipment marked as initialized but _equipment is empty! Force reloading...');
_isInitialized = false; // Réinitialiser le flag
await loadEquipments();
return;
}
// Si déjà initialisé avec des données, ne rien faire
if (_isInitialized) {
print('[EquipmentProvider] Equipment already loaded (${_equipment.length} items), skipping...');
return;
}
print('[EquipmentProvider] Equipment not loaded, loading now...'); print('[EquipmentProvider] Equipment not loaded, loading now...');
await loadEquipments(); await loadEquipments();
} }
/// Charger tous les équipements via l'API (utilisé par la page de gestion) /// Charger tous les équipements via l'API (utilisé par les dialogs et sélection)
Future<void> loadEquipments() async { Future<void> loadEquipments() async {
print('[EquipmentProvider] Starting to load equipments...'); print('[EquipmentProvider] Starting to load ALL equipments...');
_isLoading = true; _isLoading = true;
notifyListeners(); notifyListeners();
try { try {
print('[EquipmentProvider] Calling getEquipments API...'); _equipment.clear();
final equipmentsData = await _dataService.getEquipments(); String? lastVisible;
print('[EquipmentProvider] Received ${equipmentsData.length} equipments from API'); bool hasMore = true;
int pageCount = 0;
_equipment = equipmentsData.map((data) { // Charger toutes les pages en boucle
return EquipmentModel.fromMap(data, data['id'] as String); while (hasMore) {
}).toList(); pageCount++;
print('[EquipmentProvider] Mapped ${_equipment.length} equipment models'); print('[EquipmentProvider] Loading page $pageCount...');
final result = await _dataService.getEquipmentsPaginated(
limit: 100, // Charger 100 par page pour aller plus vite
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
);
final equipmentsData = result['equipments'] as List<dynamic>;
print('[EquipmentProvider] Page $pageCount: ${equipmentsData.length} equipments');
final pageEquipments = equipmentsData.map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data as Map<String, dynamic>, id);
}).toList();
_equipment.addAll(pageEquipments);
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
if (!hasMore) {
print('[EquipmentProvider] All pages loaded. Total: ${_equipment.length} equipments');
}
}
// Extraire les modèles et marques uniques // Extraire les modèles et marques uniques
_extractUniqueValues(); _extractUniqueValues();
_isInitialized = true; _isInitialized = true;
_isLoading = false; _isLoading = false;
notifyListeners(); notifyListeners();
print('[EquipmentProvider] Equipment loading complete'); print('[EquipmentProvider] Equipment loading complete: ${_equipment.length} equipments');
} catch (e) { } catch (e) {
print('[EquipmentProvider] Error loading equipments: $e'); print('[EquipmentProvider] Error loading equipments: $e');
_isLoading = false; _isLoading = false;
@@ -118,7 +176,8 @@ class EquipmentProvider extends ChangeNotifier {
final equipmentsData = await _dataService.getEquipmentsByIds(missingIds); final equipmentsData = await _dataService.getEquipmentsByIds(missingIds);
final loadedEquipments = equipmentsData.map((data) { final loadedEquipments = equipmentsData.map((data) {
return EquipmentModel.fromMap(data, data['id'] as String); final id = data['id'] as String; // L'ID vient du backend
return EquipmentModel.fromMap(data, id);
}).toList(); }).toList();
// Ajouter au cache // Ajouter au cache
@@ -185,58 +244,205 @@ class EquipmentProvider extends ChangeNotifier {
return filtered; return filtered;
} }
/// Définir le filtre de catégorie // ============================================================================
void setSelectedCategory(EquipmentCategory? category) { // PAGINATION - Nouvelles méthodes
_selectedCategory = category; // ============================================================================
/// Active le mode pagination (pour la page de gestion)
void enablePagination() {
if (!_usePagination) {
_usePagination = true;
DebugLog.info('[EquipmentProvider] Pagination mode enabled');
}
}
/// Désactive le mode pagination (pour les autres pages)
void disablePagination() {
if (_usePagination) {
_usePagination = false;
DebugLog.info('[EquipmentProvider] Pagination mode disabled');
}
}
/// Charge la première page (réinitialise tout)
Future<void> loadFirstPage() async {
DebugLog.info('[EquipmentProvider] Loading first page...');
_paginatedEquipment.clear();
_lastVisible = null;
_hasMore = true;
_isLoading = true;
notifyListeners(); notifyListeners();
try {
await loadNextPage();
_isInitialized = true;
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading first page', e);
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Charge la page suivante (scroll infini)
Future<void> loadNextPage() async {
if (_isLoadingMore || !_hasMore) {
DebugLog.info('[EquipmentProvider] Skip loadNextPage: isLoadingMore=$_isLoadingMore, hasMore=$_hasMore');
return;
}
DebugLog.info('[EquipmentProvider] Loading next page... (current: ${_paginatedEquipment.length})');
_isLoadingMore = true;
_isLoading = true;
notifyListeners();
try {
final result = await _dataService.getEquipmentsPaginated(
limit: 20,
startAfter: _lastVisible,
category: _selectedCategory?.name,
status: _selectedStatus?.name,
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : 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; // L'ID vient du backend dans le JSON
return EquipmentModel.fromMap(map, id);
})
.toList();
_paginatedEquipment.addAll(newEquipments);
_hasMore = result['hasMore'] as bool? ?? false;
_lastVisible = result['lastVisible'] as String?;
DebugLog.info('[EquipmentProvider] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipment.length}, hasMore: $_hasMore');
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
} catch (e) {
DebugLog.error('[EquipmentProvider] Error loading next page', e);
_isLoadingMore = false;
_isLoading = false;
notifyListeners();
rethrow;
}
}
/// Recharge en changeant de filtre ou recherche
Future<void> reload() async {
DebugLog.info('[EquipmentProvider] Reloading with new filters...');
await loadFirstPage();
}
/// Définir le filtre de catégorie
void setSelectedCategory(EquipmentCategory? category) async {
if (_selectedCategory == category) return;
_selectedCategory = category;
if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Définir le filtre de statut /// Définir le filtre de statut
void setSelectedStatus(EquipmentStatus? status) { void setSelectedStatus(EquipmentStatus? status) async {
if (_selectedStatus == status) return;
_selectedStatus = status; _selectedStatus = status;
notifyListeners(); if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Définir le filtre de modèle /// Définir le filtre de modèle
void setSelectedModel(String? model) { void setSelectedModel(String? model) async {
if (_selectedModel == model) return;
_selectedModel = model; _selectedModel = model;
notifyListeners(); if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Définir la requête de recherche /// Définir la requête de recherche (avec debouncing)
void setSearchQuery(String query) { void setSearchQuery(String query) {
if (_searchQuery == query) return;
_searchQuery = query; _searchQuery = query;
notifyListeners();
// Annuler le timer précédent
_searchDebounceTimer?.cancel();
if (_usePagination) {
// Attendre 500ms avant de recharger (debouncing)
_searchDebounceTimer = Timer(const Duration(milliseconds: 500), () {
reload();
});
} else {
notifyListeners();
}
}
@override
void dispose() {
_searchDebounceTimer?.cancel();
super.dispose();
} }
/// Réinitialiser tous les filtres /// Réinitialiser tous les filtres
void clearFilters() { void clearFilters() async {
_selectedCategory = null; _selectedCategory = null;
_selectedStatus = null; _selectedStatus = null;
_selectedModel = null; _selectedModel = null;
_searchQuery = ''; _searchQuery = '';
notifyListeners(); if (_usePagination) {
await reload();
} else {
notifyListeners();
}
} }
/// Recharger les équipements // ============================================================================
// MÉTHODES COMPATIBILITÉ (pour ancien code)
// ============================================================================
/// Recharger les équipements (ancien système)
Future<void> refresh() async { Future<void> refresh() async {
await loadEquipments(); if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} }
// === MÉTHODES STREAM (COMPATIBILITÉ) ===
/// Stream des équipements (pour compatibilité avec ancien code) /// Stream des équipements (pour compatibilité avec ancien code)
Stream<List<EquipmentModel>> get equipmentStream async* { Stream<List<EquipmentModel>> get equipmentStream async* {
yield _equipment; if (!_isInitialized && !_usePagination) {
await loadEquipments();
}
yield equipment;
} }
/// Supprimer un équipement /// Supprimer un équipement
Future<void> deleteEquipment(String equipmentId) async { Future<void> deleteEquipment(String equipmentId) async {
try { try {
await _dataService.deleteEquipment(equipmentId); await _dataService.deleteEquipment(equipmentId);
await loadEquipments(); // Recharger la liste if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) { } catch (e) {
print('Error deleting equipment: $e'); DebugLog.error('[EquipmentProvider] Error deleting equipment', e);
rethrow; rethrow;
} }
} }
@@ -245,9 +451,13 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> addEquipment(EquipmentModel equipment) async { Future<void> addEquipment(EquipmentModel equipment) async {
try { try {
await _dataService.createEquipment(equipment.id, equipment.toMap()); await _dataService.createEquipment(equipment.id, equipment.toMap());
await loadEquipments(); // Recharger la liste if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) { } catch (e) {
print('Error adding equipment: $e'); DebugLog.error('[EquipmentProvider] Error adding equipment', e);
rethrow; rethrow;
} }
} }
@@ -256,52 +466,67 @@ class EquipmentProvider extends ChangeNotifier {
Future<void> updateEquipment(EquipmentModel equipment) async { Future<void> updateEquipment(EquipmentModel equipment) async {
try { try {
await _dataService.updateEquipment(equipment.id, equipment.toMap()); await _dataService.updateEquipment(equipment.id, equipment.toMap());
await loadEquipments(); // Recharger la liste if (_usePagination) {
await reload();
} else {
await loadEquipments();
}
} catch (e) { } catch (e) {
print('Error updating equipment: $e'); DebugLog.error('[EquipmentProvider] Error updating equipment', e);
rethrow; rethrow;
} }
} }
/// Charger les marques /// Charger les marques
Future<void> loadBrands() async { Future<void> loadBrands() async {
// Les marques sont déjà chargées avec loadEquipments await ensureLoaded();
_extractUniqueValues(); _extractUniqueValues();
} }
/// Charger les modèles /// Charger les modèles
Future<void> loadModels() async { Future<void> loadModels() async {
// Les modèles sont déjà chargés avec loadEquipments await ensureLoaded();
_extractUniqueValues(); _extractUniqueValues();
} }
/// Charger les modèles d'une marque spécifique /// Charger les modèles d'une marque spécifique
Future<List<String>> loadModelsByBrand(String brand) async { Future<List<String>> loadModelsByBrand(String brand) async {
// Filtrer les modèles par marque await ensureLoaded();
final modelsByBrand = _equipment return _equipment
.where((eq) => eq.brand == brand && eq.model != null) .where((eq) => eq.brand?.toLowerCase() == brand.toLowerCase())
.map((eq) => eq.model!) .map((eq) => eq.model ?? '')
.where((model) => model.isNotEmpty)
.toSet() .toSet()
.toList(); .toList()
return modelsByBrand; ..sort();
} }
/// Charger les sous-catégories d'une catégorie spécifique /// Charger les sous-catégories d'une catégorie spécifique
Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async { Future<List<String>> loadSubCategoriesByCategory(EquipmentCategory category) async {
// Filtrer les sous-catégories par catégorie await ensureLoaded();
final subCategoriesByCategory = _equipment return _equipment
.where((eq) => eq.category == category && eq.subCategory != null && eq.subCategory!.isNotEmpty) .where((eq) => eq.category == category)
.map((eq) => eq.subCategory!) .map((eq) => eq.subCategory ?? '')
.where((sub) => sub.isNotEmpty)
.toSet() .toSet()
.toList() .toList()
..sort(); ..sort();
return subCategoriesByCategory;
} }
/// Calculer le statut réel d'un équipement (compatibilité) /// Calculer le statut réel d'un équipement (pour badge)
Future<EquipmentStatus> calculateRealStatus(EquipmentModel equipment) async { EquipmentStatus calculateRealStatus(EquipmentModel equipment) {
// Pour l'instant, retourner le statut stocké // Pour les consommables/câbles, vérifier le seuil critique
// TODO: Implémenter le calcul réel si nécessaire if (equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) {
final availableQty = equipment.availableQuantity ?? 0;
final criticalThreshold = equipment.criticalThreshold ?? 0;
if (criticalThreshold > 0 && availableQty <= criticalThreshold) {
return EquipmentStatus.maintenance; // Utiliser maintenance pour indiquer un problème
}
}
// Sinon retourner le statut de base
return equipment.status; return equipment.status;
} }
} }

View File

@@ -277,6 +277,63 @@ class FirebaseFunctionsApiService implements ApiService {
); );
} }
} }
/// Appelle une Cloud Function avec pagination
Future<Map<String, dynamic>> callPaginated(
String functionName,
Map<String, dynamic> params,
) async {
try {
final headers = await _getHeaders();
final url = Uri.parse('$_baseUrl/$functionName');
DebugLog.info('[API] Calling paginated function: $functionName with params: $params');
final response = await http.post(
url,
headers: headers,
body: jsonEncode({'data': params}),
);
DebugLog.info('[API] Response status: ${response.statusCode}');
if (response.statusCode == 200) {
final data = jsonDecode(response.body) as Map<String, dynamic>;
return data;
} else {
DebugLog.error('[API] Error response: ${response.body}');
throw Exception('API call failed with status ${response.statusCode}: ${response.body}');
}
} catch (e) {
DebugLog.error('[API] Exception in callPaginated: $e');
rethrow;
}
}
/// Recherche rapide avec autocomplétion
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
final params = {
'query': query,
'limit': limit,
'includeEquipments': includeEquipments.toString(),
'includeContainers': includeContainers.toString(),
};
final response = await callPaginated('quickSearch', params);
final results = response['results'] as List<dynamic>? ?? [];
return results.cast<Map<String, dynamic>>();
} catch (e) {
DebugLog.error('[API] Error in quickSearch: $e');
return [];
}
}
} }
/// Exception personnalisée pour les erreurs API /// Exception personnalisée pour les erreurs API

View File

@@ -169,7 +169,8 @@ class ContainerService {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds); final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
for (var data in equipmentsData) { for (var data in equipmentsData) {
final equipment = EquipmentModel.fromMap(data, data['id'] as String); final id = data['id'] as String;
final equipment = EquipmentModel.fromMap(data, id);
if (equipment.status != EquipmentStatus.available) { if (equipment.status != EquipmentStatus.available) {
unavailableEquipment.add('${equipment.name} (${equipment.status})'); unavailableEquipment.add('${equipment.name} (${equipment.status})');
} }
@@ -202,7 +203,10 @@ class ContainerService {
final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds); final equipmentsData = await _dataService.getEquipmentsByIds(container.equipmentIds);
return equipmentsData return equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String)) .map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList(); .toList();
} catch (e) { } catch (e) {
print('Error getting container equipment: $e'); print('Error getting container equipment: $e');

View File

@@ -1,4 +1,5 @@
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
import 'package:em2rp/utils/debug_log.dart';
/// Service générique pour les opérations de lecture de données via Cloud Functions /// Service générique pour les opérations de lecture de données via Cloud Functions
class DataService { class DataService {
@@ -300,7 +301,7 @@ class DataService {
} }
} }
/// Récupère plusieurs conteneurs par leurs IDs /// Récupère plusieurs containers par leurs IDs
Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async { Future<List<Map<String, dynamic>>> getContainersByIds(List<String> containerIds) async {
try { try {
if (containerIds.isEmpty) return []; if (containerIds.isEmpty) return [];
@@ -318,37 +319,119 @@ class DataService {
return containers.map((e) => e as Map<String, dynamic>).toList(); return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) { } catch (e) {
print('[DataService] Error getting containers by IDs: $e'); print('[DataService] Error getting containers by IDs: $e');
throw Exception('Erreur lors de la récupération des conteneurs: $e'); throw Exception('Erreur lors de la récupération des containers: $e');
} }
} }
/// Récupère les maintenances (optionnellement filtrées par équipement) // ============================================================================
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async { // EQUIPMENTS & CONTAINERS - Pagination
try { // ============================================================================
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
final result = await _apiService.call('getMaintenances', data); /// Récupère les équipements avec pagination et filtrage
final maintenances = result['maintenances'] as List<dynamic>?; Future<Map<String, dynamic>> getEquipmentsPaginated({
if (maintenances == null) return []; int limit = 20,
return maintenances.map((e) => e as Map<String, dynamic>).toList(); String? startAfter,
String? category,
String? status,
String? searchQuery,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try {
final params = <String, dynamic>{
'limit': limit,
'sortBy': sortBy,
'sortOrder': sortOrder,
};
if (startAfter != null) params['startAfter'] = startAfter;
if (category != null) params['category'] = category;
if (status != null) params['status'] = status;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getEquipmentsPaginated',
params,
);
return {
'equipments': (result['equipments'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e'); DebugLog.error('[DataService] Error in getEquipmentsPaginated', e);
throw Exception('Erreur lors de la récupération paginée des équipements: $e');
} }
} }
/// Récupère les containers avec pagination et filtrage
/// Récupère les containers contenant un équipement spécifique Future<Map<String, dynamic>> getContainersPaginated({
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async { int limit = 20,
String? startAfter,
String? type,
String? status,
String? searchQuery,
String? category,
String sortBy = 'id',
String sortOrder = 'asc',
}) async {
try { try {
final result = await _apiService.call('getContainersByEquipment', { final params = <String, dynamic>{
'equipmentId': equipmentId, 'limit': limit,
}); 'sortBy': sortBy,
final containers = result['containers'] as List<dynamic>?; 'sortOrder': sortOrder,
if (containers == null) return []; };
return containers.map((e) => e as Map<String, dynamic>).toList();
if (startAfter != null) params['startAfter'] = startAfter;
if (type != null) params['type'] = type;
if (status != null) params['status'] = status;
if (category != null) params['category'] = category;
if (searchQuery != null && searchQuery.isNotEmpty) {
params['searchQuery'] = searchQuery;
}
final result = await (_apiService as FirebaseFunctionsApiService).callPaginated(
'getContainersPaginated',
params,
);
return {
'containers': (result['containers'] as List<dynamic>?)
?.map((e) => e as Map<String, dynamic>)
.toList() ?? [],
'hasMore': result['hasMore'] as bool? ?? false,
'lastVisible': result['lastVisible'] as String?,
'total': result['total'] as int? ?? 0,
};
} catch (e) { } catch (e) {
throw Exception('Erreur lors de la récupération des containers pour l\'équipement: $e'); DebugLog.error('[DataService] Error in getContainersPaginated', e);
throw Exception('Erreur lors de la récupération paginée des containers: $e');
}
}
/// Recherche rapide (autocomplétion)
Future<List<Map<String, dynamic>>> quickSearch(
String query, {
int limit = 10,
bool includeEquipments = true,
bool includeContainers = true,
}) async {
try {
return await (_apiService as FirebaseFunctionsApiService).quickSearch(
query,
limit: limit,
includeEquipments: includeEquipments,
includeContainers: includeContainers,
);
} catch (e) {
DebugLog.error('[DataService] Error in quickSearch', e);
return [];
} }
} }
@@ -454,6 +537,21 @@ class DataService {
// MAINTENANCES // MAINTENANCES
// ============================================================================ // ============================================================================
/// Récupère toutes les maintenances
Future<List<Map<String, dynamic>>> getMaintenances({String? equipmentId}) async {
try {
final data = <String, dynamic>{};
if (equipmentId != null) data['equipmentId'] = equipmentId;
final result = await _apiService.call('getMaintenances', data);
final maintenances = result['maintenances'] as List<dynamic>?;
if (maintenances == null) return [];
return maintenances.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des maintenances: $e');
}
}
/// Supprime une maintenance /// Supprime une maintenance
Future<void> deleteMaintenance(String maintenanceId) async { Future<void> deleteMaintenance(String maintenanceId) async {
try { try {
@@ -463,6 +561,20 @@ class DataService {
} }
} }
/// Récupère les containers contenant un équipement
Future<List<Map<String, dynamic>>> getContainersByEquipment(String equipmentId) async {
try {
final result = await _apiService.call('getContainersByEquipment', {
'equipmentId': equipmentId,
});
final containers = result['containers'] as List<dynamic>?;
if (containers == null) return [];
return containers.map((e) => e as Map<String, dynamic>).toList();
} catch (e) {
throw Exception('Erreur lors de la récupération des containers: $e');
}
}
// ============================================================================ // ============================================================================
// USERS // USERS
// ============================================================================ // ============================================================================

View File

@@ -9,6 +9,34 @@ class EquipmentService {
final ApiService _apiService = apiService; final ApiService _apiService = apiService;
final DataService _dataService = DataService(apiService); final DataService _dataService = DataService(apiService);
// ============================================================================
// Helper privée - Charge TOUS les équipements avec pagination
// ============================================================================
/// Charge tous les équipements en utilisant la pagination
Future<List<Map<String, dynamic>>> _getAllEquipmentsPaginated() async {
final allEquipments = <Map<String, dynamic>>[];
String? lastVisible;
bool hasMore = true;
while (hasMore) {
final result = await _dataService.getEquipmentsPaginated(
limit: 100,
startAfter: lastVisible,
sortBy: 'id',
sortOrder: 'asc',
);
final equipments = result['equipments'] as List<dynamic>;
allEquipments.addAll(equipments.cast<Map<String, dynamic>>());
hasMore = result['hasMore'] as bool? ?? false;
lastVisible = result['lastVisible'] as String?;
}
return allEquipments;
}
// ============================================================================ // ============================================================================
// CRUD Operations - Utilise le backend sécurisé // CRUD Operations - Utilise le backend sécurisé
// ============================================================================ // ============================================================================
@@ -82,10 +110,13 @@ class EquipmentService {
String? searchQuery, String? searchQuery,
}) async { }) async {
try { try {
final equipmentsData = await _dataService.getEquipments(); final equipmentsData = await _getAllEquipmentsPaginated();
var equipmentList = equipmentsData var equipmentList = equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String)) .map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList(); .toList();
// Filtres côté client // Filtres côté client
@@ -165,7 +196,11 @@ class EquipmentService {
}); });
final alternatives = (response['alternatives'] as List?) final alternatives = (response['alternatives'] as List?)
?.map((a) => EquipmentModel.fromMap(a as Map<String, dynamic>, a['id'] as String)) ?.map((a) {
final map = a as Map<String, dynamic>;
final id = map['id'] as String;
return EquipmentModel.fromMap(map, id);
})
.toList() ?? []; .toList() ?? [];
return alternatives; return alternatives;
@@ -204,27 +239,6 @@ class EquipmentService {
} }
} }
/// Vérifier les stocks critiques et créer des alertes
Future<void> checkCriticalStock() async {
try {
final equipmentsData = await _dataService.getEquipments();
for (var data in equipmentsData) {
final equipment = EquipmentModel.fromMap(data, data['id'] as String);
// Filtrer uniquement les consommables et câbles
if ((equipment.category == EquipmentCategory.consumable ||
equipment.category == EquipmentCategory.cable) &&
equipment.isCriticalStock) {
await _createLowStockAlert(equipment);
}
}
} catch (e) {
print('Error checking critical stock: $e');
rethrow;
}
}
/// Créer une alerte de stock faible /// Créer une alerte de stock faible
Future<void> _createLowStockAlert(EquipmentModel equipment) async { Future<void> _createLowStockAlert(EquipmentModel equipment) async {
try { try {
@@ -251,50 +265,10 @@ class EquipmentService {
return equipmentId; return equipmentId;
} }
/// Récupérer tous les modèles uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllModels() async {
try {
final equipmentsData = await _dataService.getEquipments();
final models = <String>{};
for (var data in equipmentsData) {
final model = data['model'] as String?;
if (model != null && model.isNotEmpty) {
models.add(model);
}
}
return models.toList()..sort();
} catch (e) {
print('Error getting all models: $e');
rethrow;
}
}
/// Récupérer toutes les marques uniques (pour l'indexation/autocomplete)
Future<List<String>> getAllBrands() async {
try {
final equipmentsData = await _dataService.getEquipments();
final brands = <String>{};
for (var data in equipmentsData) {
final brand = data['brand'] as String?;
if (brand != null && brand.isNotEmpty) {
brands.add(brand);
}
}
return brands.toList()..sort();
} catch (e) {
print('Error getting all brands: $e');
rethrow;
}
}
/// Récupérer les modèles filtrés par marque /// Récupérer les modèles filtrés par marque
Future<List<String>> getModelsByBrand(String brand) async { Future<List<String>> getModelsByBrand(String brand) async {
try { try {
final equipmentsData = await _dataService.getEquipments(); final equipmentsData = await _getAllEquipmentsPaginated();
final models = <String>{}; final models = <String>{};
for (var data in equipmentsData) { for (var data in equipmentsData) {
@@ -316,7 +290,7 @@ class EquipmentService {
/// Récupérer les sous-catégories filtrées par catégorie /// Récupérer les sous-catégories filtrées par catégorie
Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async { Future<List<String>> getSubCategoriesByCategory(EquipmentCategory category) async {
try { try {
final equipmentsData = await _dataService.getEquipments(); final equipmentsData = await _getAllEquipmentsPaginated();
final subCategories = <String>{}; final subCategories = <String>{};
final categoryString = equipmentCategoryToString(category); final categoryString = equipmentCategoryToString(category);
@@ -375,7 +349,10 @@ class EquipmentService {
final equipmentsData = await _dataService.getEquipmentsByIds(ids); final equipmentsData = await _dataService.getEquipmentsByIds(ids);
return equipmentsData return equipmentsData
.map((data) => EquipmentModel.fromMap(data, data['id'] as String)) .map((data) {
final id = data['id'] as String;
return EquipmentModel.fromMap(data, id);
})
.toList(); .toList();
} catch (e) { } catch (e) {
print('Error getting equipments by IDs: $e'); print('Error getting equipments by IDs: $e');

View File

@@ -231,7 +231,7 @@ END:VCALENDAR''';
// Lien vers l'application // Lien vers l'application
buffer.writeln(''); buffer.writeln('');
buffer.writeln('---'); buffer.writeln('---');
buffer.writeln('Généré par EM2 ERP ${AppVersion.fullVersion} http://app.em2events.fr'); buffer.writeln('Généré par EM2 Hub ${AppVersion.fullVersion} http://app.em2events.fr');
return buffer.toString(); return buffer.toString();
} }

View File

@@ -6,7 +6,6 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/views/equipment_detail_page.dart'; import 'package:em2rp/views/equipment_detail_page.dart';
@@ -14,10 +13,11 @@ import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
import 'package:em2rp/views/widgets/management/management_card.dart'; import 'package:em2rp/views/widgets/management/management_card.dart';
import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class ContainerManagementPage extends StatefulWidget { class ContainerManagementPage extends StatefulWidget {
const ContainerManagementPage({super.key}); const ContainerManagementPage({super.key});
@@ -30,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget {
class _ContainerManagementPageState extends State<ContainerManagementPage> class _ContainerManagementPageState extends State<ContainerManagementPage>
with SelectionModeMixin<ContainerManagementPage> { with SelectionModeMixin<ContainerManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
ContainerType? _selectedType; ContainerType? _selectedType;
EquipmentStatus? _selectedStatus; bool _isLoadingMore = false; // Flag pour éviter les appels multiples
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
@override
void initState() {
super.initState();
// Activer le mode pagination
final provider = context.read<ContainerProvider>();
provider.enablePagination();
// Ajouter le listener de scroll
_scrollController.addListener(_onScroll);
// Charger la première page
WidgetsBinding.instance.addPostFrameCallback((_) {
provider.loadFirstPage();
});
}
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<ContainerProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
});
}
}
}
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
context.read<ContainerProvider>().disablePagination();
super.dispose(); super.dispose();
} }
@@ -73,6 +121,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
actions: [ actions: [
const NotificationBadge(),
if (hasSelection) ...[ if (hasSelection) ...[
IconButton( IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white), icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -87,44 +136,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
], ],
], ],
) )
: AppBar( : CustomAppBar(
title: const Text('Gestion des Containers'), title: 'Gestion des Containers',
backgroundColor: AppColors.rouge,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
tooltip: 'Retour à la gestion des équipements', tooltip: 'Retour à la gestion des équipements',
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'), onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
), ),
actions: [ showLogoutButton: true,
IconButton(
icon: const Icon(Icons.logout, color: Colors.white),
onPressed: () async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Déconnexion'),
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Annuler'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Déconnexion'),
),
],
),
);
if (shouldLogout == true && context.mounted) {
await context.read<LocalUserProvider>().signOut();
if (context.mounted) {
Navigator.pushReplacementNamed(context, '/login');
}
}
},
),
],
), ),
drawer: const MainDrawer(currentPage: '/container_management'), drawer: const MainDrawer(currentPage: '/container_management'),
floatingActionButton: !isSelectionMode floatingActionButton: !isSelectionMode
@@ -174,21 +193,36 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
} }
Widget _buildSearchBar() { Widget _buildSearchBar() {
return ManagementSearchBar( return SearchActionsBar(
controller: _searchController, controller: _searchController,
hintText: 'Rechercher un container...', hintText: 'Rechercher un container...',
onChanged: (value) { onChanged: (value) {
context.read<ContainerProvider>().setSearchQuery(value); context.read<ContainerProvider>().setSearchQuery(value);
}, },
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode, onClear: () {
showSelectionModeButton: !isSelectionMode, _searchController.clear();
additionalActions: [ context.read<ContainerProvider>().setSearchQuery('');
const SizedBox(width: 12), },
IconButton( actions: [
icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge), IconButton.filled(
tooltip: 'Scanner un QR Code',
onPressed: _scanQRCode, onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
), ),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
], ],
); );
} }
@@ -274,30 +308,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
...ContainerType.values.map((type) { ...ContainerType.values.map((type) {
return _buildFilterOption(type, type.label); return _buildFilterOption(type, type.label);
}), }),
const Divider(height: 32),
// Filtre par statut
Text(
'Statut',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: AppColors.noir,
),
),
const SizedBox(height: 8),
_buildStatusFilter(null, 'Tous les statuts'),
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
], ],
), ),
); );
} }
Widget _buildFilterOption(ContainerType? type, String label) { Widget _buildFilterOption(ContainerType? type, String label) {
final isSelected = _selectedType == type;
return RadioListTile<ContainerType?>( return RadioListTile<ContainerType?>(
title: Text(label), title: Text(label),
value: type, value: type,
@@ -314,36 +330,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
); );
} }
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
final isSelected = _selectedStatus == status;
return RadioListTile<EquipmentStatus?>(
title: Text(label),
value: status,
groupValue: _selectedStatus,
activeColor: AppColors.rouge,
dense: true,
contentPadding: EdgeInsets.zero,
onChanged: (value) {
setState(() {
_selectedStatus = value;
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
});
},
);
}
Widget _buildContainerList() { Widget _buildContainerList() {
return Consumer<ContainerProvider>( return Consumer<ContainerProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
return ManagementList<ContainerModel>( // Afficher l'indicateur de chargement initial
stream: provider.containersStream, if (provider.isLoading && provider.containers.isEmpty) {
cachedItems: _cachedContainers, return const Center(child: CircularProgressIndicator());
emptyMessage: 'Aucun container trouvé', }
emptyIcon: Icons.inventory_2_outlined,
onDataReceived: (items) { final containers = provider.containers;
_cachedContainers = items;
// Afficher le message vide
if (containers.isEmpty && !provider.isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inventory_2_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'Aucun container trouvé',
style: TextStyle(
fontSize: 18,
color: Colors.grey[600],
),
),
],
),
);
}
// Calculer le nombre total d'items
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
return ListView.builder(
controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == containers.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildContainerCard(containers[index]);
}, },
itemBuilder: (container) => _buildContainerCard(container),
); );
}, },
); );

View File

@@ -18,6 +18,8 @@ import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart';
import 'package:em2rp/views/widgets/management/management_list.dart'; import 'package:em2rp/views/widgets/management/management_list.dart';
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
import 'package:em2rp/views/widgets/notification_badge.dart';
class EquipmentManagementPage extends StatefulWidget { class EquipmentManagementPage extends StatefulWidget {
const EquipmentManagementPage({super.key}); const EquipmentManagementPage({super.key});
@@ -31,23 +33,66 @@ class EquipmentManagementPage extends StatefulWidget {
class _EquipmentManagementPageState extends State<EquipmentManagementPage> class _EquipmentManagementPageState extends State<EquipmentManagementPage>
with SelectionModeMixin<EquipmentManagementPage> { with SelectionModeMixin<EquipmentManagementPage> {
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final ScrollController _scrollController = ScrollController();
EquipmentCategory? _selectedCategory; EquipmentCategory? _selectedCategory;
List<EquipmentModel>? _cachedEquipment; List<EquipmentModel>? _cachedEquipment;
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
@override @override
void initState() { void initState() {
super.initState(); super.initState();
DebugLog.info('[EquipmentManagementPage] initState called'); DebugLog.info('[EquipmentManagementPage] initState called');
// Charger les équipements au démarrage
// Activer le mode pagination
final provider = context.read<EquipmentProvider>();
provider.enablePagination();
// Ajouter le listener de scroll pour le chargement infini
_scrollController.addListener(_onScroll);
// Charger la première page au démarrage
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
DebugLog.info('[EquipmentManagementPage] Loading equipments...'); DebugLog.info('[EquipmentManagementPage] Loading first page...');
context.read<EquipmentProvider>().loadEquipments(); provider.loadFirstPage();
}); });
} }
void _onScroll() {
// Éviter les appels multiples
if (_isLoadingMore) return;
final provider = context.read<EquipmentProvider>();
// Charger la page suivante quand on arrive à 300px du bas
if (_scrollController.hasClients &&
_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 300) {
// Vérifier qu'on peut charger plus
if (provider.hasMore && !provider.isLoadingMore) {
setState(() => _isLoadingMore = true);
provider.loadNextPage().then((_) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
}).catchError((error) {
if (mounted) {
setState(() => _isLoadingMore = false);
}
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
});
}
}
}
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
_searchController.dispose(); _searchController.dispose();
// Désactiver le mode pagination en quittant
context.read<EquipmentProvider>().disablePagination();
super.dispose(); super.dispose();
} }
@@ -84,6 +129,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
style: const TextStyle(color: Colors.white), style: const TextStyle(color: Colors.white),
), ),
actions: [ actions: [
const NotificationBadge(),
if (hasSelection) ...[ if (hasSelection) ...[
IconButton( IconButton(
icon: const Icon(Icons.qr_code, color: Colors.white), icon: const Icon(Icons.qr_code, color: Colors.white),
@@ -100,13 +146,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
) )
: CustomAppBar( : CustomAppBar(
title: 'Gestion du matériel', title: 'Gestion du matériel',
actions: [
IconButton(
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
onPressed: toggleSelectionMode,
),
],
), ),
drawer: const MainDrawer(currentPage: '/equipment_management'), drawer: const MainDrawer(currentPage: '/equipment_management'),
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
@@ -130,61 +169,39 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
Widget _buildMobileLayout() { Widget _buildMobileLayout() {
return Column( return Column(
children: [ children: [
// Barre de recherche et bouton boîtes // Barre de recherche et boutons d'action
Padding( SearchActionsBar(
padding: const EdgeInsets.all(16.0), controller: _searchController,
child: Row( hintText: 'Rechercher par nom, modèle ou ID...',
children: [ onChanged: (value) {
Expanded( context.read<EquipmentProvider>().setSearchQuery(value);
child: TextField( },
controller: _searchController, onClear: () {
decoration: InputDecoration( _searchController.clear();
hintText: 'Rechercher par nom, modèle ou ID...', context.read<EquipmentProvider>().setSearchQuery('');
prefixIcon: const Icon(Icons.search), },
suffixIcon: _searchController.text.isNotEmpty actions: [
? IconButton( IconButton.filled(
icon: const Icon(Icons.clear), onPressed: _scanQRCode,
onPressed: () { icon: const Icon(Icons.qr_code_scanner),
_searchController.clear(); tooltip: 'Scanner un QR Code',
context.read<EquipmentProvider>().setSearchQuery(''); style: IconButton.styleFrom(
}, backgroundColor: Colors.grey[700],
) foregroundColor: Colors.white,
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
), ),
const SizedBox(width: 8), ),
// Bouton Scanner QR IconButton.filled(
IconButton.filled( onPressed: () {
onPressed: _scanQRCode, Navigator.pushNamed(context, '/container_management');
icon: const Icon(Icons.qr_code_scanner), },
tooltip: 'Scanner un QR Code', icon: const Icon(Icons.inventory_2),
style: IconButton.styleFrom( tooltip: 'Gérer les boîtes',
backgroundColor: Colors.grey[700], style: IconButton.styleFrom(
foregroundColor: Colors.white, backgroundColor: AppColors.rouge,
), foregroundColor: Colors.white,
), ),
const SizedBox(width: 8), ),
// Bouton Gérer les boîtes ],
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
), ),
// Menu horizontal de filtres par catégorie // Menu horizontal de filtres par catégorie
SizedBox( SizedBox(
@@ -249,49 +266,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
child: Column( child: Column(
children: [ children: [
// Bouton Gérer les boîtes const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.all(16.0),
child: ElevatedButton.icon(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2, color: Colors.white),
label: const Text(
'Gérer les boîtes',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
minimumSize: const Size(double.infinity, 50),
),
),
),
// Bouton Scanner QR
Padding(
padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0),
child: ElevatedButton.icon(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
label: const Text(
'Scanner QR Code',
style: TextStyle(color: Colors.white),
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey[700],
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
minimumSize: const Size(double.infinity, 50),
),
),
),
const Divider(),
// En-tête filtres // En-tête filtres
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
@@ -312,37 +287,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
], ],
), ),
), ),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher...',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
context
.read<EquipmentProvider>()
.setSearchQuery('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
),
),
// Filtres par catégorie // Filtres par catégorie
Padding( Padding(
padding: padding:
@@ -396,7 +340,56 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
), ),
), ),
// Contenu principal // Contenu principal
Expanded(child: _buildEquipmentList()), Expanded(
child: Column(
children: [
SearchActionsBar(
controller: _searchController,
hintText: 'Rechercher par nom, modèle ou ID...',
onChanged: (value) {
context.read<EquipmentProvider>().setSearchQuery(value);
},
onClear: () {
_searchController.clear();
context.read<EquipmentProvider>().setSearchQuery('');
},
actions: [
IconButton.filled(
onPressed: _scanQRCode,
icon: const Icon(Icons.qr_code_scanner),
tooltip: 'Scanner un QR Code',
style: IconButton.styleFrom(
backgroundColor: Colors.grey[700],
foregroundColor: Colors.white,
),
),
IconButton.filled(
onPressed: () {
Navigator.pushNamed(context, '/container_management');
},
icon: const Icon(Icons.inventory_2),
tooltip: 'Gérer les boîtes',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
if (!isSelectionMode)
IconButton.filled(
onPressed: toggleSelectionMode,
icon: const Icon(Icons.checklist),
tooltip: 'Mode sélection',
style: IconButton.styleFrom(
backgroundColor: AppColors.rouge,
foregroundColor: Colors.white,
),
),
],
),
Expanded(child: _buildEquipmentList()),
],
),
),
], ],
); );
} }
@@ -469,8 +462,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
builder: (context, provider, child) { builder: (context, provider, child) {
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}'); DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
if (provider.isLoading && _cachedEquipment == null) { // Afficher l'indicateur de chargement initial uniquement
DebugLog.info('[EquipmentManagementPage] Showing loading indicator'); if (provider.isLoading && provider.equipment.isEmpty) {
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
@@ -501,9 +495,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
} }
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items'); DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
// Calculer le nombre total d'items (équipements + indicateur de chargement)
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
return ListView.builder( return ListView.builder(
itemCount: equipments.length, controller: _scrollController,
itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
// Dernier élément = indicateur de chargement
if (index == equipments.length) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: provider.isLoadingMore
? const CircularProgressIndicator()
: const SizedBox.shrink(),
),
);
}
return _buildEquipmentCard(equipments[index]); return _buildEquipmentCard(equipments[index]);
}, },
); );

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
class SearchActionsBar extends StatelessWidget {
final TextEditingController controller;
final String hintText;
final ValueChanged<String> onChanged;
final VoidCallback onClear;
final List<Widget> actions;
const SearchActionsBar({
super.key,
required this.controller,
required this.hintText,
required this.onChanged,
required this.onClear,
this.actions = const [],
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Expanded(
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
prefixIcon: const Icon(Icons.search),
suffixIcon: controller.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: onClear,
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
),
onChanged: onChanged,
),
),
if (actions.isNotEmpty) ...[
const SizedBox(width: 8),
Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < actions.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
actions[i],
],
],
),
],
],
),
);
}
}

View File

@@ -49,19 +49,28 @@ class _EquipmentAssociatedEventsSectionState
final events = <EventModel>[]; final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API // Collecter tous les IDs de containers utilisés dans les événements
final containersData = await _dataService.getContainers(); final allContainerIds = <String>{};
for (var eventData in eventsData) {
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
}
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
final containersWithEquipment = <String>[]; final containersWithEquipment = <String>[];
for (var containerData in containersData) { if (allContainerIds.isNotEmpty) {
try { final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) { for (var containerData in containersData) {
containersWithEquipment.add(containerData['id'] as String); try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
} }
} catch (e) {
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
} }
} }

View File

@@ -43,19 +43,28 @@ class _EquipmentCurrentEventsSectionState
final events = <EventModel>[]; final events = <EventModel>[];
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API // Collecter tous les IDs de containers utilisés dans les événements
final containersData = await _dataService.getContainers(); final allContainerIds = <String>{};
for (var eventData in eventsData) {
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
}
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
final containersWithEquipment = <String>[]; final containersWithEquipment = <String>[];
for (var containerData in containersData) { if (allContainerIds.isNotEmpty) {
try { final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) { for (var containerData in containersData) {
containersWithEquipment.add(containerData['id'] as String); try {
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
if (equipmentIds.contains(widget.equipment.id)) {
containersWithEquipment.add(containerData['id'] as String);
}
} catch (e) {
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
} }
} catch (e) {
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
} }
} }

View File

@@ -16,35 +16,25 @@ class EquipmentStatusBadge extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final provider = Provider.of<EquipmentProvider>(context, listen: false); final provider = Provider.of<EquipmentProvider>(context, listen: false);
// Calculer le statut réel (synchrone maintenant)
final status = provider.calculateRealStatus(equipment);
// Logs désactivés en production // Logs désactivés en production
return Container(
return FutureBuilder<EquipmentStatus>( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
// On calcule le statut réel de manière asynchrone decoration: BoxDecoration(
future: provider.calculateRealStatus(equipment), color: status.color.withValues(alpha: 0.2),
// En attendant, on affiche le statut stocké borderRadius: BorderRadius.circular(12),
initialData: equipment.status, border: Border.all(color: status.color),
builder: (context, snapshot) { ),
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké child: Text(
final status = snapshot.data ?? equipment.status; status.label,
// Logs désactivés en production style: TextStyle(
fontSize: 12,
return Container( fontWeight: FontWeight.bold,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: status.color,
decoration: BoxDecoration( ),
color: status.color.withValues(alpha: 0.2), ),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: status.color),
),
child: Text(
status.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: status.color,
),
),
);
},
); );
} }
} }

View File

@@ -1,435 +0,0 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart';
import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/utils/colors.dart';
/// Widget pour sélectionner les boîtes parentes d'un équipement
class ParentBoxesSelector extends StatefulWidget {
final List<ContainerModel> availableBoxes;
final List<String> selectedBoxIds;
final Function(List<String>) onSelectionChanged;
const ParentBoxesSelector({
super.key,
required this.availableBoxes,
required this.selectedBoxIds,
required this.onSelectionChanged,
});
@override
State<ParentBoxesSelector> createState() => _ParentBoxesSelectorState();
}
class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
@override
void initState() {
super.initState();
}
@override
void didUpdateWidget(ParentBoxesSelector oldWidget) {
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
List<ContainerModel> get _filteredBoxes {
if (_searchQuery.isEmpty) {
return widget.availableBoxes;
}
final query = _searchQuery.toLowerCase();
return widget.availableBoxes.where((box) {
return box.name.toLowerCase().contains(query) ||
box.id.toLowerCase().contains(query) ||
box.type.label.toLowerCase().contains(query);
}).toList();
}
void _toggleSelection(String boxId) {
final newSelection = List<String>.from(widget.selectedBoxIds);
if (newSelection.contains(boxId)) {
newSelection.remove(boxId);
} else {
newSelection.add(boxId);
}
widget.onSelectionChanged(newSelection);
}
@override
Widget build(BuildContext context) {
if (widget.availableBoxes.isEmpty && widget.selectedBoxIds.isEmpty) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
Icon(Icons.info_outline, color: Colors.grey.shade600),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Aucune boîte disponible',
style: TextStyle(color: Colors.grey),
),
),
],
),
),
);
}
final filteredBoxes = _filteredBoxes;
final selectedCount = widget.selectedBoxIds.length;
// Vérifier s'il y a des boîtes sélectionnées qui ne sont pas dans la liste
final missingBoxIds = widget.selectedBoxIds
.where((id) => !widget.availableBoxes.any((box) => box.id == id))
.toList();
return Card(
elevation: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// En-tête avec titre et compteur
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: [
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
const Text(
'Boîtes parentes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
if (selectedCount > 0)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.rouge.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.rouge.withValues(alpha: 0.3),
width: 1,
),
),
child: Text(
'$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.rouge,
),
),
),
],
),
),
const Divider(height: 1),
// Message d'avertissement si des boîtes sélectionnées sont manquantes
if (missingBoxIds.isNotEmpty)
Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.shade300),
),
child: Row(
children: [
Icon(Icons.warning_amber, color: Colors.orange.shade700),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Boîtes introuvables',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade900,
),
),
const SizedBox(height: 4),
Text(
'Les boîtes suivantes sont sélectionnées mais n\'existent plus : ${missingBoxIds.join(", ")}',
style: TextStyle(
fontSize: 13,
color: Colors.orange.shade800,
),
),
],
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () {
// Retirer les boîtes manquantes de la sélection
final newSelection = widget.selectedBoxIds
.where((id) => !missingBoxIds.contains(id))
.toList();
widget.onSelectionChanged(newSelection);
},
tooltip: 'Retirer ces boîtes',
),
],
),
),
// Barre de recherche
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _searchController,
onChanged: (value) {
setState(() {
_searchQuery = value;
});
},
decoration: InputDecoration(
hintText: 'Rechercher par nom, ID ou type...',
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
suffixIcon: _searchQuery.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
_searchController.clear();
_searchQuery = '';
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.rouge, width: 2),
),
filled: true,
fillColor: Colors.grey.shade50,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
),
),
// Message si aucun résultat
if (filteredBoxes.isEmpty)
Padding(
padding: const EdgeInsets.all(32.0),
child: Center(
child: Column(
children: [
Icon(Icons.search_off, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 12),
Text(
'Aucune boîte trouvée',
style: TextStyle(
fontSize: 16,
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'Essayez une autre recherche',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
),
],
),
),
)
else
// Liste des boîtes
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: filteredBoxes.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final box = filteredBoxes[index];
final isSelected = widget.selectedBoxIds.contains(box.id);
if (index == 0) {
DebugLog.info('[ParentBoxesSelector] Building item $index');
DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}');
DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected');
}
return _buildBoxCard(box, isSelected);
},
),
const SizedBox(height: 16),
],
),
);
}
Widget _buildBoxCard(ContainerModel box, bool isSelected) {
return Card(
elevation: isSelected ? 3 : 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: isSelected ? AppColors.rouge : Colors.grey.shade300,
width: isSelected ? 2 : 1,
),
),
child: InkWell(
onTap: () => _toggleSelection(box.id),
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// Checkbox
Checkbox(
value: isSelected,
onChanged: (value) => _toggleSelection(box.id),
activeColor: AppColors.rouge,
),
const SizedBox(width: 8),
// Icône du type de container
CircleAvatar(
backgroundColor: isSelected
? AppColors.rouge.withValues(alpha: 0.15)
: Colors.grey.shade200,
radius: 24,
child: box.type.getIconForAvatar(
size: 24,
color: isSelected ? AppColors.rouge : Colors.grey.shade700,
),
),
const SizedBox(width: 12),
// Informations de la boîte
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
box.name,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: isSelected ? AppColors.rouge : Colors.black87,
),
),
const SizedBox(height: 4),
Text(
box.type.label,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 6),
// Badges
Wrap(
spacing: 6,
runSpacing: 4,
children: [
_buildInfoChip(
icon: Icons.inventory,
label: '${box.itemCount} équip.',
color: Colors.blue,
),
if (box.weight != null)
_buildInfoChip(
icon: Icons.scale,
label: '${box.weight!.toStringAsFixed(1)} kg',
color: Colors.orange,
),
_buildInfoChip(
icon: Icons.tag,
label: box.id,
color: Colors.grey,
isCompact: true,
),
],
),
],
),
),
// Indicateur de sélection
if (isSelected)
const Icon(
Icons.check_circle,
color: AppColors.rouge,
size: 24,
),
],
),
),
),
);
}
Widget _buildInfoChip({
required IconData icon,
required String label,
required Color color,
bool isCompact = false,
}) {
return Container(
padding: EdgeInsets.symmetric(
horizontal: isCompact ? 6 : 8,
vertical: 3,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: isCompact ? 10 : 12,
color: color.withValues(alpha: 0.8),
),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: isCompact ? 9 : 11,
fontWeight: FontWeight.w600,
color: color.withValues(alpha: 0.9),
),
),
],
),
);
}
}

View File

@@ -1,11 +1,8 @@
import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/container_model.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/container_provider.dart';
import 'package:em2rp/services/event_availability_service.dart'; import 'package:em2rp/services/event_availability_service.dart';
import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart';
@@ -109,93 +106,70 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
bool _isLoadingQuantities = false;
bool _isLoadingConflicts = false; bool _isLoadingConflicts = false;
String _searchQuery = ''; String _searchQuery = '';
// Nouvelles options d'affichage // Nouvelles options d'affichage
bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit
bool _containersExpanded = true; // Section "Boîtes" dépliée
bool _equipmentExpanded = true; // Section "Tous les équipements" dépliée // 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 // Cache pour éviter les rebuilds inutiles
List<ContainerModel> _cachedContainers = []; List<ContainerModel> _cachedContainers = [];
List<EquipmentModel> _cachedEquipment = []; List<EquipmentModel> _cachedEquipment = [];
bool _initialDataLoaded = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Ajouter le listener de scroll pour lazy loading
_scrollController.addListener(_onScroll);
// Charger immédiatement les données de manière asynchrone // Charger immédiatement les données de manière asynchrone
_initializeData(); _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 /// Initialise toutes les données nécessaires
Future<void> _initializeData() async { Future<void> _initializeData() async {
try { try {
// 1. S'assurer que les équipements et conteneurs sont chargés // 1. Charger les conflits (batch optimisé)
await _ensureEquipmentsLoaded(); await _loadEquipmentConflicts();
// 2. Mettre à jour le cache immédiatement après le chargement // 2. Initialiser la sélection avec le matériel déjà assigné
if (mounted) {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
setState(() {
// Utiliser allEquipment pour avoir TOUS les équipements sans filtres
_cachedEquipment = equipmentProvider.allEquipment;
_cachedContainers = containerProvider.containers;
_initialDataLoaded = true;
});
DebugLog.info('[EquipmentSelectionDialog] Cache updated: ${_cachedEquipment.length} equipment(s), ${_cachedContainers.length} container(s)');
}
// 3. Initialiser la sélection avec le matériel déjà assigné
await _initializeAlreadyAssigned(); await _initializeAlreadyAssigned();
// 4. Charger les quantités et conflits en parallèle // 3. Charger la première page selon le type sélectionné
await Future.wait([ if (_displayType == SelectionType.equipment) {
_loadAvailableQuantities(), await _loadNextEquipmentPage();
_loadEquipmentConflicts(), } else {
]); await _loadNextContainerPage();
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error during initialization', e);
}
}
/// S'assure que les équipements sont chargés avant d'utiliser le dialog
Future<void> _ensureEquipmentsLoaded() async {
final equipmentProvider = context.read<EquipmentProvider>();
final containerProvider = context.read<ContainerProvider>();
DebugLog.info('[EquipmentSelectionDialog] Starting equipment loading...');
// Forcer le chargement et attendre qu'il soit terminé
await equipmentProvider.ensureLoaded();
// Attendre que le chargement soit vraiment terminé
while (equipmentProvider.isLoading) {
await Future.delayed(const Duration(milliseconds: 100));
}
// Vérifier qu'on a bien des équipements chargés
if (equipmentProvider.allEquipment.isEmpty) {
DebugLog.warning('[EquipmentSelectionDialog] No equipment loaded after ensureLoaded!');
}
// Charger aussi les conteneurs si nécessaire
if (containerProvider.containers.isEmpty) {
await containerProvider.loadContainers();
// Attendre que le chargement des conteneurs soit terminé
while (containerProvider.isLoading) {
await Future.delayed(const Duration(milliseconds: 100));
} }
} catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error initializing data', e);
} }
DebugLog.info('[EquipmentSelectionDialog] Data loaded: ${equipmentProvider.allEquipment.length} equipment(s), ${containerProvider.containers.length} container(s)');
} }
/// Initialise la sélection avec le matériel déjà assigné /// Initialise la sélection avec le matériel déjà assigné
@@ -215,15 +189,15 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Ajouter les conteneurs déjà assignés // Ajouter les conteneurs déjà assignés
if (widget.alreadyAssignedContainers.isNotEmpty) { if (widget.alreadyAssignedContainers.isNotEmpty) {
try { try {
final containerProvider = context.read<ContainerProvider>(); // Pour les conteneurs déjà assignés, on va les chercher via l'API si nécessaire
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers; // ou créer des conteneurs temporaires
for (var containerId in widget.alreadyAssignedContainers) { for (var containerId in widget.alreadyAssignedContainers) {
final container = containers.firstWhere( // Chercher dans le cache ou créer un conteneur temporaire
final container = _cachedContainers.firstWhere(
(c) => c.id == containerId, (c) => c.id == containerId,
orElse: () => ContainerModel( orElse: () => ContainerModel(
id: containerId, id: containerId,
name: 'Inconnu', name: 'Conteneur $containerId',
type: ContainerType.flightCase, type: ContainerType.flightCase,
status: EquipmentStatus.available, status: EquipmentStatus.available,
equipmentIds: [], equipmentIds: [],
@@ -267,6 +241,152 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items'); 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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -275,34 +395,29 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
super.dispose(); super.dispose();
} }
/// Charge les quantités disponibles pour tous les consommables/câbles /// Charge les quantités disponibles pour les consommables/câbles d'une liste d'équipements
Future<void> _loadAvailableQuantities() async { Future<void> _loadAvailableQuantities(List<EquipmentModel> equipments) async {
if (!mounted) return; if (!mounted) return;
setState(() => _isLoadingQuantities = true);
try { try {
final equipmentProvider = context.read<EquipmentProvider>(); final consumables = equipments.where((eq) =>
// Utiliser directement allEquipment du provider (déjà chargé)
final equipment = equipmentProvider.allEquipment;
final consumables = equipment.where((eq) =>
eq.category == EquipmentCategory.consumable || eq.category == EquipmentCategory.consumable ||
eq.category == EquipmentCategory.cable); eq.category == EquipmentCategory.cable);
for (var eq in consumables) { for (var eq in consumables) {
final available = await _availabilityService.getAvailableQuantity( // Ne recharger que si on n'a pas déjà la quantité
equipment: eq, if (!_availableQuantities.containsKey(eq.id)) {
startDate: widget.startDate, final available = await _availabilityService.getAvailableQuantity(
endDate: widget.endDate, equipment: eq,
excludeEventId: widget.excludeEventId, startDate: widget.startDate,
); endDate: widget.endDate,
_availableQuantities[eq.id] = available; excludeEventId: widget.excludeEventId,
);
_availableQuantities[eq.id] = available;
}
} }
} catch (e) { } catch (e) {
DebugLog.error('Error loading quantities', e); DebugLog.error('Error loading quantities', e);
} finally {
if (mounted) setState(() => _isLoadingQuantities = false);
} }
} }
@@ -351,6 +466,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
_conflictingContainerIds = conflictingContainerIds; _conflictingContainerIds = conflictingContainerIds;
_conflictDetails = conflictDetails; _conflictDetails = conflictDetails;
_equipmentQuantities = equipmentQuantities; _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;
});
}); });
} }
@@ -366,15 +527,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit /// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
Future<void> _updateContainerConflictStatus() async { Future<void> _updateContainerConflictStatus() async {
if (!mounted) return; // Vérifier si le widget est toujours monté if (!mounted) return;
try { try {
final containerProvider = context.read<ContainerProvider>(); // Utiliser les containers paginés chargés
final containers = await containerProvider.containersStream.first; for (var container in _paginatedContainers) {
if (!mounted) return; // Vérifier à nouveau après l'async
for (var container in containers) {
// Vérifier si le conteneur lui-même est en conflit // Vérifier si le conteneur lui-même est en conflit
if (_conflictingContainerIds.contains(container.id)) { if (_conflictingContainerIds.contains(container.id)) {
_containerConflicts[container.id] = ContainerConflictInfo( _containerConflicts[container.id] = ContainerConflictInfo(
@@ -406,6 +563,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
} }
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}'); DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
// Déclencher un rebuild pour afficher les changements visuels
if (mounted) {
setState(() {});
}
} catch (e) { } catch (e) {
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e); DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
} }
@@ -639,26 +801,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
} }
/// Recherche les conteneurs recommandés pour un équipement /// 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 { Future<void> _findRecommendedContainers(String equipmentId) async {
try { // Désactivé pour le moment avec le lazy loading
final containerProvider = context.read<ContainerProvider>(); // On pourrait implémenter une API dédiée si nécessaire
return;
// Récupérer les conteneurs depuis le stream
final containerStream = containerProvider.containersStream;
final containers = await containerStream.first;
final recommended = containers
.where((container) => container.equipmentIds.contains(equipmentId))
.toList();
if (recommended.isNotEmpty) {
setState(() {
_recommendedContainers[equipmentId] = recommended;
});
}
} catch (e) {
DebugLog.error('Error finding recommended containers', e);
}
} }
/// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache /// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache
@@ -733,14 +880,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Sélectionner tous les enfants d'un conteneur /// Sélectionner tous les enfants d'un conteneur
Future<void> _selectContainerChildren(String containerId) async { Future<void> _selectContainerChildren(String containerId) async {
try { try {
final containerProvider = context.read<ContainerProvider>(); // Chercher le container dans les données paginées ou le cache
final equipmentProvider = context.read<EquipmentProvider>(); final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment;
final container = containers.firstWhere(
(c) => c.id == containerId, (c) => c.id == containerId,
orElse: () => ContainerModel( orElse: () => ContainerModel(
id: containerId, id: containerId,
@@ -759,7 +900,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
// Sélectionner chaque enfant (sans bloquer, car ils sont "composés") // Sélectionner chaque enfant (sans bloquer, car ils sont "composés")
for (var equipmentId in container.equipmentIds) { for (var equipmentId in container.equipmentIds) {
if (!_selectedItems.containsKey(equipmentId)) { if (!_selectedItems.containsKey(equipmentId)) {
final eq = equipment.firstWhere( // Chercher l'équipement dans les données paginées ou le cache
final eq = [..._paginatedEquipments, ..._cachedEquipment].firstWhere(
(e) => e.id == equipmentId, (e) => e.id == equipmentId,
orElse: () => EquipmentModel( orElse: () => EquipmentModel(
id: equipmentId, id: equipmentId,
@@ -794,12 +936,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Désélectionner tous les enfants d'un conteneur /// Désélectionner tous les enfants d'un conteneur
Future<void> _deselectContainerChildren(String containerId) async { Future<void> _deselectContainerChildren(String containerId) async {
try { try {
final containerProvider = context.read<ContainerProvider>(); // Chercher le container dans les données paginées ou le cache
final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
// Utiliser le cache si disponible
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
final container = containers.firstWhere(
(c) => c.id == containerId, (c) => c.id == containerId,
orElse: () => ContainerModel( orElse: () => ContainerModel(
id: containerId, id: containerId,
@@ -1027,6 +1165,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
), ),
onChanged: (value) { onChanged: (value) {
setState(() => _searchQuery = value.toLowerCase()); setState(() => _searchQuery = value.toLowerCase());
// Recharger depuis le début avec le nouveau filtre
_reloadData();
}, },
), ),
@@ -1078,6 +1218,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
), ),
], ],
), ),
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,
),
),
],
),
], ],
), ),
); );
@@ -1093,6 +1279,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
setState(() { setState(() {
_selectedCategory = selected ? category : null; _selectedCategory = selected ? category : null;
}); });
// Recharger depuis le début avec le nouveau filtre
_reloadData();
}, },
selectedColor: AppColors.rouge, selectedColor: AppColors.rouge,
checkmarkColor: Colors.white, checkmarkColor: Colors.white,
@@ -1104,7 +1292,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
Widget _buildMainList() { Widget _buildMainList() {
// Afficher un indicateur de chargement si les données sont en cours de chargement // Afficher un indicateur de chargement si les données sont en cours de chargement
if (_isLoadingQuantities || _isLoadingConflicts) { if (_isLoadingConflicts) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -1112,9 +1300,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
const CircularProgressIndicator(color: AppColors.rouge), const CircularProgressIndicator(color: AppColors.rouge),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_isLoadingConflicts 'Vérification de la disponibilité...',
? 'Vérification de la disponibilité...'
: 'Chargement des quantités disponibles...',
style: TextStyle(color: Colors.grey.shade600), style: TextStyle(color: Colors.grey.shade600),
), ),
], ],
@@ -1128,150 +1314,105 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles /// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
Widget _buildHierarchicalList() { Widget _buildHierarchicalList() {
return Consumer2<ContainerProvider, EquipmentProvider>( return ValueListenableBuilder<int>(
builder: (context, containerProvider, equipmentProvider, child) { valueListenable: _selectionChangeNotifier,
// Utiliser les données du cache si disponibles, sinon utiliser allEquipment des providers builder: (context, _, __) {
final allContainers = _initialDataLoaded ? _cachedContainers : containerProvider.containers; // Filtrer les données paginées selon le type affiché
final allEquipment = _initialDataLoaded ? _cachedEquipment : equipmentProvider.allEquipment; List<Widget> itemWidgets = [];
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection if (_displayType == SelectionType.equipment) {
return ValueListenableBuilder<int>( // Filtrer côté client pour "Afficher équipements déjà utilisés"
valueListenable: _selectionChangeNotifier, final filteredEquipments = _paginatedEquipments.where((eq) {
builder: (context, _, __) { if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
// Filtrage des boîtes return false;
final filteredContainers = allContainers.where((container) { }
// Filtre par conflit (masquer si non cochée et en conflit) return true;
if (!_showConflictingItems && _conflictingContainerIds.contains(container.id)) { }).toList();
return false;
}
// Filtre par catégorie : afficher uniquement les boîtes contenant au moins 1 équipement de la catégorie itemWidgets = filteredEquipments.map((equipment) {
if (_selectedCategory != null) { return _buildEquipmentCard(equipment, key: ValueKey('equipment_${equipment.id}'));
final hasEquipmentOfCategory = container.equipmentIds.any((eqId) { }).toList();
final equipment = allEquipment.firstWhere( } else {
(e) => e.id == eqId, // Containers
orElse: () => EquipmentModel( final filteredContainers = _paginatedContainers.where((container) {
id: '', if (!_showConflictingItems) {
name: '', // Vérifier si le container lui-même est en conflit
category: EquipmentCategory.other, if (_conflictingContainerIds.contains(container.id)) {
status: EquipmentStatus.available, return false;
maintenanceIds: [], }
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
),
);
return equipment.id.isNotEmpty && equipment.category == _selectedCategory;
});
if (!hasEquipmentOfCategory) { // Vérifier si le container a des équipements enfants en conflit
return false; final hasConflictingChildren = container.equipmentIds.any(
} (eqId) => _conflictingEquipmentIds.contains(eqId),
} );
// Filtre par recherche if (hasConflictingChildren) {
if (_searchQuery.isNotEmpty) { return false;
final searchLower = _searchQuery.toLowerCase(); }
return container.id.toLowerCase().contains(searchLower) || }
container.name.toLowerCase().contains(searchLower); return true;
} }).toList();
return true; itemWidgets = filteredContainers.map((container) {
}).toList(); return _buildContainerCard(container, key: ValueKey('container_${container.id}'));
}).toList();
}
// Filtrage des équipements (TOUS, pas seulement les orphelins) return ListView(
final filteredEquipment = allEquipment.where((eq) { controller: _scrollController,
// Filtre par conflit (masquer si non cochée et en conflit) padding: const EdgeInsets.all(16),
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) { children: [
return false; // Header
} _buildSectionHeader(
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
itemWidgets.length,
),
const SizedBox(height: 12),
// Filtre par catégorie // Items
if (_selectedCategory != null && eq.category != _selectedCategory) { ...itemWidgets,
return false;
}
// Filtre par recherche // Indicateur de chargement en bas
if (_searchQuery.isNotEmpty) { if (_isLoadingMore)
final searchLower = _searchQuery.toLowerCase(); const Padding(
return eq.id.toLowerCase().contains(searchLower) || padding: EdgeInsets.all(16),
(eq.brand?.toLowerCase().contains(searchLower) ?? false) || child: Center(
(eq.model?.toLowerCase().contains(searchLower) ?? false); child: CircularProgressIndicator(color: AppColors.rouge),
} ),
),
return true; // Message si fin de liste
}).toList(); 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),
),
),
),
return ListView( // Message si rien trouvé
controller: _scrollController, // Préserve la position de scroll if (itemWidgets.isEmpty && !_isLoadingMore)
padding: const EdgeInsets.all(16), Center(
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll child: Padding(
children: [ padding: const EdgeInsets.all(32),
// SECTION 1 : BOÎTES child: Column(
if (filteredContainers.isNotEmpty) ...[ children: [
_buildCollapsibleSectionHeader( Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
'Boîtes', const SizedBox(height: 16),
Icons.inventory, Text(
filteredContainers.length, 'Aucun résultat trouvé',
_containersExpanded, style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
(expanded) {
setState(() {
_containersExpanded = expanded;
});
},
), ),
const SizedBox(height: 12),
if (_containersExpanded) ...[
...filteredContainers.map((container) => _buildContainerCard(
container,
key: ValueKey('container_${container.id}'),
)),
const SizedBox(height: 24),
],
], ],
),
// SECTION 2 : TOUS LES ÉQUIPEMENTS ),
if (filteredEquipment.isNotEmpty) ...[ ),
_buildCollapsibleSectionHeader( ],
'Tous les équipements', );
Icons.inventory_2,
filteredEquipment.length,
_equipmentExpanded,
(expanded) {
setState(() {
_equipmentExpanded = expanded;
});
},
),
const SizedBox(height: 12),
if (_equipmentExpanded) ...[
...filteredEquipment.map((equipment) => _buildEquipmentCard(
equipment,
key: ValueKey('equipment_${equipment.id}'),
)),
],
],
// Message si rien n'est trouvé
if (filteredContainers.isEmpty && filteredEquipment.isEmpty)
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),
),
],
),
),
),
],
);
},
); // Fin du ValueListenableBuilder
}, },
); );
} }
@@ -1873,10 +2014,10 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
const SizedBox(width: 4), const SizedBox(width: 4),
Text( Text(
'${container.itemCount} équipement(s)', '${container.itemCount} équipement(s)',
style: TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 13,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: Colors.blue.shade700, color: Colors.blue,
), ),
), ),
if (hasConflict) ...[ if (hasConflict) ...[
@@ -1965,68 +2106,65 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
/// Widget pour afficher les équipements enfants d'un conteneur /// Widget pour afficher les équipements enfants d'un conteneur
Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) { Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) {
return Consumer<EquipmentProvider>( // Utiliser les équipements paginés et le cache
builder: (context, provider, child) { final allEquipment = [..._paginatedEquipments, ..._cachedEquipment];
return StreamBuilder<List<EquipmentModel>>( final childEquipments = allEquipment
stream: provider.equipmentStream, .where((eq) => container.equipmentIds.contains(eq.id))
builder: (context, snapshot) { .toList();
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(child: CircularProgressIndicator()),
);
}
final allEquipment = snapshot.data ?? []; DebugLog.info('[EquipmentSelectionDialog] Building container children for ${container.id}: ${container.equipmentIds.length} IDs, found ${childEquipments.length} equipment(s) in cache (total cache: ${_cachedEquipment.length})');
final childEquipments = allEquipment
.where((eq) => container.equipmentIds.contains(eq.id))
.toList();
if (childEquipments.isEmpty) { if (container.equipmentIds.isNotEmpty && childEquipments.isEmpty) {
return Container( DebugLog.error('[EquipmentSelectionDialog] Container ${container.id} has ${container.equipmentIds.length} equipment IDs but found 0 equipment in cache!');
decoration: BoxDecoration( DebugLog.info('[EquipmentSelectionDialog] Looking for IDs: ${container.equipmentIds.take(5).join(", ")}...');
color: Colors.grey.shade50, DebugLog.info('[EquipmentSelectionDialog] Cache contains IDs: ${_cachedEquipment.take(5).map((e) => e.id).join(", ")}...');
border: Border(top: BorderSide(color: Colors.grey.shade300)), }
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,
), ),
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, const SizedBox(height: 12),
children: [ ...childEquipments.map((eq) {
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 hasConflict = _equipmentConflicts.containsKey(eq.id);
final conflicts = _equipmentConflicts[eq.id] ?? []; final conflicts = _equipmentConflicts[eq.id] ?? [];
@@ -2115,10 +2253,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
], ],
), ),
); );
},
);
},
);
} }
Widget _buildSelectionPanel() { Widget _buildSelectionPanel() {

View File

@@ -156,211 +156,8 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)'); DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// Charger les équipements et conteneurs // ✅ Pas de vérification de conflits : déjà fait dans le pop-up
final containerProvider = context.read<ContainerProvider>(); // On enregistre directement la sélection
final equipmentProvider = context.read<EquipmentProvider>();
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 // Fusionner avec l'existant
final updatedEquipment = [...widget.assignedEquipment]; final updatedEquipment = [...widget.assignedEquipment];
@@ -398,7 +195,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
// Recharger le cache // Recharger le cache
await _loadEquipmentAndContainers(); await _loadEquipmentAndContainers();
} }
void _removeEquipment(String equipmentId) { void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment final updated = widget.assignedEquipment

View File

@@ -9,12 +9,14 @@ class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
final String title; final String title;
final List<Widget>? actions; final List<Widget>? actions;
final bool showLogoutButton; final bool showLogoutButton;
final Widget? leading;
const CustomAppBar({ const CustomAppBar({
super.key, super.key,
required this.title, required this.title,
this.actions, this.actions,
this.showLogoutButton = true, this.showLogoutButton = true,
this.leading,
}); });
@override @override
@@ -30,6 +32,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
return AppBar( return AppBar(
title: Text(widget.title), title: Text(widget.title),
backgroundColor: AppColors.rouge, backgroundColor: AppColors.rouge,
leading: widget.leading,
actions: [ actions: [
NotificationBadge(), NotificationBadge(),
if (widget.showLogoutButton) if (widget.showLogoutButton)