diff --git a/em2rp/firebase.json b/em2rp/firebase.json index b9a6d41..ac42fb7 100644 --- a/em2rp/firebase.json +++ b/em2rp/firebase.json @@ -49,6 +49,10 @@ } ] }, + "firestore": { + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, "emulators": { "functions": { "port": 5051 diff --git a/em2rp/firestore.indexes.json b/em2rp/firestore.indexes.json new file mode 100644 index 0000000..6dc6673 --- /dev/null +++ b/em2rp/firestore.indexes.json @@ -0,0 +1,46 @@ +{ + "indexes": [ + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "EndDateTime", + "order": "ASCENDING" + }, + { + "fieldPath": "StartDateTime", + "order": "ASCENDING" + }, + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "__name__", + "order": "ASCENDING" + } + ] + }, + { + "collectionGroup": "events", + "queryScope": "COLLECTION", + "fields": [ + { + "fieldPath": "status", + "order": "ASCENDING" + }, + { + "fieldPath": "StartDateTime", + "order": "ASCENDING" + }, + { + "fieldPath": "EndDateTime", + "order": "ASCENDING" + } + ] + } + ], + "fieldOverrides": [] +} + diff --git a/em2rp/firestore.rules b/em2rp/firestore.rules new file mode 100644 index 0000000..8672580 --- /dev/null +++ b/em2rp/firestore.rules @@ -0,0 +1,172 @@ +rules_version = '2'; + +// ============================================================================ +// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION +// ============================================================================ +// Date de création : 14 janvier 2026 +// Objectif : Bloquer tous les accès directs à Firestore depuis les clients +// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données +// ============================================================================ + +service cloud.firestore { + match /databases/{database}/documents { + + // ======================================================================== + // RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER + // ======================================================================== + // Cette règle empêche tout accès direct depuis les clients (web/mobile) + // Les Cloud Functions ont un accès admin et ne sont pas affectées + + match /{document=**} { + // ❌ REFUSER TOUS LES ACCÈS directs depuis les clients + allow read, write: if false; + } + + // ======================================================================== + // EXCEPTIONS OPTIONNELLES pour les listeners temps réel + // ======================================================================== + // Si vous avez besoin de listeners en temps réel pour certaines collections, + // décommentez les règles ci-dessous. + // + // ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE. + // Toutes les ÉCRITURES doivent passer par les Cloud Functions. + // ======================================================================== + + /* + // Événements : Lecture seule pour utilisateurs authentifiés + match /events/{eventId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Équipements : Lecture seule pour utilisateurs authentifiés + match /equipments/{equipmentId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Conteneurs : Lecture seule pour utilisateurs authentifiés + match /containers/{containerId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Maintenances : Lecture seule pour utilisateurs authentifiés + match /maintenances/{maintenanceId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Alertes : Lecture seule pour utilisateurs authentifiés + match /alerts/{alertId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Utilisateurs : Lecture de son propre profil uniquement + match /users/{userId} { + allow read: if request.auth != null && request.auth.uid == userId; + allow write: if false; // ❌ Écriture interdite + } + + // Types d'événements : Lecture seule + match /eventTypes/{typeId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Options : Lecture seule + match /options/{optionId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + + // Clients : Lecture seule + match /customers/{customerId} { + allow read: if request.auth != null; + allow write: if false; // ❌ Écriture interdite + } + */ + + // ======================================================================== + // RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL) + // ======================================================================== + // Décommentez ces règles si vous voulez des permissions basées sur les rôles + // pour la lecture en temps réel + // + // ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans + // la collection users, ce qui peut impacter les performances et les coûts. + // ======================================================================== + + /* + // Fonction helper : Récupérer les permissions de l'utilisateur + function getUserPermissions() { + return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions; + } + + // Fonction helper : Vérifier si l'utilisateur a une permission + function hasPermission(permission) { + return request.auth != null && permission in getUserPermissions(); + } + + // Équipements : Lecture uniquement si permission view_equipment + match /equipments/{equipmentId} { + allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment'); + allow write: if false; // ❌ Écriture interdite + } + + // Événements : Lecture selon permissions + match /events/{eventId} { + allow read: if hasPermission('view_events') || hasPermission('edit_event'); + allow write: if false; // ❌ Écriture interdite + } + + // Conteneurs : Lecture uniquement si permission view_equipment + match /containers/{containerId} { + allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment'); + allow write: if false; // ❌ Écriture interdite + } + + // Maintenances : Lecture uniquement si permission view_equipment + match /maintenances/{maintenanceId} { + allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment'); + allow write: if false; // ❌ Écriture interdite + } + */ + + } +} + +// ============================================================================ +// NOTES DE SÉCURITÉ +// ============================================================================ +// +// 1. RÈGLE PAR DÉFAUT (allow read, write: if false) +// - Bloque TOUS les accès directs depuis les clients +// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin) +// - C'est la configuration la PLUS SÉCURISÉE +// +// 2. EXCEPTIONS DE LECTURE (commentées par défaut) +// - Permettent les listeners en temps réel pour certaines collections +// - UNIQUEMENT la LECTURE est autorisée +// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions) +// +// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut) +// - Permettent un contrôle plus fin basé sur les permissions utilisateur +// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users) +// - À utiliser uniquement si nécessaire +// +// 4. TESTS APRÈS DÉPLOIEMENT +// - Vérifier que les Cloud Functions fonctionnent toujours +// - Tester qu'un accès direct depuis la console échoue +// - Surveiller les logs : firebase functions:log +// +// 5. ROLLBACK EN CAS DE PROBLÈME +// - Remplacer temporairement par : +// match /{document=**} { +// allow read, write: if request.auth != null; +// } +// - Déployer rapidement : firebase deploy --only firestore:rules +// +// ============================================================================ + diff --git a/em2rp/functions/index.js b/em2rp/functions/index.js index 2a3d3e7..8994aaa 100644 --- a/em2rp/functions/index.js +++ b/em2rp/functions/index.js @@ -153,6 +153,11 @@ exports.updateEquipment = onRequest(httpOptions, withCors(async (req, res) => { return; } + if (!data || typeof data !== 'object' || Object.keys(data).length === 0) { + res.status(400).json({ error: 'Update data is required and must be a non-empty object' }); + return; + } + // Empêcher la modification de l'ID delete data.id; @@ -703,10 +708,14 @@ exports.createEvent = onRequest(httpOptions, withCors(async (req, res) => { const eventData = req.body.data; - const dataToSave = helpers.deserializeTimestamps(eventData, [ + // Désérialiser les timestamps + let dataToSave = helpers.deserializeTimestamps(eventData, [ 'StartDateTime', 'EndDateTime', 'createdAt', 'updatedAt' ]); + // Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format + dataToSave = helpers.convertIdsToReferences(dataToSave); + const docRef = await db.collection('events').add(dataToSave); res.status(201).json({ id: docRef.id, message: 'Event created successfully' }); @@ -750,10 +759,14 @@ exports.updateEvent = onRequest(httpOptions, withCors(async (req, res) => { delete data.id; data.updatedAt = admin.firestore.Timestamp.now(); - const dataToSave = helpers.deserializeTimestamps(data, [ + // Désérialiser les timestamps + let dataToSave = helpers.deserializeTimestamps(data, [ 'StartDateTime', 'EndDateTime' ]); + // Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format + dataToSave = helpers.convertIdsToReferences(dataToSave); + await db.collection('events').doc(eventId).update(dataToSave); res.status(200).json({ message: 'Event updated successfully' }); diff --git a/em2rp/functions/utils/helpers.js b/em2rp/functions/utils/helpers.js index 52591a0..3558433 100644 --- a/em2rp/functions/utils/helpers.js +++ b/em2rp/functions/utils/helpers.js @@ -146,6 +146,39 @@ function filterCancelledEvents(events) { return events.filter(event => event.status !== 'CANCELLED'); } +/** + * Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format + * @param {Object} data - Données de l'événement + * @returns {Object} - Données avec DocumentReference + */ +function convertIdsToReferences(data) { + if (!data) return data; + + const result = { ...data }; + + // Convertir EventType (ID → DocumentReference) + if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) { + result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType); + } + + // Convertir customer (ID → DocumentReference) + if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) { + result.customer = admin.firestore().collection('customers').doc(result.customer); + } + + // Convertir workforce (IDs → DocumentReference) + if (Array.isArray(result.workforce)) { + result.workforce = result.workforce.map(item => { + if (typeof item === 'string' && !item.includes('/')) { + return admin.firestore().collection('users').doc(item); + } + return item; + }); + } + + return result; +} + module.exports = { serializeTimestamps, deserializeTimestamps, @@ -153,5 +186,6 @@ module.exports = { maskSensitiveFields, paginate, filterCancelledEvents, + convertIdsToReferences, }; diff --git a/em2rp/lib/config/app_version.dart b/em2rp/lib/config/app_version.dart index abae7d5..2bc8a84 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 = '0.3.7'; + static const String version = '0.3.8'; /// Retourne la version complète de l'application static String get fullVersion => 'v$version'; diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 0413543..3819404 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -192,15 +192,15 @@ class EventFormController extends ChangeNotifier { if (newTypeId != null) { final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId); - // Utiliser le prix par défaut du type d'événement - final defaultPrice = selectedType.defaultPrice; + // Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice) + final defaultPriceTTC = selectedType.defaultPrice; final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.')); final oldDefaultPrice = oldEventType?.defaultPrice; - // Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut + // Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut if (basePriceController.text.isEmpty || (currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) { - basePriceController.text = defaultPrice.toStringAsFixed(2); + basePriceController.text = defaultPriceTTC.toStringAsFixed(2); } // Filtrer les options qui ne sont plus compatibles avec le nouveau type @@ -334,9 +334,8 @@ class EventFormController extends ChangeNotifier { eventTypeRef: eventTypeRef, customerId: existingEvent.customerId, address: addressController.text.trim(), - workforce: _selectedUserIds - .map((id) => FirebaseFirestore.instance.doc('users/$id')) - .toList(), + // Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions + workforce: _selectedUserIds, latitude: existingEvent.latitude, longitude: existingEvent.longitude, documents: finalDocuments, @@ -379,9 +378,8 @@ class EventFormController extends ChangeNotifier { eventTypeRef: eventTypeRef, customerId: '', address: addressController.text.trim(), - workforce: _selectedUserIds - .map((id) => FirebaseFirestore.instance.doc('users/$id')) - .toList(), + // Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions + workforce: _selectedUserIds, latitude: 0.0, longitude: 0.0, documents: _uploadedFiles, diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart index 625580e..56df85b 100644 --- a/em2rp/lib/models/equipment_model.dart +++ b/em2rp/lib/models/equipment_model.dart @@ -55,6 +55,7 @@ enum EquipmentCategory { structure, // Structure consumable, // Consommable cable, // Câble + vehicle, // Véhicule other // Autre } @@ -72,6 +73,8 @@ String equipmentCategoryToString(EquipmentCategory category) { return 'CONSUMABLE'; case EquipmentCategory.cable: return 'CABLE'; + case EquipmentCategory.vehicle: + return 'VEHICLE'; case EquipmentCategory.other: return 'OTHER'; case EquipmentCategory.effect: @@ -93,6 +96,8 @@ EquipmentCategory equipmentCategoryFromString(String? category) { return EquipmentCategory.consumable; case 'CABLE': return EquipmentCategory.cable; + case 'VEHICLE': + return EquipmentCategory.vehicle; case 'EFFECT': return EquipmentCategory.effect; case 'OTHER': @@ -120,6 +125,8 @@ extension EquipmentCategoryExtension on EquipmentCategory { return 'Consommable'; case EquipmentCategory.cable: return 'Câble'; + case EquipmentCategory.vehicle: + return 'Véhicule'; case EquipmentCategory.other: return 'Autre'; } @@ -142,6 +149,8 @@ extension EquipmentCategoryExtension on EquipmentCategory { return Icons.inventory_2; case EquipmentCategory.cable: return Icons.cable; + case EquipmentCategory.vehicle: + return Icons.local_shipping; case EquipmentCategory.other: return Icons.more_horiz; } @@ -164,6 +173,8 @@ extension EquipmentCategoryExtension on EquipmentCategory { return Colors.orange; case EquipmentCategory.cable: return Colors.grey; + case EquipmentCategory.vehicle: + return Colors.teal; case EquipmentCategory.other: return Colors.blueGrey; } @@ -176,7 +187,13 @@ extension EquipmentCategoryExtension on EquipmentCategory { return 'assets/icons/truss.svg'; case EquipmentCategory.consumable: return 'assets/icons/tape.svg'; - default: + case EquipmentCategory.lighting: + case EquipmentCategory.sound: + case EquipmentCategory.video: + case EquipmentCategory.effect: + case EquipmentCategory.cable: + case EquipmentCategory.vehicle: + case EquipmentCategory.other: return null; } } diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index d1ca95b..22969af 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -489,12 +489,10 @@ class EventModel { 'BasePrice': basePrice, 'InstallationTime': installationTime, 'DisassemblyTime': disassemblyTime, - 'EventType': eventTypeId.isNotEmpty - ? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId) - : null, - 'customer': customerId.isNotEmpty - ? FirebaseFirestore.instance.collection('customers').doc(customerId) - : null, + // Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions + 'EventType': eventTypeId.isNotEmpty ? eventTypeId : null, + // Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions + 'customer': customerId.isNotEmpty ? customerId : null, 'Address': address, 'Position': GeoPoint(latitude, longitude), 'Latitude': latitude, diff --git a/em2rp/lib/providers/container_provider.dart b/em2rp/lib/providers/container_provider.dart index 352977f..bb37747 100644 --- a/em2rp/lib/providers/container_provider.dart +++ b/em2rp/lib/providers/container_provider.dart @@ -26,10 +26,8 @@ class ContainerProvider with ChangeNotifier { /// S'assure que les conteneurs sont chargés (charge si nécessaire) Future ensureLoaded() async { if (_isInitialized || _isLoading) { - print('[ContainerProvider] Containers already loaded or loading, skipping...'); return; } - print('[ContainerProvider] Containers not loaded, loading now...'); await loadContainers(); } diff --git a/em2rp/lib/providers/local_user_provider.dart b/em2rp/lib/providers/local_user_provider.dart index 13322e8..1c437aa 100644 --- a/em2rp/lib/providers/local_user_provider.dart +++ b/em2rp/lib/providers/local_user_provider.dart @@ -154,4 +154,16 @@ class LocalUserProvider with ChangeNotifier { bool hasPermission(String permission) { return _currentRole?.permissions.contains(permission) ?? false; } + + /// Vérifie si l'utilisateur a toutes les permissions données + bool hasAllPermissions(List permissions) { + if (_currentRole == null) return false; + return permissions.every((p) => _currentRole!.permissions.contains(p)); + } + + /// Vérifie si l'utilisateur a au moins une des permissions données + bool hasAnyPermission(List permissions) { + if (_currentRole == null) return false; + return permissions.any((p) => _currentRole!.permissions.contains(p)); + } } diff --git a/em2rp/lib/services/api_service.dart b/em2rp/lib/services/api_service.dart index b5f7650..41a3984 100644 --- a/em2rp/lib/services/api_service.dart +++ b/em2rp/lib/services/api_service.dart @@ -3,6 +3,7 @@ import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:em2rp/config/api_config.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/utils/debug_log.dart'; /// Interface abstraite pour les opérations API /// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.) @@ -107,8 +108,8 @@ class FirebaseFunctionsApiService implements ApiService { return Map.from(safeData as Map); } catch (e) { // Si l'encodage échoue, essayer de créer une copie profonde manuelle - print('[API] Error in _prepareForJson: $e'); - print('[API] Trying manual deep copy...'); + DebugLog.error('[API] Error in _prepareForJson', e); + DebugLog.info('[API] Trying manual deep copy...'); return _deepCopyMap(data); } } @@ -149,8 +150,8 @@ class FirebaseFunctionsApiService implements ApiService { // Préparer les données avec double passage pour éviter les _JsonMap final preparedData = _prepareForJson(data); - // Log pour débogage - print('[API] Calling $functionName with eventId: ${preparedData['eventId']}'); + // Log pour débogage (seulement en mode debug) + DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}'); try { // Encoder directement avec jsonEncode standard @@ -173,8 +174,7 @@ class FirebaseFunctionsApiService implements ApiService { ); } } catch (e) { - print('[API] Error during request: $e'); - print('[API] Error type: ${e.runtimeType}'); + DebugLog.error('[API] Error during request: $functionName', e); throw ApiException( message: 'Error calling $functionName: $e', statusCode: 0, diff --git a/em2rp/lib/services/data_service.dart b/em2rp/lib/services/data_service.dart index 7e1adad..55fe46b 100644 --- a/em2rp/lib/services/data_service.dart +++ b/em2rp/lib/services/data_service.dart @@ -103,11 +103,14 @@ class DataService { } } - /// Crée un nouvel équipement + /// Crée un équipement Future createEquipment(String equipmentId, Map data) async { try { - final requestData = {'equipmentId': equipmentId, ...data}; - await _apiService.call('createEquipment', requestData); + // S'assurer que l'ID est dans les données + final equipmentData = Map.from(data); + equipmentData['id'] = equipmentId; + + await _apiService.call('createEquipment', equipmentData); } catch (e) { throw Exception('Erreur lors de la création de l\'équipement: $e'); } @@ -116,8 +119,10 @@ class DataService { /// Met à jour un équipement Future updateEquipment(String equipmentId, Map data) async { try { - final requestData = {'equipmentId': equipmentId, ...data}; - await _apiService.call('updateEquipment', requestData); + await _apiService.call('updateEquipment', { + 'equipmentId': equipmentId, + 'data': data, + }); } catch (e) { throw Exception('Erreur lors de la mise à jour de l\'équipement: $e'); } diff --git a/em2rp/lib/services/equipment_service.dart b/em2rp/lib/services/equipment_service.dart index a0e89f0..c2bcc58 100644 --- a/em2rp/lib/services/equipment_service.dart +++ b/em2rp/lib/services/equipment_service.dart @@ -16,7 +16,14 @@ class EquipmentService { /// Créer un nouvel équipement (via Cloud Function) Future createEquipment(EquipmentModel equipment) async { try { - await _apiService.call('createEquipment', equipment.toMap()..['id'] = equipment.id); + if (equipment.id.isEmpty) { + throw Exception('L\'ID de l\'équipement est requis pour la création'); + } + + final data = equipment.toMap(); + data['id'] = equipment.id; // S'assurer que l'ID est inclus + + await _apiService.call('createEquipment', data); } catch (e) { print('Error creating equipment: $e'); rethrow; @@ -26,6 +33,10 @@ class EquipmentService { /// Mettre à jour un équipement (via Cloud Function) Future updateEquipment(String id, Map data) async { try { + if (data.isEmpty) { + throw Exception('Aucune donnée à mettre à jour'); + } + await _apiService.call('updateEquipment', { 'equipmentId': id, 'data': data, diff --git a/em2rp/lib/services/ics_export_service.dart b/em2rp/lib/services/ics_export_service.dart index a866a9b..1a25fa3 100644 --- a/em2rp/lib/services/ics_export_service.dart +++ b/em2rp/lib/services/ics_export_service.dart @@ -1,5 +1,6 @@ import 'package:em2rp/models/event_model.dart'; import 'package:intl/intl.dart'; +import 'package:em2rp/utils/debug_log.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; class IcsExportService { diff --git a/em2rp/lib/utils/app_permissions.dart b/em2rp/lib/utils/app_permissions.dart new file mode 100644 index 0000000..da69c65 --- /dev/null +++ b/em2rp/lib/utils/app_permissions.dart @@ -0,0 +1,274 @@ +/// Énumération centralisée de toutes les permissions de l'application +/// Chaque permission contrôle l'accès à une fonctionnalité spécifique +enum AppPermission { + // ============= ÉVÉNEMENTS ============= + /// Permet de voir les événements + viewEvents('view_events'), + + /// Permet de créer de nouveaux événements + createEvents('create_events'), + + /// Permet de modifier les événements existants + editEvents('edit_events'), + + /// Permet de supprimer des événements + deleteEvents('delete_events'), + + /// Permet de voir tous les événements de tous les utilisateurs + /// (nécessaire pour le filtre par utilisateur dans le calendrier) + viewAllUserEvents('view_all_user_events'), + + // ============= ÉQUIPEMENTS ============= + /// Permet de voir la liste des équipements + viewEquipment('view_equipment'), + + /// Permet de créer, modifier et supprimer des équipements + /// Inclut aussi la gestion des prix d'achat/location + manageEquipment('manage_equipment'), + + // ============= CONTENEURS ============= + /// Permet de voir les conteneurs + viewContainers('view_containers'), + + /// Permet de créer, modifier et supprimer des conteneurs + manageContainers('manage_containers'), + + // ============= MAINTENANCE ============= + /// Permet de voir les maintenances + viewMaintenance('view_maintenance'), + + /// Permet de créer, modifier et supprimer des maintenances + manageMaintenance('manage_maintenance'), + + // ============= UTILISATEURS ============= + /// Permet de voir la liste de tous les utilisateurs + viewAllUsers('view_all_users'), + + /// Permet de créer, modifier et supprimer des utilisateurs + /// Inclut la gestion des rôles + manageUsers('manage_users'), + + // ============= ALERTES ============= + /// Reçoit les alertes de maintenance + receiveMaintenanceAlerts('receive_maintenance_alerts'), + + /// Reçoit les alertes d'événements (création, modification) + receiveEventAlerts('receive_event_alerts'), + + /// Reçoit les alertes de stock faible + receiveStockAlerts('receive_stock_alerts'), + + // ============= NOTIFICATIONS ============= + /// Peut recevoir des notifications par email + receiveEmailNotifications('receive_email_notifications'), + + /// Peut recevoir des notifications push dans le navigateur + receivePushNotifications('receive_push_notifications'), + + // ============= PRÉPARATION/CHARGEMENT ============= + /// Permet d'accéder aux pages de préparation d'événements + accessPreparation('access_preparation'), + + /// Permet de valider les étapes de préparation + validatePreparation('validate_preparation'), + + // ============= EXPORTS/RAPPORTS ============= + /// Permet d'exporter des données (ICS, PDF, etc.) + exportData('export_data'), + + /// Permet de générer des rapports + generateReports('generate_reports'); + + /// L'identifiant de la permission tel qu'il est stocké dans Firestore + final String id; + + const AppPermission(this.id); + + /// Convertit une string en AppPermission + static AppPermission? fromString(String? value) { + if (value == null) return null; + try { + return AppPermission.values.firstWhere((p) => p.id == value); + } catch (e) { + return null; + } + } + + /// Retourne une description lisible de la permission (pour l'UI admin) + String get description { + switch (this) { + // Événements + case AppPermission.viewEvents: + return 'Voir les événements'; + case AppPermission.createEvents: + return 'Créer des événements'; + case AppPermission.editEvents: + return 'Modifier des événements'; + case AppPermission.deleteEvents: + return 'Supprimer des événements'; + case AppPermission.viewAllUserEvents: + return 'Voir les événements de tous les utilisateurs'; + + // Équipements + case AppPermission.viewEquipment: + return 'Voir les équipements'; + case AppPermission.manageEquipment: + return 'Gérer les équipements'; + + // Conteneurs + case AppPermission.viewContainers: + return 'Voir les conteneurs'; + case AppPermission.manageContainers: + return 'Gérer les conteneurs'; + + // Maintenance + case AppPermission.viewMaintenance: + return 'Voir les maintenances'; + case AppPermission.manageMaintenance: + return 'Gérer les maintenances'; + + // Utilisateurs + case AppPermission.viewAllUsers: + return 'Voir tous les utilisateurs'; + case AppPermission.manageUsers: + return 'Gérer les utilisateurs'; + + // Alertes + case AppPermission.receiveMaintenanceAlerts: + return 'Recevoir les alertes de maintenance'; + case AppPermission.receiveEventAlerts: + return 'Recevoir les alertes d\'événements'; + case AppPermission.receiveStockAlerts: + return 'Recevoir les alertes de stock'; + + // Notifications + case AppPermission.receiveEmailNotifications: + return 'Recevoir les notifications par email'; + case AppPermission.receivePushNotifications: + return 'Recevoir les notifications push'; + + // Préparation + case AppPermission.accessPreparation: + return 'Accéder aux préparations d\'événements'; + case AppPermission.validatePreparation: + return 'Valider les préparations'; + + // Exports + case AppPermission.exportData: + return 'Exporter des données'; + case AppPermission.generateReports: + return 'Générer des rapports'; + } + } + + /// Retourne la catégorie de la permission (pour l'UI de gestion des rôles) + String get category { + switch (this) { + case AppPermission.viewEvents: + case AppPermission.createEvents: + case AppPermission.editEvents: + case AppPermission.deleteEvents: + case AppPermission.viewAllUserEvents: + return 'Événements'; + + case AppPermission.viewEquipment: + case AppPermission.manageEquipment: + return 'Équipements'; + + case AppPermission.viewContainers: + case AppPermission.manageContainers: + return 'Conteneurs'; + + case AppPermission.viewMaintenance: + case AppPermission.manageMaintenance: + return 'Maintenance'; + + case AppPermission.viewAllUsers: + case AppPermission.manageUsers: + return 'Utilisateurs'; + + case AppPermission.receiveMaintenanceAlerts: + case AppPermission.receiveEventAlerts: + case AppPermission.receiveStockAlerts: + return 'Alertes'; + + case AppPermission.receiveEmailNotifications: + case AppPermission.receivePushNotifications: + return 'Notifications'; + + case AppPermission.accessPreparation: + case AppPermission.validatePreparation: + return 'Préparation'; + + case AppPermission.exportData: + case AppPermission.generateReports: + return 'Exports & Rapports'; + } + } +} + +/// Extension pour faciliter les vérifications de permissions +extension PermissionListExtension on List { + /// Vérifie si la liste contient une permission donnée + bool hasPermission(AppPermission permission) { + return contains(permission.id); + } + + /// Vérifie si la liste contient toutes les permissions données + bool hasAllPermissions(List permissions) { + return permissions.every((p) => contains(p.id)); + } + + /// Vérifie si la liste contient au moins une des permissions données + bool hasAnyPermission(List permissions) { + return permissions.any((p) => contains(p.id)); + } +} + +/// Rôles prédéfinis avec leurs permissions +class PredefinedRoles { + /// Rôle ADMIN : Accès complet à toutes les fonctionnalités + static List get admin => AppPermission.values.map((p) => p.id).toList(); + + /// Rôle TECHNICIEN : Gestion des équipements et préparation + static List get technician => [ + AppPermission.viewEvents.id, + AppPermission.viewEquipment.id, + AppPermission.manageEquipment.id, + AppPermission.viewContainers.id, + AppPermission.manageContainers.id, + AppPermission.viewMaintenance.id, + AppPermission.manageMaintenance.id, + AppPermission.receiveMaintenanceAlerts.id, + AppPermission.receiveStockAlerts.id, + AppPermission.accessPreparation.id, + AppPermission.validatePreparation.id, + AppPermission.exportData.id, + ]; + + /// Rôle MANAGER : Gestion des événements et vue d'ensemble + static List get manager => [ + AppPermission.viewEvents.id, + AppPermission.createEvents.id, + AppPermission.editEvents.id, + AppPermission.deleteEvents.id, + AppPermission.viewAllUserEvents.id, + AppPermission.viewEquipment.id, + AppPermission.viewContainers.id, + AppPermission.viewMaintenance.id, + AppPermission.viewAllUsers.id, + AppPermission.receiveEventAlerts.id, + AppPermission.accessPreparation.id, + AppPermission.exportData.id, + AppPermission.generateReports.id, + ]; + + /// Rôle USER : Consultation uniquement + static List get user => [ + AppPermission.viewEvents.id, + AppPermission.viewEquipment.id, + AppPermission.viewContainers.id, + AppPermission.receiveEventAlerts.id, + ]; +} + diff --git a/em2rp/lib/utils/debug_log.dart b/em2rp/lib/utils/debug_log.dart new file mode 100644 index 0000000..0cf81db --- /dev/null +++ b/em2rp/lib/utils/debug_log.dart @@ -0,0 +1,33 @@ +import 'package:flutter/foundation.dart'; + +/// Helper pour gérer les logs de debug +/// Les logs sont automatiquement désactivés en mode release +class DebugLog { + /// Flag pour activer/désactiver les logs manuellement + static const bool _forceEnableLogs = false; + + /// Vérifie si les logs doivent être affichés + static bool get _shouldLog => kDebugMode || _forceEnableLogs; + + /// Log une information + static void info(String message) { + if (_shouldLog) { + print(message); + } + } + + /// Log une erreur (toujours affiché, même en production) + static void error(String message, [Object? error, StackTrace? stackTrace]) { + print('ERROR: $message'); + if (error != null) print(' Error: $error'); + if (stackTrace != null && kDebugMode) print(' StackTrace: $stackTrace'); + } + + /// Log un warning + static void warning(String message) { + if (_shouldLog) { + print('WARNING: $message'); + } + } +} + diff --git a/em2rp/lib/utils/equipment_helpers.dart b/em2rp/lib/utils/equipment_helpers.dart new file mode 100644 index 0000000..df18ba2 --- /dev/null +++ b/em2rp/lib/utils/equipment_helpers.dart @@ -0,0 +1,35 @@ +import 'package:em2rp/models/equipment_model.dart'; + +/// Helpers pour la gestion et l'affichage des équipements +class EquipmentHelpers { + /// Détermine si un équipement devrait avoir une quantité par défaut + /// Retourne true pour câbles, consommables et structures + static bool shouldBeQuantifiableByDefault(EquipmentCategory category) { + return category == EquipmentCategory.cable || + category == EquipmentCategory.consumable || + category == EquipmentCategory.structure; + } + + /// Calcule la quantité disponible d'un équipement + /// Prend en compte la quantité totale et la quantité déjà assignée + static int calculateAvailableQuantity( + EquipmentModel equipment, + int assignedQuantity, + ) { + if (!equipment.hasQuantity) return 0; + + final total = equipment.availableQuantity ?? equipment.totalQuantity ?? 0; + return (total - assignedQuantity).clamp(0, total); + } + + /// Vérifie si un équipement est en stock faible + /// (quantité disponible en dessous du seuil critique) + static bool isLowStock(EquipmentModel equipment) { + if (!equipment.hasQuantity) return false; + if (equipment.criticalThreshold == null) return false; + + final available = equipment.availableQuantity ?? 0; + return available <= equipment.criticalThreshold!; + } +} + diff --git a/em2rp/lib/utils/price_helpers.dart b/em2rp/lib/utils/price_helpers.dart new file mode 100644 index 0000000..1075ea6 --- /dev/null +++ b/em2rp/lib/utils/price_helpers.dart @@ -0,0 +1,86 @@ +import 'package:em2rp/models/event_model.dart'; + +/// Helper pour la gestion des prix HT et TTC +class PriceHelpers { + /// Taux de TVA par défaut (20%) + static const double defaultTaxRate = 0.20; + + /// Calcule le prix TTC à partir du prix HT + static double calculateTTC(double priceHT, {double taxRate = defaultTaxRate}) { + return priceHT * (1 + taxRate); + } + + /// Calcule le prix HT à partir du prix TTC + static double calculateHT(double priceTTC, {double taxRate = defaultTaxRate}) { + return priceTTC / (1 + taxRate); + } + + /// Calcule le montant de TVA + static double calculateTax(double priceHT, {double taxRate = defaultTaxRate}) { + return priceHT * taxRate; + } + + /// Formate un prix en euros avec deux décimales + static String formatPrice(double price) { + return '${price.toStringAsFixed(2)} €'; + } + + /// Retourne un objet EventPricing avec HT, TVA et TTC calculés + static EventPricing getPricing(EventModel event, {double taxRate = defaultTaxRate}) { + // basePrice dans Firestore est le prix TTC (avec TVA 20% déjà incluse) + final priceTTC = event.basePrice; + final priceHT = calculateHT(priceTTC, taxRate: taxRate); + final taxAmount = calculateTax(priceHT, taxRate: taxRate); + + return EventPricing( + priceHT: priceHT, + taxAmount: taxAmount, + priceTTC: priceTTC, + taxRate: taxRate, + ); + } +} + +/// Classe pour stocker les différentes composantes du prix d'un événement +class EventPricing { + final double priceHT; + final double taxAmount; + final double priceTTC; + final double taxRate; + + const EventPricing({ + required this.priceHT, + required this.taxAmount, + required this.priceTTC, + required this.taxRate, + }); + + /// Retourne le taux de TVA en pourcentage (ex: 20.0 pour 20%) + double get taxRatePercentage => taxRate * 100; + + /// Formate le prix HT + String get formattedHT => PriceHelpers.formatPrice(priceHT); + + /// Formate le montant de TVA + String get formattedTax => PriceHelpers.formatPrice(taxAmount); + + /// Formate le prix TTC + String get formattedTTC => PriceHelpers.formatPrice(priceTTC); + + /// Retourne un résumé complet du pricing + String get summary => 'HT: $formattedHT | TVA (${taxRatePercentage.toStringAsFixed(0)}%): $formattedTax | TTC: $formattedTTC'; +} + +/// Widget helper pour afficher les prix +class PriceDisplay { + /// Génère un Map avec les composantes de prix pour affichage + static Map getPriceComponents(EventModel event) { + final pricing = PriceHelpers.getPricing(event); + return { + 'HT': pricing.formattedHT, + 'TVA': '${pricing.formattedTax} (${pricing.taxRatePercentage.toStringAsFixed(0)}%)', + 'TTC': pricing.formattedTTC, + }; + } +} + diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index e427cba..c7f0f55 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -12,6 +12,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart'; +import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart'; import 'package:em2rp/utils/colors.dart'; class CalendarPage extends StatefulWidget { @@ -28,6 +29,7 @@ class _CalendarPageState extends State { EventModel? _selectedEvent; bool _calendarCollapsed = false; int _selectedEventIndex = 0; + String? _selectedUserId; // Filtre par utilisateur (null = tous les événements) @override void initState() { @@ -94,6 +96,26 @@ 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) { + if (_selectedUserId == null) { + return allEvents; // Pas de filtre, retourner tous les événements + } + + // Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce + return allEvents.where((event) { + return event.workforce.any((worker) { + if (worker is String) { + return worker == _selectedUserId; + } + // Si c'est une DocumentReference, on ne peut pas facilement comparer + // On suppose que les données sont chargées correctement en String + return false; + }); + }).toList(); + } + void _changeWeek(int delta) { setState(() { _focusedDay = _focusedDay.add(Duration(days: 7 * delta)); @@ -104,9 +126,13 @@ class _CalendarPageState extends State { Widget build(BuildContext context) { final eventProvider = Provider.of(context); final localUserProvider = Provider.of(context); - final isAdmin = localUserProvider.hasPermission('view_all_users'); + final canCreateEvents = localUserProvider.hasPermission('create_events'); + final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events'); final isMobile = MediaQuery.of(context).size.width < 600; + // Appliquer le filtre utilisateur si actif + final filteredEvents = _getFilteredEvents(eventProvider.events); + if (eventProvider.isLoading) { return const Scaffold( body: Center( @@ -120,8 +146,42 @@ class _CalendarPageState extends State { title: "Calendrier", ), drawer: const MainDrawer(currentPage: '/calendar'), - body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), - floatingActionButton: isAdmin + 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; + }); + }, + ), + ), + ], + ), + ), + // Corps du calendrier + Expanded( + child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents), + ), + ], + ), + floatingActionButton: canCreateEvents ? FloatingActionButton( backgroundColor: Colors.white, onPressed: () { @@ -140,14 +200,13 @@ class _CalendarPageState extends State { ); } - Widget _buildDesktopLayout() { - final eventProvider = Provider.of(context); + Widget _buildDesktopLayout(List filteredEvents) { return Row( children: [ // Calendrier (65% de la largeur) Expanded( flex: 65, - child: _buildCalendar(), + child: _buildCalendar(filteredEvents), ), // Détails de l'événement (35% de la largeur) Expanded( @@ -156,7 +215,7 @@ class _CalendarPageState extends State { ? EventDetails( event: _selectedEvent!, selectedDate: _selectedDay, - events: eventProvider.events, + events: filteredEvents, onSelectEvent: (event, date) { setState(() { _selectedEvent = event; @@ -175,11 +234,10 @@ class _CalendarPageState extends State { ); } - Widget _buildMobileLayout() { - final eventProvider = Provider.of(context); + Widget _buildMobileLayout(List filteredEvents) { final eventsForSelectedDay = _selectedDay == null ? [] - : eventProvider.events + : filteredEvents .where((e) => e.startDateTime.year == _selectedDay!.year && e.startDateTime.month == _selectedDay!.month && @@ -264,9 +322,9 @@ class _CalendarPageState extends State { child: MobileCalendarView( focusedDay: _focusedDay, selectedDay: _selectedDay, - events: eventProvider.events, + events: filteredEvents, onDaySelected: (day) { - final eventsForDay = eventProvider.events + final eventsForDay = filteredEvents .where((e) => e.startDateTime.year == day.year && e.startDateTime.month == day.month && @@ -502,13 +560,11 @@ class _CalendarPageState extends State { } } - Widget _buildCalendar() { - final eventProvider = Provider.of(context); - + Widget _buildCalendar(List filteredEvents) { if (_calendarFormat == CalendarFormat.week) { return WeekView( focusedDay: _focusedDay, - events: eventProvider.events, + events: filteredEvents, onWeekChange: _changeWeek, onEventSelected: (event) { setState(() { @@ -522,7 +578,7 @@ class _CalendarPageState extends State { }); }, onDaySelected: (selectedDay) { - final eventsForDay = eventProvider.events + final eventsForDay = filteredEvents .where((e) => e.startDateTime.year == selectedDay.year && e.startDateTime.month == selectedDay.month && @@ -554,9 +610,9 @@ class _CalendarPageState extends State { focusedDay: _focusedDay, selectedDay: _selectedDay, calendarFormat: _calendarFormat, - events: eventProvider.events, + events: filteredEvents, onDaySelected: (selectedDay, focusedDay) { - final eventsForDay = eventProvider.events + final eventsForDay = filteredEvents .where((event) => event.startDateTime.year == selectedDay.year && event.startDateTime.month == selectedDay.month && diff --git a/em2rp/lib/views/container_detail_page.dart b/em2rp/lib/views/container_detail_page.dart index de5a640..399a015 100644 --- a/em2rp/lib/views/container_detail_page.dart +++ b/em2rp/lib/views/container_detail_page.dart @@ -622,6 +622,8 @@ class _ContainerDetailPageState extends State { return 'Consommable'; case EquipmentCategory.cable: return 'Câble'; + case EquipmentCategory.vehicle: + return 'Véhicule'; case EquipmentCategory.other: return 'Autre'; } diff --git a/em2rp/lib/views/container_form_page.dart b/em2rp/lib/views/container_form_page.dart index 4335f74..034e89e 100644 --- a/em2rp/lib/views/container_form_page.dart +++ b/em2rp/lib/views/container_form_page.dart @@ -5,6 +5,7 @@ import 'package:em2rp/models/container_model.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/providers/container_provider.dart'; import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/id_generator.dart'; class ContainerFormPage extends StatefulWidget { @@ -534,7 +535,7 @@ class _ContainerFormPageState extends State { equipmentId: equipmentId, ); } catch (e) { - print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); } } @@ -580,7 +581,7 @@ class _ContainerFormPageState extends State { equipmentId: equipmentId, ); } catch (e) { - print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e); } } @@ -593,7 +594,7 @@ class _ContainerFormPageState extends State { equipmentId: equipmentId, ); } catch (e) { - print('Erreur lors du retrait de l\'équipement $equipmentId: $e'); + DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e); } } @@ -911,6 +912,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { return 'Consommable'; case EquipmentCategory.cable: return 'Câble'; + case EquipmentCategory.vehicle: + return 'Véhicule'; case EquipmentCategory.other: return 'Autre'; } @@ -932,6 +935,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { return Icons.inventory; case EquipmentCategory.cable: return Icons.cable; + case EquipmentCategory.vehicle: + return Icons.local_shipping; case EquipmentCategory.other: return Icons.category; } diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart index 2726a7c..72be0b8 100644 --- a/em2rp/lib/views/equipment_form_page.dart +++ b/em2rp/lib/views/equipment_form_page.dart @@ -13,6 +13,7 @@ import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:intl/intl.dart'; import 'package:em2rp/views/equipment_form/brand_model_selector.dart'; import 'package:em2rp/utils/id_generator.dart'; +import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.dart'; class EquipmentFormPage extends StatefulWidget { @@ -86,7 +87,7 @@ class _EquipmentFormPageState extends State { _notesController.text = equipment.notes ?? ''; }); - print('[EquipmentForm] Populating fields for equipment: ${equipment.id}'); + DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}'); // Charger les containers contenant cet équipement depuis Firestore _loadCurrentContainers(equipment.id); @@ -103,26 +104,26 @@ class _EquipmentFormPageState extends State { setState(() { _selectedParentBoxIds = containers.map((c) => c.id).toList(); }); - print('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId'); - print('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds'); + DebugLog.info('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId'); + DebugLog.info('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds'); } catch (e) { - print('[EquipmentForm] Error loading containers for equipment: $e'); + DebugLog.error('[EquipmentForm] Error loading containers for equipment', e); } } Future _loadAvailableBoxes() async { try { final boxes = await _equipmentService.getBoxes(); - print('[EquipmentForm] Loaded ${boxes.length} boxes from service'); + DebugLog.info('[EquipmentForm] Loaded ${boxes.length} boxes from service'); for (var box in boxes) { - print('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}'); + DebugLog.info('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}'); } setState(() { _availableBoxes = boxes; _isLoadingBoxes = false; }); } catch (e) { - print('[EquipmentForm] Error loading boxes: $e'); + DebugLog.error('[EquipmentForm] Error loading boxes', e); setState(() { _isLoadingBoxes = false; }); @@ -660,9 +661,9 @@ class _EquipmentFormPageState extends State { containerId: boxId, equipmentId: equipment.id, ); - print('[EquipmentForm] Added equipment ${equipment.id} to container $boxId'); + DebugLog.info('[EquipmentForm] Added equipment ${equipment.id} to container $boxId'); } catch (e) { - print('[EquipmentForm] Error adding equipment to container $boxId: $e'); + DebugLog.error('[EquipmentForm] Error adding equipment to container $boxId', e); } } @@ -675,9 +676,9 @@ class _EquipmentFormPageState extends State { containerId: boxId, equipmentId: equipment.id, ); - print('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId'); + DebugLog.info('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId'); } catch (e) { - print('[EquipmentForm] Error removing equipment from container $boxId: $e'); + DebugLog.error('[EquipmentForm] Error removing equipment from container $boxId', e); } } } else { @@ -691,9 +692,9 @@ class _EquipmentFormPageState extends State { containerId: boxId, equipmentId: equipment.id, ); - print('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId'); + DebugLog.info('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId'); } catch (e) { - print('[EquipmentForm] Error adding new equipment to container $boxId: $e'); + DebugLog.error('[EquipmentForm] Error adding new equipment to container $boxId', e); } } } diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index ee76aff..6b73d6f 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -11,6 +11,7 @@ import 'package:em2rp/views/equipment_detail_page.dart'; import 'package:em2rp/views/widgets/common/qr_code_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/mixins/selection_mode_mixin.dart'; import 'package:em2rp/views/widgets/management/management_list.dart'; @@ -32,10 +33,10 @@ class _EquipmentManagementPageState extends State @override void initState() { super.initState(); - print('[EquipmentManagementPage] initState called'); + DebugLog.info('[EquipmentManagementPage] initState called'); // Charger les équipements au démarrage WidgetsBinding.instance.addPostFrameCallback((_) { - print('[EquipmentManagementPage] Loading equipments...'); + DebugLog.info('[EquipmentManagementPage] Loading equipments...'); context.read().loadEquipments(); }); } @@ -431,17 +432,17 @@ class _EquipmentManagementPageState extends State Widget _buildEquipmentList() { return Consumer( builder: (context, provider, child) { - print('[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) { - print('[EquipmentManagementPage] Showing loading indicator'); + DebugLog.info('[EquipmentManagementPage] Showing loading indicator'); return const Center(child: CircularProgressIndicator()); } final equipments = provider.equipment; if (equipments.isEmpty && !provider.isLoading) { - print('[EquipmentManagementPage] No equipment found'); + DebugLog.info('[EquipmentManagementPage] No equipment found'); return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -464,7 +465,7 @@ class _EquipmentManagementPageState extends State ); } - print('[EquipmentManagementPage] Building list with ${equipments.length} items'); + DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items'); return ListView.builder( itemCount: equipments.length, itemBuilder: (context, index) { diff --git a/em2rp/lib/views/event_preparation_page.dart b/em2rp/lib/views/event_preparation_page.dart index 5b32d7f..2f1a7a4 100644 --- a/em2rp/lib/views/event_preparation_page.dart +++ b/em2rp/lib/views/event_preparation_page.dart @@ -11,6 +11,7 @@ import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/services/api_service.dart'; import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep; +import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart'; import 'package:em2rp/utils/colors.dart'; @@ -203,7 +204,7 @@ class _EventPreparationPageState extends State with Single _containerCache[containerId] = container; } } catch (e) { - print('[EventPreparationPage] Error: $e'); + DebugLog.error('[EventPreparationPage] Error', e); } finally { setState(() => _isLoading = false); } diff --git a/em2rp/lib/views/widgets/auth/forgot_password_dialog.dart b/em2rp/lib/views/widgets/auth/forgot_password_dialog.dart index 47bdc12..6818994 100644 --- a/em2rp/lib/views/widgets/auth/forgot_password_dialog.dart +++ b/em2rp/lib/views/widgets/auth/forgot_password_dialog.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; @@ -41,8 +42,6 @@ class _ForgotPasswordDialogState extends State { _errorMessage = "Erreur : ${e.message}"; _emailSent = false; }); - print( - "Erreur de réinitialisation du mot de passe: ${e.code} - ${e.message}"); } } diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_info.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_info.dart index 5743409..6c74f12 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_info.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_info.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/price_helpers.dart'; import 'package:intl/intl.dart'; import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart'; @@ -34,13 +35,48 @@ class EventDetailsInfo extends StatelessWidget { 'Horaire de fin', dateFormat.format(event.endDateTime), ), - if (canViewPrices) - _buildInfoRow( - context, - Icons.euro, - 'Prix de base', - currencyFormat.format(event.basePrice), + if (canViewPrices) ...[ + // Calcul des prix HT/TVA/TTC + Builder( + builder: (context) { + final pricing = PriceHelpers.getPricing(event); + return Column( + children: [ + _buildInfoRow( + context, + Icons.euro, + 'Prix HT', + pricing.formattedHT, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Row( + children: [ + Icon(Icons.percent, size: 16, color: Colors.grey[600]), + const SizedBox(width: 8), + Text( + 'TVA (${pricing.taxRatePercentage.toStringAsFixed(0)}%) : ${pricing.formattedTax}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ], + ), + ), + _buildInfoRow( + context, + Icons.attach_money, + 'Prix TTC', + pricing.formattedTTC, + highlighted: true, + ), + ], + ); + }, ), + ], if (event.options.isNotEmpty) ...[ EventOptionsDisplayWidget( optionsData: event.options, @@ -52,34 +88,85 @@ class EventDetailsInfo extends StatelessWidget { const SizedBox(height: 4), Builder( builder: (context) { - final total = event.basePrice + + // Total TTC = basePrice (TTC) + options (TTC) + final totalTTC = event.basePrice + event.options.fold( 0, (sum, opt) { - final price = opt['price'] ?? 0.0; + final priceTTC = opt['price'] ?? 0.0; final quantity = opt['quantity'] ?? 1; - return sum + (price * quantity); + return sum + (priceTTC * quantity); }, ); + + // Calculer le total HT + final totalHT = PriceHelpers.calculateHT(totalTTC.toDouble()); + final totalTVA = PriceHelpers.calculateTax(totalHT); + return Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.attach_money, color: AppColors.rouge), - const SizedBox(width: 8), - Text( - 'Prix total : ', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), + // Séparateur visuel + const Divider(thickness: 1), + const SizedBox(height: 8), + + // Prix total HT + Row( + children: [ + const Icon(Icons.euro, color: AppColors.rouge, size: 22), + const SizedBox(width: 8), + Text( + 'Prix total HT : ', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + Text( + currencyFormat.format(totalHT), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + ], ), - Text( - currencyFormat.format(total), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppColors.rouge, - fontWeight: FontWeight.bold, - ), + + // TVA en petit + Padding( + padding: const EdgeInsets.only(left: 30.0, top: 4.0, bottom: 4.0), + child: Text( + 'TVA (20%) : ${currencyFormat.format(totalTVA)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ), + + // Prix total TTC en surbrillance + Row( + children: [ + const Icon(Icons.attach_money, color: AppColors.rouge, size: 24), + const SizedBox(width: 8), + Text( + 'Prix total TTC : ', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.w900, + ), + ), + Text( + currencyFormat.format(totalTTC), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.w900, + ), + ), + ], ), ], ), @@ -139,24 +226,28 @@ class EventDetailsInfo extends StatelessWidget { BuildContext context, IconData icon, String label, - String value, - ) { + String value, { + bool highlighted = false, + }) { return Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Row( children: [ - Icon(icon, color: AppColors.rouge), + Icon(icon, color: highlighted ? AppColors.rouge : AppColors.rouge), const SizedBox(width: 8), Text( '$label : ', style: Theme.of(context).textTheme.titleMedium?.copyWith( color: AppColors.noir, - fontWeight: FontWeight.bold, + fontWeight: highlighted ? FontWeight.w900 : FontWeight.bold, ), ), Text( value, - style: Theme.of(context).textTheme.titleMedium, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: highlighted ? AppColors.rouge : null, + fontWeight: highlighted ? FontWeight.bold : null, + ), ), ], ), diff --git a/em2rp/lib/views/widgets/calendar_widgets/user_filter_dropdown.dart b/em2rp/lib/views/widgets/calendar_widgets/user_filter_dropdown.dart new file mode 100644 index 0000000..9682327 --- /dev/null +++ b/em2rp/lib/views/widgets/calendar_widgets/user_filter_dropdown.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/user_model.dart'; +import 'package:em2rp/providers/users_provider.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Widget de filtre par utilisateur pour le calendrier +/// Affiche un dropdown permettant de filtrer les événements par utilisateur +class UserFilterDropdown extends StatefulWidget { + final String? selectedUserId; + final ValueChanged onUserSelected; + + const UserFilterDropdown({ + super.key, + required this.selectedUserId, + required this.onUserSelected, + }); + + @override + State createState() => _UserFilterDropdownState(); +} + +class _UserFilterDropdownState extends State { + List _users = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + // Charger après le premier frame pour éviter setState pendant build + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _loadUsers(); + } + }); + } + + Future _loadUsers() async { + if (!mounted) return; + + final usersProvider = Provider.of(context, listen: false); + + // Ne pas appeler fetchUsers si les utilisateurs sont déjà chargés + if (usersProvider.users.isEmpty) { + await usersProvider.fetchUsers(); + } + + if (!mounted) return; + + setState(() { + _users = usersProvider.users; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + if (_isLoading) { + return const SizedBox( + width: 200, + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + + return Container( + width: 250, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: widget.selectedUserId, + hint: const Row( + children: [ + Icon(Icons.filter_list, size: 18, color: Colors.grey), + SizedBox(width: 8), + Text('Tous les utilisateurs', style: TextStyle(fontSize: 14)), + ], + ), + isExpanded: true, + icon: const Icon(Icons.arrow_drop_down, size: 24), + style: const TextStyle(fontSize: 14, color: Colors.black87), + onChanged: widget.onUserSelected, + items: [ + // Option "Tous les utilisateurs" + const DropdownMenuItem( + value: null, + child: Row( + children: [ + Icon(Icons.people, size: 18, color: AppColors.rouge), + SizedBox(width: 8), + Text('Tous les utilisateurs', style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ), + // Liste des utilisateurs + ..._users.map((user) { + return DropdownMenuItem( + value: user.uid, + child: Row( + children: [ + CircleAvatar( + radius: 12, + backgroundImage: user.profilePhotoUrl.isNotEmpty + ? NetworkImage(user.profilePhotoUrl) + : null, + child: user.profilePhotoUrl.isEmpty + ? Text( + user.firstName.isNotEmpty + ? user.firstName[0].toUpperCase() + : '?', + style: const TextStyle(fontSize: 10), + ) + : null, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '${user.firstName} ${user.lastName}', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_equipment_tile.dart b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart index d72fbb0..6c2b163 100644 --- a/em2rp/lib/views/widgets/containers/container_equipment_tile.dart +++ b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart @@ -102,6 +102,8 @@ class ContainerEquipmentTile extends StatelessWidget { return 'Consommable'; case EquipmentCategory.cable: return 'Câble'; + case EquipmentCategory.vehicle: + return 'Véhicule'; case EquipmentCategory.other: return 'Autre'; } diff --git a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart index bf27573..65e9da6 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_associated_events_section.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; @@ -60,7 +61,7 @@ class _EquipmentAssociatedEventsSectionState containersWithEquipment.add(containerData['id'] as String); } } catch (e) { - print('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}: $e'); + DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e); } } @@ -83,7 +84,7 @@ class _EquipmentAssociatedEventsSectionState events.add(event); } } catch (e) { - print('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}: $e'); + DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}', e); } } diff --git a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart index cc3de8d..23a6b73 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_current_events_section.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/equipment_model.dart'; @@ -54,7 +55,7 @@ class _EquipmentCurrentEventsSectionState containersWithEquipment.add(containerData['id'] as String); } } catch (e) { - print('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}: $e'); + DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e); } } @@ -89,7 +90,7 @@ class _EquipmentCurrentEventsSectionState } } } catch (e) { - print('[EquipmentCurrentEventsSection] Error parsing event $eventData: $e'); + DebugLog.error('[EquipmentCurrentEventsSection] Error parsing event $eventData', e); } } diff --git a/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart b/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart index 85c11c4..3f5f492 100644 --- a/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart +++ b/em2rp/lib/views/widgets/equipment/equipment_status_badge.dart @@ -16,7 +16,7 @@ class EquipmentStatusBadge extends StatelessWidget { @override Widget build(BuildContext context) { final provider = Provider.of(context, listen: false); - print('[EquipmentStatusBadge] Building badge for ${equipment.id}'); + // Logs désactivés en production return FutureBuilder( // On calcule le statut réel de manière asynchrone @@ -26,7 +26,7 @@ class EquipmentStatusBadge extends StatelessWidget { builder: (context, snapshot) { // Utiliser le statut calculé s'il est disponible, sinon le statut stocké final status = snapshot.data ?? equipment.status; - print('[EquipmentStatusBadge] ${equipment.id} - Status: ${status.label} (hasData: ${snapshot.hasData}, connectionState: ${snapshot.connectionState})'); + // Logs désactivés en production return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), diff --git a/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart b/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart index 710c51c..d139e5c 100644 --- a/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart +++ b/em2rp/lib/views/widgets/equipment/parent_boxes_selector.dart @@ -1,3 +1,4 @@ +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'; @@ -26,22 +27,11 @@ class _ParentBoxesSelectorState extends State { @override void initState() { super.initState(); - print('[ParentBoxesSelector] initState'); - print('[ParentBoxesSelector] Available boxes: ${widget.availableBoxes.length}'); - print('[ParentBoxesSelector] Selected box IDs: ${widget.selectedBoxIds}'); - - // Log détaillé de chaque boîte - for (var box in widget.availableBoxes) { - print('[ParentBoxesSelector] Box - ID: ${box.id}, Name: ${box.name}'); - } } @override void didUpdateWidget(ParentBoxesSelector oldWidget) { super.didUpdateWidget(oldWidget); - print('[ParentBoxesSelector] didUpdateWidget'); - print('[ParentBoxesSelector] Old selected: ${oldWidget.selectedBoxIds}'); - print('[ParentBoxesSelector] New selected: ${widget.selectedBoxIds}'); } @override @@ -283,10 +273,10 @@ class _ParentBoxesSelectorState extends State { final box = filteredBoxes[index]; final isSelected = widget.selectedBoxIds.contains(box.id); if (index == 0) { - print('[ParentBoxesSelector] Building item $index'); - print('[ParentBoxesSelector] Box ID: ${box.id}'); - print('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}'); - print('[ParentBoxesSelector] Is selected: $isSelected'); + 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); }, diff --git a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart index 78d0dea..0d4e0f8 100644 --- a/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart +++ b/em2rp/lib/views/widgets/event/equipment_selection_dialog.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/models/equipment_model.dart'; @@ -88,12 +89,14 @@ class EquipmentSelectionDialog extends StatefulWidget { class _EquipmentSelectionDialogState extends State { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll final EventAvailabilityService _availabilityService = EventAvailabilityService(); final DataService _dataService = DataService(apiService); EquipmentCategory? _selectedCategory; Map _selectedItems = {}; + final ValueNotifier _selectionChangeNotifier = ValueNotifier(0); // Pour notifier les changements de sélection sans setState Map _availableQuantities = {}; // Pour consommables Map> _recommendedContainers = {}; // Recommandations Map> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés) @@ -108,8 +111,14 @@ class _EquipmentSelectionDialogState extends State { bool _isLoadingQuantities = false; bool _isLoadingConflicts = false; + bool _conflictsLoaded = false; // Flag pour éviter de recharger indéfiniment String _searchQuery = ''; + // Cache pour éviter les rebuilds inutiles + List _cachedContainers = []; + List _cachedEquipment = []; + bool _initialDataLoaded = false; + @override void initState() { super.initState(); @@ -186,18 +195,19 @@ class _EquipmentSelectionDialogState extends State { ); } } - } - } catch (e) { - print('[EquipmentSelectionDialog] Error loading already assigned containers: $e'); - } - } - - print('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items'); - } - + } + } catch (e) { + DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e); + } + } + + DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items'); + } @override void dispose() { _searchController.dispose(); + _scrollController.dispose(); // Nettoyer le ScrollController + _selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier super.dispose(); } @@ -226,7 +236,7 @@ class _EquipmentSelectionDialogState extends State { _availableQuantities[eq.id] = available; } } catch (e) { - print('Error loading quantities: $e'); + DebugLog.error('Error loading quantities', e); } finally { if (mounted) setState(() => _isLoadingQuantities = false); } @@ -238,7 +248,7 @@ class _EquipmentSelectionDialogState extends State { setState(() => _isLoadingConflicts = true); try { - print('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...'); + DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...'); final startTime = DateTime.now(); @@ -254,7 +264,7 @@ class _EquipmentSelectionDialogState extends State { final endTime = DateTime.now(); final duration = endTime.difference(startTime); - print('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms'); + DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms'); // Extraire les IDs en conflit final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List?) @@ -268,8 +278,8 @@ class _EquipmentSelectionDialogState extends State { final conflictDetails = result['conflictDetails'] as Map? ?? {}; final equipmentQuantities = result['equipmentQuantities'] as Map? ?? {}; - print('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict'); - print('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)'); + DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict'); + DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)'); if (mounted) { setState(() { @@ -277,6 +287,7 @@ class _EquipmentSelectionDialogState extends State { _conflictingContainerIds = conflictingContainerIds; _conflictDetails = conflictDetails; _equipmentQuantities = equipmentQuantities; + _conflictsLoaded = true; // Marquer comme chargé }); } @@ -284,7 +295,7 @@ class _EquipmentSelectionDialogState extends State { await _updateContainerConflictStatus(); } catch (e) { - print('[EquipmentSelectionDialog] Error loading conflicts: $e'); + DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e); } finally { if (mounted) setState(() => _isLoadingConflicts = false); } @@ -292,10 +303,14 @@ class _EquipmentSelectionDialogState extends State { /// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit Future _updateContainerConflictStatus() async { + if (!mounted) return; // Vérifier si le widget est toujours monté + try { final containerProvider = context.read(); final containers = await containerProvider.containersStream.first; + 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 if (_conflictingContainerIds.contains(container.id)) { @@ -323,13 +338,13 @@ class _EquipmentSelectionDialogState extends State { totalChildren: container.equipmentIds.length, ); - print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)'); + DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)'); } } - print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}'); + DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}'); } catch (e) { - print('[EquipmentSelectionDialog] Error updating container conflicts: $e'); + DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e); } } @@ -355,7 +370,7 @@ class _EquipmentSelectionDialogState extends State { if (availableQty == null) return const SizedBox.shrink(); return Text( - 'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}', + 'Disponible : $availableQty', style: TextStyle( color: availableQty > 0 ? Colors.green : Colors.red, fontWeight: FontWeight.w500, @@ -580,7 +595,7 @@ class _EquipmentSelectionDialogState extends State { }); } } catch (e) { - print('Error finding recommended containers: $e'); + DebugLog.error('Error finding recommended containers', e); } } @@ -602,26 +617,26 @@ class _EquipmentSelectionDialogState extends State { if (_selectedItems.containsKey(id)) { // Désélectionner - print('[EquipmentSelectionDialog] Deselecting $type: $id'); - print('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}'); + DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id'); + DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}'); if (type == SelectionType.container) { // Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone await _deselectContainerChildren(id); } - // Mise à jour sans setState pour éviter le flash + // Mise à jour sans setState - utiliser ValueNotifier pour notifier uniquement les cards concernées _selectedItems.remove(id); - print('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}'); - print('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}'); + DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}'); + DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}'); - // Forcer uniquement la reconstruction du panneau de sélection et de la card concernée - if (mounted) setState(() {}); + // Notifier le changement sans rebuilder toute la liste + _selectionChangeNotifier.value++; } else { // Sélectionner - print('[EquipmentSelectionDialog] Selecting $type: $id'); + DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id'); - // Mise à jour sans setState pour éviter le flash + // Mise à jour sans setState - utiliser ValueNotifier _selectedItems[id] = SelectedItem( id: id, name: name, @@ -639,8 +654,8 @@ class _EquipmentSelectionDialogState extends State { await _selectContainerChildren(id); } - // Forcer uniquement la reconstruction du panneau de sélection et de la card concernée - if (mounted) setState(() {}); + // Notifier le changement sans rebuilder toute la liste + _selectionChangeNotifier.value++; } } @@ -699,7 +714,7 @@ class _EquipmentSelectionDialogState extends State { } } } catch (e) { - print('Error selecting container children: $e'); + DebugLog.error('Error selecting container children', e); } } @@ -733,9 +748,9 @@ class _EquipmentSelectionDialogState extends State { // Retirer de la liste des conteneurs expandés _expandedContainers.remove(containerId); - print('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children'); + DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children'); } catch (e) { - print('Error deselecting container children: $e'); + DebugLog.error('Error deselecting container children', e); } } @@ -843,7 +858,12 @@ class _EquipmentSelectionDialogState extends State { ), child: Column( children: [ - Expanded(child: _buildSelectionPanel()), + Expanded( + child: ValueListenableBuilder( + valueListenable: _selectionChangeNotifier, + builder: (context, _, __) => _buildSelectionPanel(), + ), + ), if (_hasRecommendations) Container( height: 200, @@ -997,34 +1017,41 @@ class _EquipmentSelectionDialogState extends State { return _buildHierarchicalList(); } - /// Vue hiérarchique unique + /// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles Widget _buildHierarchicalList() { return Consumer2( builder: (context, containerProvider, equipmentProvider, child) { - return StreamBuilder>( - stream: containerProvider.containersStream, - builder: (context, containerSnapshot) { - return StreamBuilder>( - stream: equipmentProvider.equipmentStream, - builder: (context, equipmentSnapshot) { - if (containerSnapshot.connectionState == ConnectionState.waiting || - equipmentSnapshot.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } + // Charger les données initiales dans le cache si pas encore fait + if (!_initialDataLoaded) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _cachedContainers = containerProvider.containers; + _cachedEquipment = equipmentProvider.equipment; + _initialDataLoaded = true; + }); + } + }); + } - final allContainers = containerSnapshot.data ?? []; - final allEquipment = equipmentSnapshot.data ?? []; + // Utiliser les données du cache au lieu des streams + final allContainers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers; + final allEquipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.equipment; - // Charger les conflits une seule fois après le chargement des données - if (!_isLoadingConflicts && _conflictingEquipmentIds.isEmpty && allEquipment.isNotEmpty) { - // Lancer le chargement des conflits en arrière-plan - WidgetsBinding.instance.addPostFrameCallback((_) { - _loadEquipmentConflicts(); - }); - } + // Charger les conflits une seule fois après le chargement des données + if (!_isLoadingConflicts && !_conflictsLoaded && allEquipment.isNotEmpty) { + // Lancer le chargement des conflits en arrière-plan + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadEquipmentConflicts(); + }); + } - // Filtrage des boîtes - final filteredContainers = allContainers.where((container) { + // Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection + return ValueListenableBuilder( + valueListenable: _selectionChangeNotifier, + builder: (context, _, __) { + // Filtrage des boîtes + final filteredContainers = allContainers.where((container) { if (_searchQuery.isNotEmpty) { final searchLower = _searchQuery.toLowerCase(); return container.id.toLowerCase().contains(searchLower) || @@ -1052,7 +1079,9 @@ class _EquipmentSelectionDialogState extends State { }).toList(); return ListView( + controller: _scrollController, // Préserve la position de scroll padding: const EdgeInsets.all(16), + cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll children: [ // SECTION 1 : BOÎTES if (filteredContainers.isNotEmpty) ...[ @@ -1094,10 +1123,8 @@ class _EquipmentSelectionDialogState extends State { ), ], ); - }, - ); }, - ); + ); // Fin du ValueListenableBuilder }, ); } @@ -1304,9 +1331,19 @@ class _EquipmentSelectionDialogState extends State { ), ), - // Sélecteur de quantité pour consommables - if (isSelected && isConsumable && availableQty != null) - _buildQuantitySelector(equipment.id, selectedItem!, availableQty), + // Sélecteur de quantité pour consommables (toujours affiché) + if (isConsumable && availableQty != null) + _buildQuantitySelector( + equipment.id, + selectedItem ?? SelectedItem( + id: equipment.id, + name: equipment.id, + type: SelectionType.equipment, + quantity: 0, // Quantité 0 si non sélectionné + ), + availableQty, + isSelected: isSelected, // Passer l'état de sélection + ), ], ), @@ -1414,40 +1451,62 @@ class _EquipmentSelectionDialogState extends State { ); } - /// Widget pour le sélecteur de quantité (sans setState global pour éviter le refresh) - Widget _buildQuantitySelector(String equipmentId, SelectedItem selectedItem, int maxQuantity) { + /// Widget pour le sélecteur de quantité + /// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1 + Widget _buildQuantitySelector( + String equipmentId, + SelectedItem selectedItem, + int maxQuantity, { + required bool isSelected, + }) { + final displayQuantity = isSelected ? selectedItem.quantity : 0; + return Container( width: 120, child: Row( children: [ IconButton( icon: const Icon(Icons.remove_circle_outline), - onPressed: selectedItem.quantity > 1 + onPressed: isSelected && selectedItem.quantity > 1 ? () { - setState(() { - _selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1); - }); + _selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1); + _selectionChangeNotifier.value++; // Notifier sans rebuild complet } : null, iconSize: 20, + color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey, ), Expanded( child: Text( - '${selectedItem.quantity}', + '$displayQuantity', textAlign: TextAlign.center, - style: const TextStyle(fontWeight: FontWeight.bold), + style: TextStyle( + fontWeight: FontWeight.bold, + color: isSelected ? Colors.black : Colors.grey, + ), ), ), IconButton( icon: const Icon(Icons.add_circle_outline), - onPressed: selectedItem.quantity < maxQuantity + onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected ? () { - setState(() { + if (!isSelected) { + // Premier clic : sélectionner avec quantité 1 + _toggleSelection( + equipmentId, + selectedItem.name, + SelectionType.equipment, + maxQuantity: maxQuantity, + ); + } else { + // Item déjà sélectionné : incrémenter _selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1); - }); + _selectionChangeNotifier.value++; // Notifier sans rebuild complet + } } : null, iconSize: 20, + color: AppColors.rouge, ), ], ), @@ -1635,13 +1694,12 @@ class _EquipmentSelectionDialogState extends State { color: AppColors.rouge, ), onPressed: () { - setState(() { - if (isExpanded) { - _expandedContainers.remove(container.id); - } else { - _expandedContainers.add(container.id); - } - }); + if (isExpanded) { + _expandedContainers.remove(container.id); + } else { + _expandedContainers.add(container.id); + } + _selectionChangeNotifier.value++; // Notifier sans rebuild complet }, tooltip: isExpanded ? 'Replier' : 'Voir le contenu', ), @@ -1996,13 +2054,12 @@ class _EquipmentSelectionDialogState extends State { size: 18, ), onPressed: () { - setState(() { - if (isExpanded) { - _expandedContainers.remove(id); - } else { - _expandedContainers.add(id); - } - }); + if (isExpanded) { + _expandedContainers.remove(id); + } else { + _expandedContainers.add(id); + } + _selectionChangeNotifier.value++; // Notifier sans rebuild complet }, ), IconButton( diff --git a/em2rp/lib/views/widgets/event/optimized_equipment_card.dart b/em2rp/lib/views/widgets/event/optimized_equipment_card.dart new file mode 100644 index 0000000..826b018 --- /dev/null +++ b/em2rp/lib/views/widgets/event/optimized_equipment_card.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart'; + +/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire +class OptimizedEquipmentCard extends StatefulWidget { + final EquipmentModel equipment; + final bool isSelected; + final int? selectedQuantity; + final bool hasConflict; + final String? conflictMessage; + final int? availableQuantity; + final VoidCallback onTap; + final Function(int)? onQuantityChanged; + + const OptimizedEquipmentCard({ + super.key, + required this.equipment, + required this.isSelected, + this.selectedQuantity, + required this.hasConflict, + this.conflictMessage, + this.availableQuantity, + required this.onTap, + this.onQuantityChanged, + }); + + @override + State createState() => _OptimizedEquipmentCardState(); +} + +class _OptimizedEquipmentCardState extends State { + @override + Widget build(BuildContext context) { + // Le contenu de la card sera ici + // Pour l'instant, retournons juste un placeholder + return Card( + key: ValueKey('equipment_${widget.equipment.id}'), + child: ListTile( + title: Text(widget.equipment.id), + subtitle: Text('${widget.equipment.brand} - ${widget.equipment.model}'), + trailing: widget.isSelected ? Icon(Icons.check_circle, color: Colors.green) : null, + onTap: widget.onTap, + ), + ); + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is OptimizedEquipmentCard && + runtimeType == other.runtimeType && + widget.equipment.id == other.equipment.id && + widget.isSelected == other.isSelected && + widget.selectedQuantity == other.selectedQuantity && + widget.hasConflict == other.hasConflict; + + @override + int get hashCode => + widget.equipment.id.hashCode ^ + widget.isSelected.hashCode ^ + widget.selectedQuantity.hashCode ^ + widget.hasConflict.hashCode; +} + diff --git a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart index 856e9ca..c3f1d13 100644 --- a/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_assigned_equipment_section.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/models/event_model.dart'; @@ -137,7 +138,7 @@ class _EventAssignedEquipmentSectionState extends State _processSelection(Map selection) async { - print('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items'); + DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items'); // Séparer équipements et conteneurs final newEquipment = []; @@ -154,7 +155,7 @@ class _EventAssignedEquipmentSectionState extends State(); @@ -163,13 +164,13 @@ class _EventAssignedEquipmentSectionState extends State>{}; // 1. Vérifier les conflits pour les équipements directs - print('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)'); + DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)'); for (var eq in newEquipment) { - print('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}'); + DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}'); final equipment = allEquipment.firstWhere( (e) => e.id == eq.equipmentId, @@ -185,7 +186,7 @@ class _EventAssignedEquipmentSectionState extends State c.id == containerId, @@ -305,37 +306,37 @@ class _EventAssignedEquipmentSectionState extends State( context: context, builder: (context) => EquipmentConflictDialog(conflicts: allConflicts), ); - print('[EventAssignedEquipmentSection] Conflict dialog result: $action'); - + 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) { @@ -363,15 +364,15 @@ class _EventAssignedEquipmentSectionState extends State e.equipmentId == eq.equipmentId); - + if (existingIndex != -1) { // L'équipement existe déjà : mettre à jour la quantité updatedEquipment[existingIndex] = EventEquipment( @@ -386,19 +387,19 @@ class _EventAssignedEquipmentSectionState extends State onAnyFieldChanged(), + PriceHtTtcFields( + basePriceController: basePriceController, + onPriceChanged: onAnyFieldChanged, ), ], ); diff --git a/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart b/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart index dd2ecdf..e12d6b4 100644 --- a/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart +++ b/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart @@ -1,6 +1,8 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/price_helpers.dart'; import 'package:em2rp/services/data_service.dart'; import 'package:em2rp/services/api_service.dart'; @@ -62,10 +64,14 @@ class EventOptionsDisplayWidget extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ...enrichedOptions.map((opt) { - final price = (opt['price'] ?? 0.0) as num; + final priceTTC = (opt['price'] ?? 0.0) as num; final quantity = (opt['quantity'] ?? 1) as int; - final totalPrice = price * quantity; - final isNegative = totalPrice < 0; + final totalPriceTTC = priceTTC * quantity; + final isNegative = totalPriceTTC < 0; + + // Calculer le prix HT + final priceHT = PriceHelpers.calculateHT(priceTTC.toDouble()); + final totalPriceHT = priceHT * quantity; return ListTile( leading: Icon(Icons.tune, color: AppColors.rouge), @@ -98,28 +104,64 @@ class EventOptionsDisplayWidget extends StatelessWidget { fontStyle: FontStyle.italic, ), ), - if (quantity > 1 && canViewPrices) + if (quantity > 1 && canViewPrices) ...[ Padding( padding: const EdgeInsets.only(top: 4.0), child: Text( - '${currencyFormat.format(price)} × $quantity', + 'HT: ${currencyFormat.format(priceHT)} × $quantity', style: TextStyle( color: Colors.grey[700], - fontSize: 12, - fontWeight: FontWeight.w500, + fontSize: 11, + fontWeight: FontWeight.w400, ), ), ), + Text( + 'TTC: ${currencyFormat.format(priceTTC)} × $quantity', + style: TextStyle( + color: Colors.grey[700], + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ] else if (canViewPrices) ...[ + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Text( + 'HT: ${currencyFormat.format(priceHT)}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + fontStyle: FontStyle.italic, + ), + ), + ), + ], ], ), trailing: canViewPrices - ? Text( - (isNegative ? '- ' : '+ ') + - currencyFormat.format(totalPrice.abs()), - style: TextStyle( - color: isNegative ? Colors.red : AppColors.noir, - fontWeight: FontWeight.bold, - ), + ? Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + (isNegative ? '- ' : '+ ') + + currencyFormat.format(totalPriceTTC.abs()), + style: TextStyle( + color: isNegative ? Colors.red : AppColors.noir, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + Text( + 'HT: ${currencyFormat.format(totalPriceHT.abs())}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + fontStyle: FontStyle.italic, + ), + ), + ], ) : null, contentPadding: EdgeInsets.zero, @@ -139,31 +181,71 @@ class EventOptionsDisplayWidget extends StatelessWidget { } Widget _buildTotalPrice(BuildContext context, List> options, NumberFormat currencyFormat) { - final optionsTotal = options.fold(0, (sum, opt) { - final price = opt['price'] ?? 0.0; + final optionsTotalTTC = options.fold(0, (sum, opt) { + final priceTTC = opt['price'] ?? 0.0; final quantity = opt['quantity'] ?? 1; - return sum + (price * quantity); + return sum + (priceTTC * quantity); }); + // Calculer le total HT + final optionsTotalHT = PriceHelpers.calculateHT(optionsTotalTTC.toDouble()); + final optionsTVA = PriceHelpers.calculateTax(optionsTotalHT); + return Padding( padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon(Icons.tune, color: AppColors.rouge), - const SizedBox(width: 8), - Text( - 'Total options : ', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, + Row( + children: [ + const Icon(Icons.tune, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + Text( + 'Total options HT : ', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.w600, + ), + ), + Text( + currencyFormat.format(optionsTotalHT), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(left: 28.0, top: 2.0), + child: Text( + 'TVA (20%) : ${currencyFormat.format(optionsTVA)}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), ), ), - Text( - currencyFormat.format(optionsTotal), - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppColors.rouge, - fontWeight: FontWeight.bold, - ), + Row( + children: [ + const Icon(Icons.attach_money, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + Text( + 'Total options TTC : ', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + Text( + currencyFormat.format(optionsTotalTTC), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + ], ), ], ), @@ -224,7 +306,7 @@ class EventOptionsDisplayWidget extends StatelessWidget { }); } } catch (e) { - print('Erreur lors du chargement de l\'option ${optionData['id']}: $e'); + DebugLog.error('Erreur lors du chargement de l\'option ${optionData['id']}', e); // En cas d'erreur, créer une entrée avec les données disponibles enrichedOptions.add({ 'id': optionData['id'], diff --git a/em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart b/em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart new file mode 100644 index 0000000..b997a7e --- /dev/null +++ b/em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart @@ -0,0 +1,235 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:em2rp/utils/price_helpers.dart'; + +/// Widget pour gérer les prix HT et TTC avec calcul automatique +/// Permet de saisir soit le prix HT, soit le prix TTC, l'autre est calculé automatiquement +class PriceHtTtcFields extends StatefulWidget { + final TextEditingController basePriceController; + final VoidCallback onPriceChanged; + final double taxRate; + + const PriceHtTtcFields({ + super.key, + required this.basePriceController, + required this.onPriceChanged, + this.taxRate = 0.20, + }); + + @override + State createState() => _PriceHtTtcFieldsState(); +} + +class _PriceHtTtcFieldsState extends State { + final TextEditingController _htController = TextEditingController(); + final TextEditingController _ttcController = TextEditingController(); + bool _updatingFromHT = false; + bool _updatingFromTTC = false; + + @override + void initState() { + super.initState(); + // Initialiser avec la valeur existante (considérée comme TTC) + if (widget.basePriceController.text.isNotEmpty) { + _ttcController.text = widget.basePriceController.text; + _updateHTFromTTC(); + } + + // Synchroniser basePriceController avec TTC + _htController.addListener(_onHTChanged); + _ttcController.addListener(_onTTCChanged); + + // Écouter les changements externes du basePriceController (ex: changement de type d'événement) + widget.basePriceController.addListener(_onBasePriceControllerChanged); + } + + @override + void dispose() { + _htController.removeListener(_onHTChanged); + _ttcController.removeListener(_onTTCChanged); + widget.basePriceController.removeListener(_onBasePriceControllerChanged); + _htController.dispose(); + _ttcController.dispose(); + super.dispose(); + } + + /// Appelé quand basePriceController change de l'extérieur (ex: sélection type d'événement) + void _onBasePriceControllerChanged() { + // Éviter la boucle infinie si le changement vient de nous + if (_updatingFromHT || _updatingFromTTC) return; + + final newTTCText = widget.basePriceController.text; + + // Si le texte est différent de ce qu'on a dans _ttcController, mettre à jour + if (newTTCText != _ttcController.text) { + _ttcController.text = newTTCText; + if (newTTCText.isNotEmpty) { + _updateHTFromTTC(); + } else { + _htController.clear(); + } + } + } + + void _onHTChanged() { + if (_updatingFromTTC) return; + + final htText = _htController.text.replaceAll(',', '.'); + final htValue = double.tryParse(htText); + + if (htValue != null) { + _updatingFromHT = true; + final ttcValue = PriceHelpers.calculateTTC(htValue, taxRate: widget.taxRate); + _ttcController.text = ttcValue.toStringAsFixed(2); + + // Mettre à jour basePriceController (qui stocke le prix TTC) + widget.basePriceController.text = ttcValue.toStringAsFixed(2); + widget.onPriceChanged(); + + _updatingFromHT = false; + } else if (htText.isEmpty) { + _ttcController.clear(); + widget.basePriceController.clear(); + widget.onPriceChanged(); + } + } + + void _onTTCChanged() { + if (_updatingFromHT) return; + + final ttcText = _ttcController.text.replaceAll(',', '.'); + final ttcValue = double.tryParse(ttcText); + + if (ttcValue != null) { + _updatingFromTTC = true; + final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate); + _htController.text = htValue.toStringAsFixed(2); + + // Mettre à jour basePriceController (qui stocke le prix TTC) + widget.basePriceController.text = ttcValue.toStringAsFixed(2); + widget.onPriceChanged(); + + _updatingFromTTC = false; + } else if (ttcText.isEmpty) { + _htController.clear(); + widget.basePriceController.clear(); + widget.onPriceChanged(); + } + } + + void _updateHTFromTTC() { + final ttcText = _ttcController.text.replaceAll(',', '.'); + final ttcValue = double.tryParse(ttcText); + + if (ttcValue != null) { + final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate); + _htController.text = htValue.toStringAsFixed(2); + } + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Prix', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 8), + Row( + children: [ + // Champ Prix HT + Expanded( + child: TextFormField( + controller: _htController, + decoration: InputDecoration( + labelText: 'Prix HT (€)*', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.euro), + hintText: '1000.00', + helperText: 'Hors taxes', + helperStyle: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Requis'; + } + final price = double.tryParse(value.replaceAll(',', '.')); + if (price == null) { + return 'Nombre invalide'; + } + return null; + }, + ), + ), + const SizedBox(width: 16), + // Champ Prix TTC + Expanded( + child: TextFormField( + controller: _ttcController, + decoration: InputDecoration( + labelText: 'Prix TTC (€)*', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.attach_money), + hintText: '1200.00', + helperText: 'Toutes taxes comprises', + helperStyle: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ], + validator: (value) { + if (value == null || value.isEmpty) { + return 'Requis'; + } + final price = double.tryParse(value.replaceAll(',', '.')); + if (price == null) { + return 'Nombre invalide'; + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 4), + // Affichage du montant de TVA (en plus petit) + Builder( + builder: (context) { + final htText = _htController.text.replaceAll(',', '.'); + final htValue = double.tryParse(htText); + + if (htValue != null) { + final taxAmount = PriceHelpers.calculateTax(htValue, taxRate: widget.taxRate); + return Padding( + padding: const EdgeInsets.only(left: 8.0, top: 4.0), + child: Text( + 'TVA (${(widget.taxRate * 100).toStringAsFixed(0)}%) : ${taxAmount.toStringAsFixed(2)} €', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + fontStyle: FontStyle.italic, + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ); + } +} + diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart index b5c7552..480b173 100644 --- a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -1,3 +1,4 @@ +import 'package:em2rp/utils/debug_log.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/models/option_model.dart'; import 'package:em2rp/services/data_service.dart'; @@ -379,37 +380,37 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { @override Widget build(BuildContext context) { // Debug: Afficher les informations de filtrage - print('=== DEBUG OptionPickerDialog ==='); - print('widget.eventType: ${widget.eventType}'); - print('_currentOptions.length: ${_currentOptions.length}'); + DebugLog.info('=== DEBUG OptionPickerDialog ==='); + DebugLog.info('widget.eventType: ${widget.eventType}'); + DebugLog.info('_currentOptions.length: ${_currentOptions.length}'); final filtered = _currentOptions.where((opt) { - print('Option: ${opt.name}'); - print(' opt.eventTypes: ${opt.eventTypes}'); - print(' widget.eventType: ${widget.eventType}'); + DebugLog.info('Option: ${opt.name}'); + DebugLog.info(' opt.eventTypes: ${opt.eventTypes}'); + DebugLog.info(' widget.eventType: ${widget.eventType}'); if (widget.eventType == null) { - print(' -> Filtered out: eventType is null'); + DebugLog.info(' -> Filtered out: eventType is null'); return false; } final matchesType = opt.eventTypes.contains(widget.eventType); - print(' -> matchesType: $matchesType'); + DebugLog.info(' -> matchesType: $matchesType'); // Recherche dans le code ET le nom final searchLower = _search.toLowerCase(); final matchesSearch = opt.name.toLowerCase().contains(searchLower) || opt.code.toLowerCase().contains(searchLower); - print(' -> matchesSearch: $matchesSearch'); + DebugLog.info(' -> matchesSearch: $matchesSearch'); final result = matchesType && matchesSearch; - print(' -> Final result: $result'); + DebugLog.info(' -> Final result: $result'); return result; }).toList(); - print('Filtered options count: ${filtered.length}'); - print('==========================='); + DebugLog.info('Filtered options count: ${filtered.length}'); + DebugLog.info('==========================='); return Dialog( child: SizedBox(