diff --git a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache index fb2728c..70051b3 100644 --- a/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache +++ b/em2rp/.firebase/hosting.YnVpbGRcd2Vi.cache @@ -34,16 +34,16 @@ assets/assets/images/tshirt-incrust.webp,1737393735487,af7cb34adfca19c0b41c8eb63 assets/assets/icons/truss.svg,1761734811263,8ddfbbb4f96de5614348eb23fa55f61b2eb1edb064719a8bbd791c35883ec4cc assets/assets/icons/tape.svg,1761734809221,631183f0ff972aa4dc3f9f51dc7abd41a607df749d1f9a44fa7e77202d95ccde assets/assets/icons/flight-case.svg,1761734822495,0cef47fdf5d7efdd110763c32f792ef9735df35c4f42ae7d02d5fbda40e6148d -version.json,1774883074073,049c47e9089dc5497475a6cf7733e11235bc9cfa30d458cc9a8eae761214c2b8 -flutter_service_worker.js,1774883173949,00cc791f6cc0d2beb4b16cc382b049268125aa6a7c5b73cd4bc89a003fc70f3a -index.html,1774883102020,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 -flutter_bootstrap.js,1774883102005,80bbca812eb76632e250fe5c6b726db647443cbabc7f90010618e6a6f445d222 -assets/FontManifest.json,1774883170660,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 -assets/AssetManifest.bin,1774883170657,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 -assets/AssetManifest.bin.json,1774883170660,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 -assets/shaders/ink_sparkle.frag,1774883170848,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 -assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1774883173201,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb -assets/AssetManifest.json,1774883170657,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 -assets/fonts/MaterialIcons-Regular.otf,1774883173207,d1409c3c8050990bdc63a413539d600245a27c9794a053c211299cc86d4f6a5c -assets/NOTICES,1774883170660,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 -main.dart.js,1774883168025,bc4bc60206728a982496fe5977f48e690fe8abdfd1167a9226de18fe0052cdcf +version.json,1776853346038,b9cb334972abfae63e76477e574d02e1b3cdf4210fa3edf744a8d33c6250a12e +index.html,1776853378875,4e8c00552c71ef134bead8bc03706952e7a415d70fca602a3839dc02a3f7ae10 +flutter_service_worker.js,1776853476352,5524fe7e227e1a0ecdd4c9b14a764638a86fe836ced8d3f80ab0817d043d436a +flutter_bootstrap.js,1776853378859,a9fbceaa97579d418c548aaa1b4fc94284bc33ef5fc2a835d80f9a96d8d6bbd8 +assets/FontManifest.json,1776853472496,e38b95988f5d060cf9b7ce97cb5ac9236d6f4cc04a11d69567df97b2b4cbc5e5 +assets/AssetManifest.json,1776853472495,0e35e7214421c832bf41b0af7c03037e66fee508b857d3143f40f6862e454dd6 +assets/AssetManifest.bin.json,1776853472495,3a244f5f866d93c17f420cc01b1ba318584b4da92af9512d9ba4acd099b49d53 +assets/AssetManifest.bin,1776853472495,205908d2fcf1ca9708b7d1f91ec7ea80c5f07eaf6cfc1458cb9364a4d9106907 +assets/packages/cupertino_icons/assets/CupertinoIcons.ttf,1776853475437,d41473de1f7708a0702d7f19327693486512db442f6ab0cf7774e6d6576f9fcb +assets/shaders/ink_sparkle.frag,1776853472722,591c7517d5cb43eb91ea451e0d3f9f585cbf8298cf6c46a9144b77cb0775a406 +assets/fonts/MaterialIcons-Regular.otf,1776853475444,213705f583b626b88f6f0c7a122a13567b6492f560ad5284176ef149f0b51fef +assets/NOTICES,1776853472497,1d9a08da58db7959b9607f0f1f342f96243af76dc608ed659614d586ec58cd79 +main.dart.js,1776853469906,1fc20606c99d6d4dde1e7d2a8a0b2e8f2e6c6f81317b8e7e4dd17d54f71a23b2 diff --git a/em2rp/CHANGELOG.md b/em2rp/CHANGELOG.md index 0e559c3..db38293 100644 --- a/em2rp/CHANGELOG.md +++ b/em2rp/CHANGELOG.md @@ -2,6 +2,9 @@ Toutes les modifications notables de ce projet seront documentées dans ce fichier. +## 22/04/2026 +Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement + ## 30/03/2026 Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement. diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 4d7c843..537a8d6 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -203,29 +203,51 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => { return; } - const { equipmentId } = req.body.data; + const { equipmentId, forceDelete = false } = req.body.data; if (!equipmentId) { res.status(400).json({ error: 'Equipment ID is required' }); return; } - // Vérifier si l'équipement est utilisé dans des événements actifs + // Vérifier si l'équipement est utilisé dans des événements à venir const eventsSnapshot = await db.collection('events') .where('status', '!=', 'CANCELLED') .get(); + const now = new Date(); + const upcomingEvents = []; + for (const eventDoc of eventsSnapshot.docs) { const eventData = eventDoc.data(); const assignedEquipment = eventData.assignedEquipment || []; - if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { - res.status(409).json({ - error: 'Cannot delete equipment: it is assigned to active events', - eventId: eventDoc.id - }); - return; + if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) { + continue; } + + let eventStart = null; + if (eventData.StartDateTime) { + eventStart = eventData.StartDateTime.toDate + ? eventData.StartDateTime.toDate() + : new Date(eventData.StartDateTime); + } + + if (eventStart && eventStart > now) { + upcomingEvents.push({ + eventId: eventDoc.id, + eventName: eventData.Name || '', + startDate: eventStart.toISOString(), + }); + } + } + + if (upcomingEvents.length > 0 && !forceDelete) { + res.status(409).json({ + error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events', + upcomingEvents, + }); + return; } await db.collection('equipments').doc(equipmentId).delete(); @@ -1864,6 +1886,116 @@ exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => { } })); +const normalizeSearchText = (value) => { + return (value || '') + .toString() + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); +}; + +const getEventStartDate = (eventData) => { + const startValue = eventData.StartDateTime; + + if (!startValue) { + return null; + } + + if (startValue.toDate) { + return startValue.toDate(); + } + + const parsedDate = new Date(startValue); + return Number.isNaN(parsedDate.getTime()) ? null : parsedDate; +}; + +const getEventWorkforceUids = (eventData) => { + if (!eventData.workforce || !Array.isArray(eventData.workforce)) { + return []; + } + + return eventData.workforce + .map((userRef) => { + if (userRef && userRef.id) { + return userRef.id; + } + + if (typeof userRef === 'string' && userRef.startsWith('users/')) { + return userRef.split('/')[1]; + } + + return null; + }) + .filter((uid) => uid !== null); +}; + +const serializeEventSearchResult = (doc) => { + const data = doc.data(); + + return { + id: doc.id, + ...helpers.serializeTimestamps(data), + workforce: getEventWorkforceUids(data), + }; +}; + +// ============================================================================ +// EVENTS - Search +// ============================================================================ +exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => { + try { + const decodedToken = await auth.authenticateUser(req); + const { userId, query, limit = 20 } = req.body.data || {}; + const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20; + + const normalizedQuery = normalizeSearchText(query); + if (!normalizedQuery) { + res.status(200).json({ events: [] }); + return; + } + + const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events'); + + let eventsSnapshot; + if (canViewAll) { + eventsSnapshot = await db.collection('events').get(); + } else { + const userRef = db.collection('users').doc(userId || decodedToken.uid); + eventsSnapshot = await db.collection('events') + .where('workforce', 'array-contains', userRef) + .get(); + } + + const matchingEvents = eventsSnapshot.docs + .filter((doc) => { + const eventData = doc.data(); + const startDate = getEventStartDate(eventData); + const searchableText = normalizeSearchText([ + eventData.Name, + eventData.Description, + eventData.Address, + startDate ? startDate.toLocaleString('fr-FR') : '', + startDate ? startDate.toISOString() : '', + ].join(' ')); + + return searchableText.includes(normalizedQuery); + }) + .sort((a, b) => { + const startA = getEventStartDate(a.data()) || new Date(0); + const startB = getEventStartDate(b.data()) || new Date(0); + return startA.getTime() - startB.getTime(); + }) + .slice(0, maxResults) + .map((doc) => serializeEventSearchResult(doc)); + + res.status(200).json({ events: matchingEvents }); + } catch (error) { + logger.error('Error searching events:', error); + res.status(500).json({ error: error.message }); + } +})); + /** * Récupère un événement avec tous les détails (équipements complets + containers avec enfants) * Optimisé pour la page de préparation et l'affichage détaillé diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index 7799eb6..fb2b95a 100644 --- a/em2rp/lib/config/app_version.dart +++ b/em2rp/lib/config/app_version.dart @@ -1,6 +1,6 @@ /// Configuration de la version de l'application class AppVersion { - static const String version = '1.1.20'; + static const String version = '1.1.21'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/config/env.dart b/em2rp/lib/config/env.dart index eff3b62..539a953 100644 --- a/em2rp/lib/config/env.dart +++ b/em2rp/lib/config/env.dart @@ -1,9 +1,9 @@ class Env { - static const bool isDevelopment = false; + static const bool isDevelopment = true; // Configuration de l'auto-login en développement - static const String devAdminEmail = ''; - static const String devAdminPassword = ''; + static const String devAdminEmail = 'paul.fournel@em2events.fr'; + static const String devAdminPassword = 'Pastis51!'; // URLs et endpoints static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; @@ -14,3 +14,4 @@ class Env { // Autres configurations static const int apiTimeout = 30000; // 30 secondes } + diff --git a/em2rp/lib/providers/equipment_provider.dart b/em2rp/lib/providers/equipment_provider.dart index 686034c..cf79f42 100644 --- a/em2rp/lib/providers/equipment_provider.dart +++ b/em2rp/lib/providers/equipment_provider.dart @@ -433,9 +433,9 @@ class EquipmentProvider extends ChangeNotifier { } /// Supprimer un équipement - Future deleteEquipment(String equipmentId) async { + Future deleteEquipment(String equipmentId, {bool forceDelete = false}) async { try { - await _dataService.deleteEquipment(equipmentId); + await _dataService.deleteEquipment(equipmentId, forceDelete: forceDelete); if (_usePagination) { await reload(); } else { diff --git a/em2rp/lib/providers/event_provider.dart b/em2rp/lib/providers/event_provider.dart index 6db1b36..88f9d38 100644 --- a/em2rp/lib/providers/event_provider.dart +++ b/em2rp/lib/providers/event_provider.dart @@ -19,7 +19,8 @@ class EventProvider with ChangeNotifier { bool _lastCanViewAll = false; // Nouveau: Cache par mois pour le lazy loading - final Map> _eventsByMonth = {}; // "2026-02" => [events] + final Map> _eventsByMonth = + {}; // "2026-02" => [events] String? _currentMonth; // Mois actuellement affiché List get events => _events; @@ -28,7 +29,8 @@ class EventProvider with ChangeNotifier { /// Vérifie si les données doivent être rechargées (cache de 30 secondes) bool _shouldReload(String userId, bool canViewAllEvents) { if (_lastLoadTime == null) return true; - if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) return true; + if (_lastUserId != userId || _lastCanViewAll != canViewAllEvents) + return true; final now = DateTime.now(); final difference = now.difference(_lastLoadTime!); @@ -36,12 +38,14 @@ class EventProvider with ChangeNotifier { } /// Charger les événements d'un utilisateur via l'API - Future loadUserEvents(String userId, {bool canViewAllEvents = false, bool forceReload = false}) async { + Future loadUserEvents(String userId, + {bool canViewAllEvents = false, bool forceReload = false}) async { PerformanceMonitor.start('EventProvider.loadUserEvents'); // Éviter les rechargements inutiles if (!forceReload && !_shouldReload(userId, canViewAllEvents)) { - print('Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)'); + print( + 'Using cached events (loaded ${DateTime.now().difference(_lastLoadTime!).inSeconds}s ago)'); PerformanceMonitor.end('EventProvider.loadUserEvents'); return; } @@ -50,7 +54,8 @@ class EventProvider with ChangeNotifier { notifyListeners(); try { - print('Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); + print( + 'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); PerformanceMonitor.start('EventProvider.getEvents_API'); // Charger via l'API - les permissions sont vérifiées côté serveur @@ -61,9 +66,8 @@ class EventProvider with ChangeNotifier { final usersData = result['users'] as Map; // Stocker les utilisateurs dans le cache - _usersCache = usersData.map((key, value) => - MapEntry(key, value as Map) - ); + _usersCache = usersData + .map((key, value) => MapEntry(key, value as Map)); print('Found ${eventsData.length} events from API'); @@ -74,7 +78,8 @@ class EventProvider with ChangeNotifier { // Parser chaque événement for (var eventData in eventsData) { try { - final event = EventModel.fromMap(eventData, eventData['id'] as String); + final event = + EventModel.fromMap(eventData, eventData['id'] as String); allEvents.add(event); } catch (e) { print('Failed to parse event ${eventData['id']}: $e'); @@ -88,7 +93,8 @@ class EventProvider with ChangeNotifier { _lastUserId = userId; _lastCanViewAll = canViewAllEvents; - print('Successfully loaded ${_events.length} events ($failedCount failed)'); + print( + 'Successfully loaded ${_events.length} events ($failedCount failed)'); _isLoading = false; notifyListeners(); @@ -104,8 +110,9 @@ class EventProvider with ChangeNotifier { /// Charger les événements d'un mois spécifique (lazy loading optimisé) Future loadMonthEvents(String userId, int year, int month, - {bool canViewAllEvents = false, bool forceReload = false, bool silent = false}) async { - + {bool canViewAllEvents = false, + bool forceReload = false, + bool silent = false}) async { final monthKey = '$year-${month.toString().padLeft(2, '0')}'; // Vérifier le cache @@ -130,19 +137,15 @@ class EventProvider with ChangeNotifier { PerformanceMonitor.start('EventProvider.loadMonthEvents_API'); final result = await _dataService.getEventsByMonth( - userId: userId, - year: year, - month: month - ); + userId: userId, year: year, month: month); PerformanceMonitor.end('EventProvider.loadMonthEvents_API'); final eventsData = result['events'] as List>; final usersData = result['users'] as Map; // Mettre à jour le cache utilisateurs (addAll pour cumuler) - _usersCache.addAll( - usersData.map((key, value) => MapEntry(key, value as Map)) - ); + _usersCache.addAll(usersData + .map((key, value) => MapEntry(key, value as Map))); print('[EventProvider] Found ${eventsData.length} events for $monthKey'); @@ -153,7 +156,8 @@ class EventProvider with ChangeNotifier { // Parser les événements for (var eventData in eventsData) { try { - final event = EventModel.fromMap(eventData, eventData['id'] as String); + final event = + EventModel.fromMap(eventData, eventData['id'] as String); monthEvents.add(event); } catch (e) { print('[EventProvider] Failed to parse event ${eventData['id']}: $e'); @@ -176,7 +180,8 @@ class EventProvider with ChangeNotifier { _lastUserId = userId; _lastCanViewAll = canViewAllEvents; - print('[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)'); + print( + '[EventProvider] Successfully loaded ${monthEvents.length} events for $monthKey ($failedCount failed)'); if (!silent) { _isLoading = false; @@ -195,7 +200,6 @@ class EventProvider with ChangeNotifier { /// Précharger les mois adjacents en arrière-plan void preloadAdjacentMonths(String userId, int year, int month, {bool canViewAllEvents = false}) { - // Mois précédent final prevMonth = month == 1 ? 12 : month - 1; final prevYear = month == 1 ? year - 1 : year; @@ -230,8 +234,10 @@ class EventProvider with ChangeNotifier { } /// Recharger les événements (utilise le dernier userId) - Future refreshEvents(String userId, {bool canViewAllEvents = false}) async { - await loadUserEvents(userId, canViewAllEvents: canViewAllEvents, forceReload: true); + Future refreshEvents(String userId, + {bool canViewAllEvents = false}) async { + await loadUserEvents(userId, + canViewAllEvents: canViewAllEvents, forceReload: true); } /// Récupérer un événement spécifique par ID @@ -243,6 +249,41 @@ class EventProvider with ChangeNotifier { } } + /// Recherche des événements accessibles à l'utilisateur. + Future> searchEvents({ + required String userId, + required String query, + int limit = 20, + }) async { + final trimmedQuery = query.trim(); + if (trimmedQuery.isEmpty) { + return []; + } + + final result = await _dataService.searchEvents( + userId: userId, + query: trimmedQuery, + limit: limit, + ); + + final events = []; + for (final eventData in result) { + try { + final eventId = eventData['id'] as String?; + if (eventId == null || eventId.isEmpty) { + continue; + } + + events.add(EventModel.fromMap(eventData, eventId)); + } catch (e) { + print('Failed to parse searched event ${eventData['id']}: $e'); + } + } + + events.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + return events; + } + /// Ajouter un nouvel événement Future addEvent(EventModel event) async { try { @@ -250,7 +291,8 @@ class EventProvider with ChangeNotifier { _events.add(event); // Ajouter dans le cache par mois - final monthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; + final monthKey = + '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; if (_eventsByMonth.containsKey(monthKey)) { _eventsByMonth[monthKey]!.add(event); } @@ -272,8 +314,10 @@ class EventProvider with ChangeNotifier { _events[index] = event; // Mettre à jour dans le cache par mois - final oldMonthKey = '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}'; - final newMonthKey = '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; + final oldMonthKey = + '${oldEvent.startDateTime.year}-${oldEvent.startDateTime.month.toString().padLeft(2, '0')}'; + final newMonthKey = + '${event.startDateTime.year}-${event.startDateTime.month.toString().padLeft(2, '0')}'; // Si le mois a changé, supprimer de l'ancien et ajouter au nouveau if (oldMonthKey != newMonthKey) { @@ -286,7 +330,8 @@ class EventProvider with ChangeNotifier { } else { // Même mois, juste mettre à jour if (_eventsByMonth.containsKey(newMonthKey)) { - final monthIndex = _eventsByMonth[newMonthKey]!.indexWhere((e) => e.id == event.id); + final monthIndex = _eventsByMonth[newMonthKey]! + .indexWhere((e) => e.id == event.id); if (monthIndex != -1) { _eventsByMonth[newMonthKey]![monthIndex] = event; } @@ -308,7 +353,8 @@ class EventProvider with ChangeNotifier { // Trouver l'événement pour obtenir sa date avant de le supprimer final eventToDelete = _events.firstWhere((e) => e.id == eventId); - final monthKey = '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}'; + final monthKey = + '${eventToDelete.startDateTime.year}-${eventToDelete.startDateTime.month.toString().padLeft(2, '0')}'; // Supprimer de _events _events.removeWhere((event) => event.id == eventId); diff --git a/em2rp/lib/services/api_service.dart b/em2rp/lib/services/api_service.dart index 3c2c513..dd84700 100644 --- a/em2rp/lib/services/api_service.dart +++ b/em2rp/lib/services/api_service.dart @@ -173,6 +173,8 @@ class FirebaseFunctionsApiService implements ApiService { statusCode: response.statusCode, ); } + } on ApiException { + rethrow; } catch (e) { DebugLog.error('[API] Error during request: $functionName', e); throw ApiException( diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 7c62b13..5e6f2d8 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -27,7 +27,8 @@ class DataService { if (eventTypes == null) return []; return eventTypes.map((e) => e as Map).toList(); } catch (e) { - throw Exception('Erreur lors de la récupération des types d\'événements: $e'); + throw Exception( + 'Erreur lors de la récupération des types d\'événements: $e'); } } @@ -55,15 +56,18 @@ class DataService { try { final data = {'eventId': eventId}; - if (assignedEquipment != null) data['assignedEquipment'] = assignedEquipment; - if (preparationStatus != null) data['preparationStatus'] = preparationStatus; + if (assignedEquipment != null) + data['assignedEquipment'] = assignedEquipment; + if (preparationStatus != null) + data['preparationStatus'] = preparationStatus; if (loadingStatus != null) data['loadingStatus'] = loadingStatus; if (unloadingStatus != null) data['unloadingStatus'] = unloadingStatus; if (returnStatus != null) data['returnStatus'] = returnStatus; await _apiService.call('updateEventEquipment', data); } catch (e) { - throw Exception('Erreur lors de la mise à jour des équipements de l\'événement: $e'); + throw Exception( + 'Erreur lors de la mise à jour des équipements de l\'événement: $e'); } } @@ -77,11 +81,13 @@ class DataService { final data = {'equipmentId': equipmentId}; if (status != null) data['status'] = status; - if (availableQuantity != null) data['availableQuantity'] = availableQuantity; + if (availableQuantity != null) + data['availableQuantity'] = availableQuantity; await _apiService.call('updateEquipmentStatusOnly', data); } catch (e) { - throw Exception('Erreur lors de la mise à jour du statut de l\'équipement: $e'); + throw Exception( + 'Erreur lors de la mise à jour du statut de l\'équipement: $e'); } } @@ -106,7 +112,8 @@ class DataService { } /// Crée un équipement - Future createEquipment(String equipmentId, Map data) async { + Future createEquipment( + String equipmentId, Map data) async { try { // S'assurer que l'ID est dans les données final equipmentData = Map.from(data); @@ -119,7 +126,8 @@ class DataService { } /// Met à jour un équipement - Future updateEquipment(String equipmentId, Map data) async { + Future updateEquipment( + String equipmentId, Map data) async { try { await _apiService.call('updateEquipment', { 'equipmentId': equipmentId, @@ -131,18 +139,26 @@ class DataService { } /// Supprime un équipement - Future deleteEquipment(String equipmentId) async { + Future deleteEquipment(String equipmentId, + {bool forceDelete = false}) async { try { - await _apiService.call('deleteEquipment', {'equipmentId': equipmentId}); + await _apiService.call('deleteEquipment', { + 'equipmentId': equipmentId, + 'forceDelete': forceDelete, + }); + } on ApiException { + rethrow; } catch (e) { throw Exception('Erreur lors de la suppression de l\'équipement: $e'); } } /// Récupère les événements utilisant un type d'événement donné - Future>> getEventsByEventType(String eventTypeId) async { + Future>> getEventsByEventType( + String eventTypeId) async { try { - final result = await _apiService.call('getEventsByEventType', {'eventTypeId': eventTypeId}); + final result = await _apiService + .call('getEventsByEventType', {'eventTypeId': eventTypeId}); final events = result['events'] as List?; if (events == null) return []; return events.map((e) => e as Map).toList(); @@ -271,7 +287,8 @@ class DataService { final events = result['events'] as List? ?? []; final users = result['users'] as Map? ?? {}; - print('[DataService] Events loaded for $year-$month: ${events.length} events'); + print( + '[DataService] Events loaded for $year-$month: ${events.length} events'); return { 'events': events.map((e) => e as Map).toList(), @@ -279,7 +296,32 @@ class DataService { }; } catch (e) { print('[DataService] Error getting events by month: $e'); - throw Exception('Erreur lors de la récupération des événements du mois: $e'); + throw Exception( + 'Erreur lors de la récupération des événements du mois: $e'); + } + } + + /// Recherche des événements accessibles à l'utilisateur. + Future>> searchEvents({ + required String userId, + required String query, + int limit = 20, + }) async { + try { + final result = await _apiService.call('searchEvents', { + 'userId': userId, + 'query': query, + 'limit': limit, + }); + + final events = result['events'] as List?; + if (events == null) { + return []; + } + + return events.map((e) => e as Map).toList(); + } catch (e) { + throw Exception('Erreur lors de la recherche d\'événements: $e'); } } @@ -299,7 +341,8 @@ class DataService { throw Exception('Event not found'); } - print('[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); + print( + '[DataService] Event loaded with ${equipments.length} equipments and ${containers.length} containers'); return { 'event': event, @@ -308,7 +351,8 @@ class DataService { }; } catch (e) { print('[DataService] Error getting event with details: $e'); - throw Exception('Erreur lors de la récupération de l\'événement avec détails: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'événement avec détails: $e'); } } @@ -332,11 +376,13 @@ class DataService { } /// Récupère plusieurs équipements par leurs IDs - Future>> getEquipmentsByIds(List equipmentIds) async { + Future>> getEquipmentsByIds( + List equipmentIds) async { try { if (equipmentIds.isEmpty) return []; - print('[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); + print( + '[DataService] Getting equipments by IDs: ${equipmentIds.length} items'); final result = await _apiService.call('getEquipmentsByIds', { 'equipmentIds': equipmentIds, }); @@ -366,11 +412,13 @@ class DataService { } /// Récupère plusieurs containers par leurs IDs - Future>> getContainersByIds(List containerIds) async { + Future>> getContainersByIds( + List containerIds) async { try { if (containerIds.isEmpty) return []; - print('[DataService] Getting containers by IDs: ${containerIds.length} items'); + print( + '[DataService] Getting containers by IDs: ${containerIds.length} items'); final result = await _apiService.call('getContainersByIds', { 'containerIds': containerIds, }); @@ -415,22 +463,25 @@ class DataService { params['searchQuery'] = searchQuery; } - final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( 'getEquipmentsPaginated', params, ); return { 'equipments': (result['equipments'] as List?) - ?.map((e) => e as Map) - .toList() ?? [], + ?.map((e) => e as Map) + .toList() ?? + [], 'hasMore': result['hasMore'] as bool? ?? false, 'lastVisible': result['lastVisible'] as String?, 'total': result['total'] as int? ?? 0, }; } catch (e) { DebugLog.error('[DataService] Error in getEquipmentsPaginated', e); - throw Exception('Erreur lors de la récupération paginée des équipements: $e'); + throw Exception( + 'Erreur lors de la récupération paginée des équipements: $e'); } } @@ -460,22 +511,25 @@ class DataService { params['searchQuery'] = searchQuery; } - final result = await (_apiService as FirebaseFunctionsApiService).callPaginated( + final result = + await (_apiService as FirebaseFunctionsApiService).callPaginated( 'getContainersPaginated', params, ); return { 'containers': (result['containers'] as List?) - ?.map((e) => e as Map) - .toList() ?? [], + ?.map((e) => e as Map) + .toList() ?? + [], 'hasMore': result['hasMore'] as bool? ?? false, 'lastVisible': result['lastVisible'] as String?, 'total': result['total'] as int? ?? 0, }; } catch (e) { DebugLog.error('[DataService] Error in getContainersPaginated', e); - throw Exception('Erreur lors de la récupération paginée des containers: $e'); + throw Exception( + 'Erreur lors de la récupération paginée des containers: $e'); } } @@ -512,7 +566,8 @@ class DataService { return result['user'] as Map; } catch (e) { print('[DataService] Error getting current user: $e'); - throw Exception('Erreur lors de la récupération de l\'utilisateur actuel: $e'); + throw Exception( + 'Erreur lors de la récupération de l\'utilisateur actuel: $e'); } } @@ -593,7 +648,8 @@ class DataService { }); return result; } catch (e) { - throw Exception('Erreur lors de la récupération des équipements en conflit: $e'); + throw Exception( + 'Erreur lors de la récupération des équipements en conflit: $e'); } } @@ -602,7 +658,8 @@ class DataService { // ============================================================================ /// Récupère toutes les maintenances - Future>> getMaintenances({String? equipmentId}) async { + Future>> getMaintenances( + {String? equipmentId}) async { try { final data = {}; if (equipmentId != null) data['equipmentId'] = equipmentId; @@ -619,14 +676,16 @@ class DataService { /// Supprime une maintenance Future deleteMaintenance(String maintenanceId) async { try { - await _apiService.call('deleteMaintenance', {'maintenanceId': maintenanceId}); + await _apiService + .call('deleteMaintenance', {'maintenanceId': maintenanceId}); } catch (e) { throw Exception('Erreur lors de la suppression de la maintenance: $e'); } } /// Récupère les containers contenant un équipement - Future>> getContainersByEquipment(String equipmentId) async { + Future>> getContainersByEquipment( + String equipmentId) async { try { final result = await _apiService.call('getContainersByEquipment', { 'equipmentId': equipmentId, diff --git a/em2rp/lib/utils/equipment_delete_utils.dart b/em2rp/lib/utils/equipment_delete_utils.dart new file mode 100644 index 0000000..7a3d891 --- /dev/null +++ b/em2rp/lib/utils/equipment_delete_utils.dart @@ -0,0 +1,131 @@ +import 'package:em2rp/services/api_service.dart'; +import 'package:flutter/material.dart'; + +/// Utilitaires partages pour la suppression d'equipement avec forcage. +class EquipmentDeleteUtils { + static const String _legacyConflictToken = 'future_event_assignment'; + static const List _conflictMessageTokens = [ + 'cannot delete equipment because it is assigned to upcoming events', + 'cannot delete equipment because it is assigned to future events', + 'assigned to upcoming events', + 'assigned to future events', + ]; + + static const String deleteDialogTitle = 'Confirmer la suppression'; + static const String deleteDialogCancelLabel = 'Annuler'; + static const String deleteDialogConfirmLabel = 'Supprimer'; + static const String deleteSuccessMessage = 'Équipement supprimé avec succès'; + + /// Retourne [name] si renseigne, sinon [id]. + static String resolveEquipmentLabel({required String id, String? name}) { + final trimmedName = name?.trim(); + if (trimmedName == null || trimmedName.isEmpty) { + return id; + } + return trimmedName; + } + + /// Construit le message de confirmation de suppression d'un equipement. + static String buildSingleDeleteConfirmationMessage(String equipmentLabel) { + return 'Voulez-vous vraiment supprimer "$equipmentLabel" ?\n\n' + 'Cette action est irréversible.'; + } + + /// Construit le message de confirmation de suppression multiple. + static String buildBulkDeleteConfirmationMessage(int selectedCount) { + return 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?\n\n' + 'Cette action est irréversible.'; + } + + /// Construit le message de succes de suppression multiple. + static String buildBulkDeleteSuccessMessage(int deletedCount) { + return '$deletedCount équipement(s) supprimé(s) avec succès'; + } + + /// Construit un message d'erreur de suppression homogene. + static String buildDeleteErrorMessage(Object error) { + return 'Erreur lors de la suppression : $error'; + } + + /// Indique si l'erreur correspond a un conflit de suppression 409. + static bool isFutureAssignmentDeleteConflict(Object error) { + if (error is ApiException && !error.isConflict) { + return false; + } + + final normalizedMessage = _normalizeErrorMessage(error); + if (normalizedMessage.contains(_legacyConflictToken)) { + return true; + } + + return _conflictMessageTokens.any(normalizedMessage.contains); + } + + /// Affiche la confirmation de suppression forcee. + static Future showForceDeleteDialog( + BuildContext context, { + required String equipmentLabel, + }) async { + final shouldForceDelete = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Équipement utilisé dans un événement à venir'), + content: Text( + '"$equipmentLabel" est assigné à au moins un événement à venir.\n\n' + 'Voulez-vous forcer la suppression ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Forcer la suppression'), + ), + ], + ), + ); + + return shouldForceDelete == true; + } + + /// Execute une suppression, puis propose un forcage en cas de conflit 409. + static Future deleteWithFutureAssignmentCheck({ + required BuildContext context, + required String equipmentLabel, + required Future Function({bool forceDelete}) deleteEquipment, + }) async { + try { + await deleteEquipment(forceDelete: false); + return true; + } catch (error) { + if (!isFutureAssignmentDeleteConflict(error)) { + rethrow; + } + + if (!context.mounted) { + return false; + } + + final shouldForceDelete = await showForceDeleteDialog( + context, + equipmentLabel: equipmentLabel, + ); + if (!shouldForceDelete) { + return false; + } + + await deleteEquipment(forceDelete: true); + return true; + } + } + + static String _normalizeErrorMessage(Object error) { + if (error is ApiException) { + return error.message.toLowerCase(); + } + return error.toString().toLowerCase(); + } +} diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index 2773f88..9387b4f 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:em2rp/providers/local_user_provider.dart'; @@ -10,6 +11,7 @@ import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart'; +import 'package:intl/intl.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; @@ -40,6 +42,14 @@ class _CalendarPageState extends State { int _selectedEventIndex = 0; String? _selectedUserId; // Filtre par utilisateur (null = tous les événements) + final TextEditingController _searchController = TextEditingController(); + Timer? _searchDebounce; + List _searchResults = []; + String _searchQuery = ''; + String? _searchError; + bool _isSearching = false; + int _searchRequestId = 0; + bool _isMobileSearchVisible = false; bool _isRefreshing = false; double _detailsPaneFraction = 0.35; @@ -105,21 +115,6 @@ class _CalendarPageState extends State { } } - /// Charge les événements de manière asynchrone et sélectionne l'événement approprié - /// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place - Future _loadEventsAsync() async { - PerformanceMonitor.start('CalendarPage.loadEventsAsync'); - await _loadEvents(); - - // Sélectionner l'événement approprié après le chargement - if (mounted) { - PerformanceMonitor.start('CalendarPage.selectDefaultEvent'); - _selectDefaultEvent(); - PerformanceMonitor.end('CalendarPage.selectDefaultEvent'); - } - PerformanceMonitor.end('CalendarPage.loadEventsAsync'); - } - /// Sélectionne automatiquement l'événement le plus proche de maintenant void _selectDefaultEvent() { final eventProvider = Provider.of(context, listen: false); @@ -188,9 +183,15 @@ class _CalendarPageState extends State { } } - /// Filtre les événements selon l'utilisateur sélectionné (si filtre actif) - /// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore - List _getFilteredEvents(List allEvents) { + @override + void dispose() { + _searchDebounce?.cancel(); + _searchController.dispose(); + super.dispose(); + } + + /// Filtre les événements selon l'utilisateur sélectionné (si filtre actif). + List _filterEventsByUser(List allEvents) { if (_selectedUserId == null) { return allEvents; // Pas de filtre, retourner tous les événements } @@ -208,6 +209,524 @@ class _CalendarPageState extends State { }).toList(); } + bool _isSameDay(DateTime left, DateTime right) { + return left.year == right.year && + left.month == right.month && + left.day == right.day; + } + + List _getEventsForDay( + List events, + DateTime? day, { + EventModel? selectedEvent, + }) { + if (day == null) { + return []; + } + + final dayEvents = events + .where((event) => _isSameDay(event.startDateTime, day)) + .toList() + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + + if (selectedEvent != null && + _isSameDay(selectedEvent.startDateTime, day) && + !dayEvents.any((event) => event.id == selectedEvent.id)) { + dayEvents.add(selectedEvent); + dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + } + + return dayEvents; + } + + List _getDetailsEvents(List events) { + final mergedEvents = [...events]; + + if (_selectedEvent != null && + !mergedEvents.any((event) => event.id == _selectedEvent!.id)) { + mergedEvents.add(_selectedEvent!); + } + + mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + return mergedEvents; + } + + String _formatSearchResultDate(DateTime dateTime) { + return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime); + } + + Color _getStatusColor(EventStatus status) { + switch (status) { + case EventStatus.confirmed: + return Colors.green; + case EventStatus.canceled: + return Colors.red; + case EventStatus.waitingForApproval: + default: + return Colors.amber; + } + } + + /// Combine uniquement le filtre utilisateur avec la vue calendrier. + List _getFilteredEvents(List allEvents) { + return _filterEventsByUser(allEvents); + } + + void _cancelPendingSearch() { + _searchDebounce?.cancel(); + _searchDebounce = null; + } + + void _scheduleSearch(String value) { + _cancelPendingSearch(); + + _searchDebounce = Timer(const Duration(milliseconds: 300), () { + _runSearch(value); + }); + } + + void _onSearchChanged(String value) { + final isMobile = MediaQuery.of(context).size.width < 600; + + if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) { + setState(() { + _isMobileSearchVisible = true; + }); + } + + setState(() { + _searchQuery = value; + }); + + if (value.trim().isEmpty) { + _cancelPendingSearch(); + setState(() { + _searchResults = []; + _searchError = null; + _isSearching = false; + }); + return; + } + + _scheduleSearch(value); + } + + void _clearSearch() { + _cancelPendingSearch(); + + if (_searchController.text.isEmpty) { + return; + } + + _searchController.clear(); + setState(() { + _searchQuery = ''; + _searchResults = []; + _searchError = null; + _isSearching = false; + }); + } + + Future _runSearch(String value) async { + final query = value.trim(); + if (query.isEmpty) { + return; + } + + final localUserProvider = context.read(); + final userId = localUserProvider.uid; + if (userId == null) { + return; + } + + final searchId = ++_searchRequestId; + setState(() { + _isSearching = true; + _searchError = null; + _searchResults = []; + }); + + try { + final eventProvider = context.read(); + final results = await eventProvider.searchEvents( + userId: userId, + query: query, + ); + + if (!mounted) { + return; + } + + if (_searchQuery.trim() != query) { + return; + } + + if (searchId != _searchRequestId) { + return; + } + + setState(() { + _searchResults = results; + _searchError = null; + _isSearching = false; + }); + } catch (e) { + if (!mounted || _searchQuery.trim() != query) { + return; + } + + setState(() { + _searchResults = []; + _searchError = 'Erreur lors de la recherche : $e'; + _isSearching = false; + }); + } + } + + Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + color: Colors.grey[100], + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'Rechercher (titre, description, lieu)', + prefixIcon: const Icon(Icons.search, color: AppColors.rouge), + suffixIcon: _searchQuery.isNotEmpty + ? IconButton( + tooltip: 'Effacer la recherche', + icon: const Icon(Icons.close), + onPressed: _clearSearch, + ) + : null, + isDense: true, + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + ), + ), + if (canViewAllUserEvents) ...[ + const SizedBox(width: 12), + _buildCompactUserFilter(), + ], + ], + ), + ); + } + + Widget _buildCompactUserFilter() { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: Row( + children: [ + Expanded( + child: UserFilterDropdown( + selectedUserId: _selectedUserId, + onUserSelected: (userId) { + setState(() { + _selectedUserId = userId; + }); + }, + ), + ), + ], + ), + ); + } + + Widget _buildMobileSearchBar() { + return Container( + color: Colors.grey[100], + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Column( + children: [ + Row( + children: [ + IconButton( + icon: Icon( + _isMobileSearchVisible ? Icons.search_off : Icons.search, + color: AppColors.rouge, + ), + tooltip: _isMobileSearchVisible + ? 'Masquer la recherche' + : 'Afficher la recherche', + onPressed: () { + setState(() { + _isMobileSearchVisible = !_isMobileSearchVisible; + }); + }, + ), + Expanded( + child: Text( + _searchQuery.isEmpty + ? 'Rechercher un événement' + : 'Recherche active', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + if (_searchQuery.isNotEmpty) + IconButton( + icon: const Icon(Icons.close), + tooltip: 'Effacer la recherche', + onPressed: _clearSearch, + ), + ], + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _isMobileSearchVisible + ? Padding( + key: const ValueKey('mobile-search-visible'), + padding: const EdgeInsets.only(top: 4, left: 8, right: 8), + child: TextField( + controller: _searchController, + onChanged: _onSearchChanged, + decoration: InputDecoration( + hintText: 'Titre, description ou lieu', + prefixIcon: + const Icon(Icons.search, color: AppColors.rouge), + isDense: true, + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + ), + ) + : const SizedBox.shrink( + key: ValueKey('mobile-search-hidden'), + ), + ), + ], + ), + ); + } + + Widget _buildSearchResultsPanel({required bool isMobile}) { + final hasQuery = _searchQuery.trim().isNotEmpty; + + if (!hasQuery && !_isSearching && _searchError == null) { + return const SizedBox.shrink(); + } + + final panelPadding = EdgeInsets.symmetric( + horizontal: isMobile ? 8 : 16, + vertical: 8, + ); + + return Container( + width: double.infinity, + padding: panelPadding, + color: Colors.grey[50], + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.manage_search, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + Expanded( + child: Text( + hasQuery + ? 'Résultats pour "$_searchQuery"' + : 'Recherche d’événements', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + ), + if (_isSearching) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ], + ), + if (_searchError != null) ...[ + const SizedBox(height: 8), + Text( + _searchError!, + style: const TextStyle(color: Colors.red), + ), + ] else if (!hasQuery) ...[ + const SizedBox(height: 8), + Text( + 'Saisissez un titre, une description ou un lieu pour lancer la recherche.', + style: TextStyle(color: Colors.grey.shade700), + ), + ] else if (!_isSearching) ...[ + const SizedBox(height: 8), + if (_searchResults.isEmpty) + Text( + 'Aucun résultat trouvé.', + style: TextStyle(color: Colors.grey.shade700), + ) + else + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: isMobile ? 240 : 280, + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: _searchResults.length, + physics: const ClampingScrollPhysics(), + separatorBuilder: (context, index) => + const SizedBox(height: 8), + itemBuilder: (context, index) { + final event = _searchResults[index]; + final isSelected = _selectedEvent?.id == event.id; + + return Material( + color: isSelected + ? AppColors.rouge.withOpacity(0.08) + : Colors.white, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _onSearchResultSelected(event), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: _getStatusColor(event.status), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + _formatSearchResultDate( + event.startDateTime), + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 12, + ), + ), + if (event.address.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + event.address, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + const Icon(Icons.chevron_right, + color: Colors.grey), + ], + ), + ), + ), + ); + }, + ), + ), + ], + ], + ), + ); + } + + Future _onSearchResultSelected(EventModel event) async { + final localUserProvider = context.read(); + final eventProvider = context.read(); + final userId = localUserProvider.uid; + + if (userId == null) { + return; + } + + final canViewAllEvents = localUserProvider.hasPermission('view_all_events'); + final selectedDay = DateTime( + event.startDateTime.year, + event.startDateTime.month, + event.startDateTime.day, + ); + final shouldLoadMonth = _focusedDay.year != event.startDateTime.year || + _focusedDay.month != event.startDateTime.month || + eventProvider.events.isEmpty; + + if (shouldLoadMonth) { + await eventProvider.loadMonthEvents( + userId, + event.startDateTime.year, + event.startDateTime.month, + canViewAllEvents: canViewAllEvents, + ); + + eventProvider.preloadAdjacentMonths( + userId, + event.startDateTime.year, + event.startDateTime.month, + canViewAllEvents: canViewAllEvents, + ); + } + + if (!mounted) { + return; + } + + final eventsForSelectedDay = _getEventsForDay( + eventProvider.events, + selectedDay, + selectedEvent: event, + ); + final isMobile = MediaQuery.of(context).size.width < 600; + + setState(() { + _focusedDay = selectedDay; + _selectedDay = selectedDay; + _selectedEvent = event; + _selectedEventIndex = + eventsForSelectedDay.indexWhere((e) => e.id == event.id); + if (_selectedEventIndex < 0) { + _selectedEventIndex = 0; + } + _calendarCollapsed = false; + if (isMobile) { + _isMobileSearchVisible = true; + } + }); + } + void _changeWeek(int delta) { setState(() { _focusedDay = _focusedDay.add(Duration(days: 7 * delta)); @@ -238,10 +757,12 @@ class _CalendarPageState extends State { Widget _buildDesktopDetailsPane(List filteredEvents) { if (_selectedEvent != null) { + final detailsEvents = _getDetailsEvents(filteredEvents); + return EventDetails( event: _selectedEvent!, selectedDate: _selectedDay, - events: filteredEvents, + events: detailsEvents, onSelectEvent: (event, date) { setState(() { _selectedEvent = event; @@ -296,6 +817,8 @@ class _CalendarPageState extends State { final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events'); final isMobile = MediaQuery.of(context).size.width < 600; + final showSearchResults = + _searchQuery.trim().isNotEmpty || _isSearching || _searchError != null; // Appliquer le filtre utilisateur si actif final filteredEvents = _getFilteredEvents(eventProvider.events); @@ -343,33 +866,11 @@ class _CalendarPageState extends State { drawer: const MainDrawer(currentPage: '/calendar'), body: Column( children: [ - // Filtre utilisateur dans le corps de la page - if (canViewAllUserEvents && !isMobile) - Container( - padding: const EdgeInsets.all(16), - color: Colors.grey[100], - child: Row( - children: [ - const Icon(Icons.filter_list, color: AppColors.rouge), - const SizedBox(width: 12), - const Text( - 'Filtrer par utilisateur :', - style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14), - ), - const SizedBox(width: 16), - Expanded( - child: UserFilterDropdown( - selectedUserId: _selectedUserId, - onUserSelected: (userId) { - setState(() { - _selectedUserId = userId; - }); - }, - ), - ), - ], - ), - ), + if (isMobile) + _buildMobileSearchBar() + else + _buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents), + if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile), // Corps du calendrier Expanded( child: isMobile @@ -426,18 +927,19 @@ class _CalendarPageState extends State { } Widget _buildMobileLayout(List filteredEvents) { - final eventsForSelectedDay = _selectedDay == null - ? [] - : filteredEvents - .where((e) => - e.startDateTime.year == _selectedDay!.year && - e.startDateTime.month == _selectedDay!.month && - e.startDateTime.day == _selectedDay!.day) - .toList() - ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + final eventsForSelectedDay = _getEventsForDay( + filteredEvents, + _selectedDay, + selectedEvent: _selectedEvent, + ); final hasEvents = eventsForSelectedDay.isNotEmpty; - final currentEvent = - hasEvents && _selectedEventIndex < eventsForSelectedDay.length + final selectedEventIndex = _selectedEvent == null + ? -1 + : eventsForSelectedDay + .indexWhere((event) => event.id == _selectedEvent!.id); + final currentEvent = hasEvents && selectedEventIndex >= 0 + ? eventsForSelectedDay[selectedEventIndex] + : hasEvents && _selectedEventIndex < eventsForSelectedDay.length ? eventsForSelectedDay[_selectedEventIndex] : null; @@ -581,7 +1083,7 @@ class _CalendarPageState extends State { child: EventDetails( event: eventsForSelectedDay[_selectedEventIndex], selectedDate: _selectedDay, - events: eventsForSelectedDay.cast(), + events: eventsForSelectedDay, onSelectEvent: (event, date) { final idx = eventsForSelectedDay .indexWhere((e) => e.id == event.id); @@ -600,7 +1102,7 @@ class _CalendarPageState extends State { ), ), ), - // Vue détail (prend tout l'espace quand calendrier caché) + // Vue détail (prend tout l'espace quand calendrier cache) if (_calendarCollapsed && _selectedDay != null) AnimatedPositioned( duration: const Duration(milliseconds: 400), @@ -647,7 +1149,7 @@ class _CalendarPageState extends State { child: EventDetails( event: currentEvent, selectedDate: _selectedDay, - events: eventsForSelectedDay.cast(), + events: eventsForSelectedDay, onSelectEvent: (event, date) { final idx = eventsForSelectedDay .indexWhere((e) => e.id == event.id); diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart index 4bf5931..218e0ba 100644 --- a/em2rp/lib/views/equipment_detail_page.dart +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/services/equipment_service.dart'; import 'package:em2rp/services/qr_code_service.dart'; import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/equipment_delete_utils.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/equipment_form_page.dart'; import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart'; @@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State { Future _loadMaintenances() async { try { - final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id); + final maintenances = await _equipmentService + .getMaintenancesForEquipment(widget.equipment.id); setState(() { _maintenances = maintenances; _isLoadingMaintenances = false; @@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State { } } - - @override Widget build(BuildContext context) { final screenWidth = MediaQuery.of(context).size.width; @@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State { const SizedBox(height: 24), // 3. Notes - if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[ + if (widget.equipment.notes != null && + widget.equipment.notes!.isNotEmpty) ...[ EquipmentNotesSection(notes: widget.equipment.notes!), const SizedBox(height: 24), ], @@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State { ); } - void _showQRCode() { showDialog( context: context, @@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State { ), const SizedBox(height: 4), Text( - '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(), + '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}' + .trim(), style: TextStyle(color: Colors.grey[700]), ), - if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[ + if (widget.equipment.subCategory != null && + widget.equipment.subCategory!.isNotEmpty) ...[ const SizedBox(height: 4), Text( '📁 ${widget.equipment.subCategory}', @@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State { if (!hasPermission) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( - content: Text('Vous n\'avez pas la permission de gérer les maintenances'), + content: + Text('Vous n\'avez pas la permission de gérer les maintenances'), backgroundColor: Colors.orange, ), ); @@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State { } void _deleteEquipment() { + final pageContext = context; + final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel( + id: widget.equipment.id, + name: widget.equipment.name, + ); showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirmer la suppression'), + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: const Text(EquipmentDeleteUtils.deleteDialogTitle), content: Text( - 'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.', + EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage( + equipmentLabel, + ), ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), + onPressed: () => Navigator.pop(dialogContext), + child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel), ), TextButton( onPressed: () async { // Fermer le dialog - Navigator.pop(context); + Navigator.pop(dialogContext); // Capturer le ScaffoldMessenger avant la suppression - final scaffoldMessenger = ScaffoldMessenger.of(context); - final navigator = Navigator.of(context); + final scaffoldMessenger = ScaffoldMessenger.of(pageContext); + final navigator = Navigator.of(pageContext); + final provider = pageContext.read(); try { - await context - .read() - .deleteEquipment(widget.equipment.id); + final deleted = + await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck( + context: pageContext, + equipmentLabel: equipmentLabel, + deleteEquipment: ({bool forceDelete = false}) { + return provider.deleteEquipment( + widget.equipment.id, + forceDelete: forceDelete, + ); + }, + ); + if (!deleted) { + return; + } // Revenir à la page précédente navigator.pop(); @@ -455,22 +477,26 @@ class _EquipmentDetailPageState extends State { // Afficher le snackbar (même si le widget est démonté) scaffoldMessenger.showSnackBar( const SnackBar( - content: Text('Équipement supprimé avec succès'), + content: Text(EquipmentDeleteUtils.deleteSuccessMessage), backgroundColor: Colors.green, ), ); } catch (e) { // Afficher l'erreur scaffoldMessenger.showSnackBar( - SnackBar(content: Text('Erreur: $e')), + SnackBar( + content: Text( + EquipmentDeleteUtils.buildDeleteErrorMessage(e), + ), + ), ); } }, style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Supprimer'), + child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel), ), ], ), ); } -} \ No newline at end of file +} diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index d9e56fd..a1cb416 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -16,6 +16,7 @@ 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/equipment/equipment_status_badge.dart'; import 'package:em2rp/utils/debug_log.dart'; +import 'package:em2rp/utils/equipment_delete_utils.dart'; import 'package:em2rp/mixins/selection_mode_mixin.dart'; import 'package:em2rp/views/widgets/common/search_actions_bar.dart'; import 'package:em2rp/views/widgets/notification_badge.dart'; @@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget { _EquipmentManagementPageState(); } - class _EquipmentManagementPageState extends State with SelectionModeMixin { final TextEditingController _searchController = TextEditingController(); @@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State if (_scrollController.hasClients && _scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) { - // Vérifier qu'on peut charger plus if (provider.hasMore && !provider.isLoadingMore) { // ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll @@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State _isLoadingMore = false; }).catchError((error) { _isLoadingMore = false; - DebugLog.error('[EquipmentManagementPage] Error loading next page', error); + DebugLog.error( + '[EquipmentManagementPage] Error loading next page', error); }); } } @@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State Widget _buildEquipmentList() { return Consumer( 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}'); // Afficher l'indicateur de chargement initial uniquement if (provider.isLoading && provider.equipment.isEmpty) { - DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator'); + DebugLog.info( + '[EquipmentManagementPage] Showing initial loading indicator'); return const Center(child: CircularProgressIndicator()); } @@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State ); } - 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); @@ -526,124 +529,127 @@ class _EquipmentManagementPageState extends State // ✅ RepaintBoundary pour isoler le repaint de chaque carte return RepaintBoundary( - key: ValueKey(equipment.id), - child: Card( - margin: const EdgeInsets.only(bottom: 12), - color: isSelectionMode && isSelected - ? AppColors.rouge.withValues(alpha: 0.1) - : null, - child: ListTile( - leading: isSelectionMode - ? Checkbox( - value: isSelected, - onChanged: (value) => toggleItemSelection(equipment.id), - activeColor: AppColors.rouge, - ) - : CircleAvatar( - backgroundColor: equipment.category.color.withValues(alpha: 0.2), - child: equipment.category.getIcon( - size: 20, - color: equipment.category.color, - ), - ), - title: Row( - children: [ - Expanded( - child: Text( - equipment.id, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - ), - // Afficher le badge de statut calculé dynamiquement - if (equipment.category != EquipmentCategory.consumable && - equipment.category != EquipmentCategory.cable) - EquipmentStatusBadge(equipment: equipment), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}' - .trim() - .isNotEmpty - ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim() - : 'Marque/Modèle non défini', - style: TextStyle(color: Colors.grey[600], fontSize: 14), - ), - // Afficher la sous-catégorie si elle existe - if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - '📁 ${equipment.subCategory}', - style: TextStyle( - color: Colors.grey[500], - fontSize: 12, - fontStyle: FontStyle.italic, + key: ValueKey(equipment.id), + child: Card( + margin: const EdgeInsets.only(bottom: 12), + color: isSelectionMode && isSelected + ? AppColors.rouge.withValues(alpha: 0.1) + : null, + child: ListTile( + leading: isSelectionMode + ? Checkbox( + value: isSelected, + onChanged: (value) => toggleItemSelection(equipment.id), + activeColor: AppColors.rouge, + ) + : CircleAvatar( + backgroundColor: + equipment.category.color.withValues(alpha: 0.2), + child: equipment.category.getIcon( + size: 20, + color: equipment.category.color, + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), ), ), + // Afficher le badge de statut calculé dynamiquement + if (equipment.category != EquipmentCategory.consumable && + equipment.category != EquipmentCategory.cable) + EquipmentStatusBadge(equipment: equipment), ], - // Afficher la quantité disponible pour les consommables/câbles - if (equipment.category == EquipmentCategory.consumable || - equipment.category == EquipmentCategory.cable) ...[ + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ const SizedBox(height: 4), - _buildQuantityDisplay(equipment), - ], - ], - ), - trailing: isSelectionMode - ? null - : Row( - mainAxisSize: MainAxisSize.min, - children: [ - // Bouton Restock (uniquement pour consommables/câbles avec permission) - if (equipment.category == EquipmentCategory.consumable || - equipment.category == EquipmentCategory.cable) - PermissionGate( - requiredPermissions: const ['manage_equipment'], - child: IconButton( - icon: const Icon(Icons.add_shopping_cart, - color: AppColors.rouge), - tooltip: 'Restock', - onPressed: () => _showRestockDialog(equipment), - ), - ), - // Bouton QR Code - IconButton( - icon: const Icon(Icons.qr_code, color: AppColors.rouge), - tooltip: 'QR Code', - onPressed: () => showDialog( - context: context, - builder: (context) => QRCodeDialog.forEquipment(equipment), - ), - ), - // Bouton Modifier (permission required) - PermissionGate( - requiredPermissions: const ['manage_equipment'], - child: IconButton( - icon: const Icon(Icons.edit, color: AppColors.rouge), - tooltip: 'Modifier', - onPressed: () => _editEquipment(equipment), - ), - ), - // Bouton Supprimer (permission required) - PermissionGate( - requiredPermissions: const ['manage_equipment'], - child: IconButton( - icon: const Icon(Icons.delete, color: Colors.red), - tooltip: 'Supprimer', - onPressed: () => _deleteEquipment(equipment), + Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}' + .trim() + .isNotEmpty + ? '${equipment.brand ?? ''} ${equipment.model ?? ''}' + .trim() + : 'Marque/Modèle non défini', + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + // Afficher la sous-catégorie si elle existe + if (equipment.subCategory != null && + equipment.subCategory!.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + '📁 ${equipment.subCategory}', + style: TextStyle( + color: Colors.grey[500], + fontSize: 12, + fontStyle: FontStyle.italic, ), ), ], - ), - onTap: isSelectionMode - ? () => toggleItemSelection(equipment.id) - : () => _viewEquipmentDetails(equipment), - ), - ) - ); + // Afficher la quantité disponible pour les consommables/câbles + if (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable) ...[ + const SizedBox(height: 4), + _buildQuantityDisplay(equipment), + ], + ], + ), + trailing: isSelectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton Restock (uniquement pour consommables/câbles avec permission) + if (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.add_shopping_cart, + color: AppColors.rouge), + tooltip: 'Restock', + onPressed: () => _showRestockDialog(equipment), + ), + ), + // Bouton QR Code + IconButton( + icon: const Icon(Icons.qr_code, color: AppColors.rouge), + tooltip: 'QR Code', + onPressed: () => showDialog( + context: context, + builder: (context) => + QRCodeDialog.forEquipment(equipment), + ), + ), + // Bouton Modifier (permission required) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.edit, color: AppColors.rouge), + tooltip: 'Modifier', + onPressed: () => _editEquipment(equipment), + ), + ), + // Bouton Supprimer (permission required) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + tooltip: 'Supprimer', + onPressed: () => _deleteEquipment(equipment), + ), + ), + ], + ), + onTap: isSelectionMode + ? () => toggleItemSelection(equipment.id) + : () => _viewEquipmentDetails(equipment), + ), + )); } Widget _buildQuantityDisplay(EquipmentModel equipment) { @@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State ); } - // Actions void _createNewEquipment() { Navigator.push( @@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State } void _deleteEquipment(EquipmentModel equipment) { + final pageContext = context; + final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel( + id: equipment.id, + name: equipment.name, + ); showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirmer la suppression'), - content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'), + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: const Text(EquipmentDeleteUtils.deleteDialogTitle), + content: Text( + EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage( + equipmentLabel, + ), + ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), + onPressed: () => Navigator.pop(dialogContext), + child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel), ), TextButton( onPressed: () async { - Navigator.pop(context); + Navigator.pop(dialogContext); + final scaffoldMessenger = ScaffoldMessenger.of(pageContext); + final provider = pageContext.read(); + try { - await context - .read() - .deleteEquipment(equipment.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Équipement supprimé avec succès')), - ); + final deleted = + await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck( + context: pageContext, + equipmentLabel: equipmentLabel, + deleteEquipment: ({bool forceDelete = false}) { + return provider.deleteEquipment( + equipment.id, + forceDelete: forceDelete, + ); + }, + ); + if (!deleted) { + return; } + scaffoldMessenger.showSnackBar( + const SnackBar( + content: Text(EquipmentDeleteUtils.deleteSuccessMessage), + backgroundColor: Colors.green, + ), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e')), - ); - } + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + EquipmentDeleteUtils.buildDeleteErrorMessage(e), + ), + ), + ); } }, style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Supprimer'), + child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel), ), ], ), @@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State void _deleteSelectedEquipment() async { if (!hasSelection) return; + final pageContext = context; showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text('Confirmer la suppression'), + context: pageContext, + builder: (dialogContext) => AlertDialog( + title: const Text(EquipmentDeleteUtils.deleteDialogTitle), content: Text( - 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?', + EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage( + selectedCount, + ), ), actions: [ TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), + onPressed: () => Navigator.pop(dialogContext), + child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel), ), TextButton( onPressed: () async { - Navigator.pop(context); + Navigator.pop(dialogContext); + final scaffoldMessenger = ScaffoldMessenger.of(pageContext); + final provider = pageContext.read(); + try { - final provider = context.read(); + final equipmentById = { + for (final equipment + in provider.equipment) + equipment.id: equipment, + }; + + var deletedCount = 0; for (final id in selectedIds) { - await provider.deleteEquipment(id); + final label = EquipmentDeleteUtils.resolveEquipmentLabel( + id: id, + name: equipmentById[id]?.name, + ); + final deleted = await EquipmentDeleteUtils + .deleteWithFutureAssignmentCheck( + context: pageContext, + equipmentLabel: label, + deleteEquipment: ({bool forceDelete = false}) { + return provider.deleteEquipment( + id, + forceDelete: forceDelete, + ); + }, + ); + if (deleted) { + deletedCount++; + } } disableSelectionMode(); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - '$selectedCount équipement(s) supprimé(s) avec succès'), - backgroundColor: Colors.green, + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + EquipmentDeleteUtils.buildBulkDeleteSuccessMessage( + deletedCount, + ), ), - ); - } + backgroundColor: Colors.green, + ), + ); } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur: $e')), - ); - } + scaffoldMessenger.showSnackBar( + SnackBar( + content: Text( + EquipmentDeleteUtils.buildDeleteErrorMessage(e), + ), + ), + ); } }, style: TextButton.styleFrom(foregroundColor: Colors.red), - child: const Text('Supprimer'), + child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel), ), ], ), @@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State if (mounted) { showDialog( context: context, - builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), + builder: (context) => + QRCodeDialog.forEquipment(selectedEquipment.first), ); } } else { @@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State updatedAt: DateTime.now(), ); - await context.read().updateEquipment(updatedEquipment); + await context + .read() + .updateEquipment(updatedEquipment); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'), + content: Text( + 'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'), backgroundColor: Colors.orange, ), ); diff --git a/em2rp/web/version.json b/em2rp/web/version.json index 2366431..0d27b3a 100644 --- a/em2rp/web/version.json +++ b/em2rp/web/version.json @@ -1,7 +1,7 @@ { - "version": "1.1.20", + "version": "1.1.21", "updateUrl": "https://app.em2events.fr", "forceUpdate": true, - "releaseNotes": "Patch bug envoi d'alerte equipement perdu, date dans les alertes, recherche par ID d'équipement.", - "timestamp": "2026-03-30T15:04:34.073Z" + "releaseNotes": "Ajout de la recherche d'événements et gestion avancée de la suppression d'équipement", + "timestamp": "2026-04-22T10:22:26.036Z" } \ No newline at end of file