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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|||||||
93
em2rp/functions/migrate_equipment_ids.js
Normal file
93
em2rp/functions/migrate_equipment_ids.js
Normal 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);
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal file
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal 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],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user