From 4128ddc34ae4217b44a760ffbf549fc91b2ffc07 Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Fri, 10 Oct 2025 19:20:38 +0200 Subject: [PATCH] =?UTF-8?q?Modif=20de=20l'affichage=20des=20donn=C3=A9es?= =?UTF-8?q?=20d'un=20=C3=A9v=C3=A9nement=20et=20de=20l'afichage=20de=20la?= =?UTF-8?q?=20cr=C3=A9ation/=C3=A9dition=20Options=20sont=20maintenant=20g?= =?UTF-8?q?=C3=A9res=20dans=20firebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- em2rp/lib/config/env.dart | 4 +- .../controllers/event_form_controller.dart | 33 ++ em2rp/lib/main.dart | 1 - em2rp/lib/models/event_model.dart | 169 ++++++--- em2rp/lib/models/option_model.dart | 4 +- em2rp/lib/providers/event_provider.dart | 62 +++- em2rp/lib/views/event_add_page.dart | 65 +++- .../calendar_widgets/event_details.dart | 174 +++++----- .../event_form/event_basic_info_section.dart | 53 +-- .../event_form/event_details_section.dart | 4 +- .../event_form/event_form_actions.dart | 54 ++- .../event_options_display_widget.dart | 202 +++++++++++ .../inputs/option_selector_widget.dart | 324 ++++++++++++------ 13 files changed, 850 insertions(+), 299 deletions(-) create mode 100644 em2rp/lib/views/widgets/event_form/event_options_display_widget.dart diff --git a/em2rp/lib/config/env.dart b/em2rp/lib/config/env.dart index 6d88247..f6c470d 100644 --- a/em2rp/lib/config/env.dart +++ b/em2rp/lib/config/env.dart @@ -1,10 +1,10 @@ class Env { - static const bool isDevelopment = false; + static const bool isDevelopment = true; // Configuration de l'auto-login en développement static const String devAdminEmail = 'paul.fournel@em2events.fr'; static const String devAdminPassword = - "Azerty\$1!"; // À remplacer par le vrai mot de passe + "Pastis51!"; // À remplacer par le vrai mot de passe // URLs et endpoints static const String baseUrl = 'https://em2rp-951dc.firebaseapp.com'; diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 951fe56..984debf 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -363,6 +363,39 @@ class EventFormController extends ChangeNotifier { } } + Future deleteEvent(BuildContext context, String eventId) async { + _isLoading = true; + _error = null; + _success = null; + notifyListeners(); + + try { + // Supprimer l'événement de Firestore + await FirebaseFirestore.instance.collection('events').doc(eventId).delete(); + + // Recharger la liste des événements + final localUserProvider = Provider.of(context, listen: false); + final eventProvider = Provider.of(context, listen: false); + final userId = localUserProvider.uid; + final canViewAllEvents = localUserProvider.hasPermission('view_all_events'); + + if (userId != null) { + await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents); + } + + _success = "Événement supprimé avec succès !"; + notifyListeners(); + return true; + } catch (e) { + _error = "Erreur lors de la suppression : $e"; + notifyListeners(); + return false; + } finally { + _isLoading = false; + notifyListeners(); + } + } + void clearError() { _error = null; notifyListeners(); diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 203e4e3..66043aa 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -54,7 +54,6 @@ class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { - print("test"); return MaterialApp( title: 'EM2 ERP', theme: ThemeData( diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index c521d12..76f8b8b 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -72,62 +72,129 @@ class EventModel { }); factory EventModel.fromMap(Map map, String id) { - final List workforceRefs = map['workforce'] ?? []; - final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?; - final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?; + try { + // Gestion sécurisée des références workforce + final List workforceRefs = map['workforce'] ?? []; + final List safeWorkforce = []; - final docsRaw = map['documents'] ?? []; - final docs = docsRaw is List - ? docsRaw.map>((e) { + for (var ref in workforceRefs) { + if (ref is DocumentReference) { + safeWorkforce.add(ref); + } else { + print('Warning: Invalid workforce reference in event $id: $ref'); + } + } + + // Gestion sécurisée des timestamps + final Timestamp? startTimestamp = map['StartDateTime'] as Timestamp?; + final Timestamp? endTimestamp = map['EndDateTime'] as Timestamp?; + + final DateTime startDate = startTimestamp?.toDate() ?? DateTime.now(); + final DateTime endDate = endTimestamp?.toDate() ?? + startDate.add(const Duration(hours: 1)); + + // Gestion sécurisée des documents + final docsRaw = map['documents'] ?? []; + final List> docs = []; + + if (docsRaw is List) { + for (var e in docsRaw) { + try { if (e is Map) { - return Map.from(e); + docs.add(Map.from(e)); } else if (e is String) { final fileName = Uri.decodeComponent( e.split('/').last.split('?').first, ); - return {'name': fileName, 'url': e}; - } else { - return {}; + docs.add({'name': fileName, 'url': e}); } - }).toList() - : >[]; - final optionsRaw = map['options'] ?? []; - final options = optionsRaw is List - ? optionsRaw.map>((e) { + } catch (docError) { + print('Warning: Failed to parse document in event $id: $docError'); + } + } + } + + // Gestion sécurisée des options + final optionsRaw = map['options'] ?? []; + final List> options = []; + + if (optionsRaw is List) { + for (var e in optionsRaw) { + try { if (e is Map) { - return Map.from(e); - } else { - return {}; + options.add(Map.from(e)); } - }).toList() - : >[]; - return EventModel( - id: id, - name: map['Name'] ?? '', - description: map['Description'] ?? '', - startDateTime: startTimestamp?.toDate() ?? DateTime.now(), - endDateTime: endTimestamp?.toDate() ?? - DateTime.now().add(const Duration(hours: 1)), - basePrice: (map['BasePrice'] ?? map['Price'] ?? 0.0).toDouble(), - installationTime: map['InstallationTime'] ?? 0, - disassemblyTime: map['DisassemblyTime'] ?? 0, - eventTypeId: map['EventType'] is DocumentReference - ? (map['EventType'] as DocumentReference).id - : map['EventType'] ?? '', - eventTypeRef: map['EventType'] is DocumentReference - ? map['EventType'] as DocumentReference - : null, - customerId: map['customer'] is DocumentReference - ? (map['customer'] as DocumentReference).id - : '', - address: map['Address'] ?? '', - latitude: (map['Latitude'] ?? 0.0).toDouble(), - longitude: (map['Longitude'] ?? 0.0).toDouble(), - workforce: workforceRefs.whereType().toList(), - documents: docs, - options: options, - status: eventStatusFromString(map['status'] as String?), - ); + } catch (optionError) { + print('Warning: Failed to parse option in event $id: $optionError'); + } + } + } + + // Gestion sécurisée de l'EventType + String eventTypeId = ''; + DocumentReference? eventTypeRef; + + if (map['EventType'] is DocumentReference) { + eventTypeRef = map['EventType'] as DocumentReference; + eventTypeId = eventTypeRef.id; + } else if (map['EventType'] is String) { + eventTypeId = map['EventType'] as String; + } + + // Gestion sécurisée du customer + String customerId = ''; + if (map['customer'] is DocumentReference) { + customerId = (map['customer'] as DocumentReference).id; + } else if (map['customer'] is String) { + customerId = map['customer'] as String; + } + + return EventModel( + id: id, + name: (map['Name'] ?? '').toString().trim(), + description: (map['Description'] ?? '').toString(), + startDateTime: startDate, + endDateTime: endDate, + basePrice: _parseDouble(map['BasePrice'] ?? map['Price'] ?? 0.0), + installationTime: _parseInt(map['InstallationTime'] ?? 0), + disassemblyTime: _parseInt(map['DisassemblyTime'] ?? 0), + eventTypeId: eventTypeId, + eventTypeRef: eventTypeRef, + customerId: customerId, + address: (map['Address'] ?? '').toString(), + latitude: _parseDouble(map['Latitude'] ?? 0.0), + longitude: _parseDouble(map['Longitude'] ?? 0.0), + workforce: safeWorkforce, + documents: docs, + options: options, + status: eventStatusFromString(map['status'] as String?), + ); + } catch (e) { + print('Error parsing event $id: $e'); + print('Event data: $map'); + rethrow; + } + } + + // Méthodes utilitaires pour le parsing sécurisé + static double _parseDouble(dynamic value) { + if (value is double) return value; + if (value is int) return value.toDouble(); + if (value is String) { + final parsed = double.tryParse(value); + if (parsed != null) return parsed; + } + return 0.0; + } + + static int _parseInt(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) { + final parsed = int.tryParse(value); + if (parsed != null) return parsed; + } + return 0; } Map toMap() { @@ -139,8 +206,12 @@ 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, + 'EventType': eventTypeId.isNotEmpty + ? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId) + : null, + 'customer': customerId.isNotEmpty + ? FirebaseFirestore.instance.collection('customers').doc(customerId) + : null, 'Address': address, 'Position': GeoPoint(latitude, longitude), 'Latitude': latitude, diff --git a/em2rp/lib/models/option_model.dart b/em2rp/lib/models/option_model.dart index ccf4e88..ca38f8d 100644 --- a/em2rp/lib/models/option_model.dart +++ b/em2rp/lib/models/option_model.dart @@ -6,7 +6,7 @@ class EventOption { final String details; final double valMin; final double valMax; - final List eventTypes; + final List eventTypes; // Changé de List à List EventOption({ required this.id, @@ -25,7 +25,7 @@ class EventOption { valMin: (map['valMin'] ?? 0.0).toDouble(), valMax: (map['valMax'] ?? 0.0).toDouble(), eventTypes: (map['eventTypes'] as List? ?? []) - .whereType() + .map((e) => e.toString()) // Convertit en String (supporte IDs et références) .toList(), ); } diff --git a/em2rp/lib/providers/event_provider.dart b/em2rp/lib/providers/event_provider.dart index 6725f47..8df344a 100644 --- a/em2rp/lib/providers/event_provider.dart +++ b/em2rp/lib/providers/event_provider.dart @@ -19,33 +19,61 @@ class EventProvider with ChangeNotifier { try { print( 'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); - QuerySnapshot eventsSnapshot; - // On charge tous les events pour les users non-admins aussi - eventsSnapshot = await _firestore.collection('events').get(); - print('Found ${eventsSnapshot.docs.length} events for user'); + QuerySnapshot eventsSnapshot = await _firestore.collection('events').get(); + print('Found ${eventsSnapshot.docs.length} events total'); - // On filtre côté client si l'utilisateur n'est pas admin - final allEvents = eventsSnapshot.docs.map((doc) { - print('Event data: ${doc.data()}'); - return EventModel.fromMap(doc.data() as Map, doc.id); - }).toList(); - if (canViewAllEvents) { - _events = allEvents; - } else { - final userRef = _firestore.collection('users').doc(userId); - _events = allEvents - .where((e) => e.workforce.any((ref) => ref.id == userRef.id)) - .toList(); + List allEvents = []; + int failedCount = 0; + + // Parser chaque événement individuellement pour éviter qu'une erreur interrompe tout + for (var doc in eventsSnapshot.docs) { + try { + final data = doc.data() as Map; + print('Processing event ${doc.id}: ${data['Name'] ?? 'Unknown'}'); + + final event = EventModel.fromMap(data, doc.id); + allEvents.add(event); + } catch (e) { + print('Failed to parse event ${doc.id}: $e'); + failedCount++; + // Continue avec les autres événements au lieu d'arrêter + } } - print('Parsed ${_events.length} events'); + print('Successfully parsed ${allEvents.length} events, failed: $failedCount'); + + // Filtrage amélioré pour les utilisateurs non-admin + if (canViewAllEvents) { + _events = allEvents; + print('Admin user: showing all ${_events.length} events'); + } else { + // Créer la référence utilisateur correctement + final userDocRef = _firestore.collection('users').doc(userId); + + _events = allEvents.where((event) { + // Vérifier si l'utilisateur est dans l'équipe de l'événement + bool isInWorkforce = event.workforce.any((workforceRef) { + return workforceRef.path == userDocRef.path; + }); + + if (isInWorkforce) { + print('Event ${event.name} includes user ${userId}'); + } + + return isInWorkforce; + }).toList(); + + print('Non-admin user: showing ${_events.length} events out of ${allEvents.length}'); + } _isLoading = false; notifyListeners(); + } catch (e) { print('Error loading events: $e'); _isLoading = false; + _events = []; // S'assurer que la liste est vide en cas d'erreur notifyListeners(); rethrow; } diff --git a/em2rp/lib/views/event_add_page.dart b/em2rp/lib/views/event_add_page.dart index 4690814..07b2183 100644 --- a/em2rp/lib/views/event_add_page.dart +++ b/em2rp/lib/views/event_add_page.dart @@ -78,6 +78,64 @@ class _EventAddEditPageState extends State { } } + Future _deleteEvent() async { + if (widget.event == null) return; + + final shouldDelete = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Supprimer l\'événement'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Êtes-vous sûr de vouloir supprimer cet événement ?'), + const SizedBox(height: 8), + Text( + 'Nom : ${widget.event!.name}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 16), + const Text( + 'Cette action est irréversible.', + style: TextStyle( + color: Colors.red, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (shouldDelete == true) { + final success = await _controller.deleteEvent(context, widget.event!.id); + if (success && mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Événement supprimé avec succès'), + backgroundColor: Colors.green, + ), + ); + Navigator.of(context).pop(); + } + } + } + @override Widget build(BuildContext context) { final isMobile = MediaQuery.of(context).size.width < 600; @@ -153,12 +211,12 @@ class _EventAddEditPageState extends State { ), const SizedBox(height: 16), OptionSelectorWidget( - eventType: selectedEventTypeName, + eventType: controller.selectedEventTypeId, // Utilise l'ID au lieu du nom selectedOptions: controller.selectedOptions, onChanged: controller.setSelectedOptions, - onRemove: (name) { + onRemove: (optionId) { final newOptions = List>.from(controller.selectedOptions); - newOptions.removeWhere((o) => o['name'] == name); + newOptions.removeWhere((o) => o['id'] == optionId); controller.setSelectedOptions(newOptions); }, eventTypeRequired: controller.selectedEventTypeId == null, @@ -215,6 +273,7 @@ class _EventAddEditPageState extends State { onSubmit: _submit, onSetConfirmed: !isEditMode ? () { } : null, + onDelete: isEditMode ? _deleteEvent : null, // Ajout du callback de suppression ), ], ), diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart index 49fb76a..8dfdc38 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -13,6 +13,7 @@ import 'package:em2rp/views/widgets/user_management/user_card.dart'; import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; import 'package:em2rp/views/event_add_page.dart'; +import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart'; class EventDetails extends StatelessWidget { final EventModel event; @@ -87,23 +88,29 @@ class EventDetails extends StatelessWidget { ), const SizedBox(height: 16), Row( - crossAxisAlignment: CrossAxisAlignment.start, // Optionnel mais recommandé pour bien aligner + //Titre de l'événement + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // On remplace le SelectableText par une Column - Expanded( // Utiliser Expanded pour que le texte ne déborde pas + Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, // Aligne les textes à gauche + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 1. Votre titre original - SelectableText( - event.name, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + event.name, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(width: 12), + _buildStatusIcon(event.status), + ], ), - ), - const SizedBox(height: 4), + //Type d'événement Text( event.eventTypeId, style: Theme.of(context).textTheme.bodyMedium?.copyWith( @@ -113,8 +120,8 @@ class EventDetails extends StatelessWidget { ], ), ), - const SizedBox(width: 12), - _buildStatusIcon(event.status), + // Statut de l'événement + const SizedBox(width: 8), Spacer(), if (Provider.of(context, listen: false) @@ -168,13 +175,13 @@ class EventDetails extends StatelessWidget { _buildInfoRow( context, Icons.calendar_today, - 'Date de début', + 'Horaire de début', dateFormat.format(event.startDateTime), ), _buildInfoRow( context, Icons.calendar_today, - 'Date de fin', + 'Horaire de fin', dateFormat.format(event.endDateTime), ), if (canViewPrices) @@ -185,82 +192,50 @@ class EventDetails extends StatelessWidget { currencyFormat.format(event.basePrice), ), if (event.options.isNotEmpty) ...[ - const SizedBox(height: 8), - Text('Options sélectionnées', - style: - Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - )), - const SizedBox(height: 4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: event.options.map((opt) { - final price = (opt['price'] ?? 0.0) as num; - final isNegative = price < 0; - return ListTile( - leading: Icon(Icons.tune, - color: - isNegative ? Colors.red : AppColors.rouge), - title: Text(opt['name'] ?? '', - style: TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(opt['details'] ?? ''), - trailing: canViewPrices - ? Text( - (isNegative ? '- ' : '+ ') + - currencyFormat.format(price.abs()), - style: TextStyle( - color: isNegative - ? Colors.red - : AppColors.noir, - fontWeight: FontWeight.bold, - ), - ) - : null, - contentPadding: EdgeInsets.zero, - dense: true, - ); - }).toList(), + EventOptionsDisplayWidget( + optionsData: event.options, + canViewPrices: canViewPrices, + showPriceCalculation: false, // On affiche le total séparément ), - if (canViewPrices) ...[ - const SizedBox(height: 4), - Builder( - builder: (context) { - final total = event.basePrice + - event.options.fold(0, - (sum, opt) => sum + (opt['price'] ?? 0.0)); - return Padding( - padding: - const EdgeInsets.only(top: 8.0, bottom: 8.0), - child: Row( - 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, - )), - Text( - currencyFormat.format(total), + ], + if (canViewPrices) ...[ + const SizedBox(height: 4), + Builder( + builder: (context) { + final total = event.basePrice + + event.options.fold(0, + (sum, opt) => sum + (opt['price'] ?? 0.0)); + return Padding( + padding: + const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Row( + 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.rouge, + color: AppColors.noir, fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - }, - ), - ], + )), + Text( + currencyFormat.format(total), + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + }, + ), ], _buildInfoRow( context, @@ -268,12 +243,39 @@ class EventDetails extends StatelessWidget { 'Temps d\'installation', '${event.installationTime} heures', ), + // Sous-titre: Horaire d'arrivée prévisionnelle (début - installation) + Builder( + builder: (context) { + final arrival = event.startDateTime.subtract(Duration(hours: event.installationTime)); + return Padding( + padding: const EdgeInsets.only(left: 36.0, bottom: 4.0), + child: Text( + 'Horaire d\'arrivée prévisionnel : ${DateFormat('dd/MM/yyyy HH:mm').format(arrival)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[700]), + ), + ); + }, + ), + _buildInfoRow( context, Icons.construction, 'Temps de démontage', '${event.disassemblyTime} heures', ), + // Sous-titre: Horaire de départ prévu (fin + démontage) + Builder( + builder: (context) { + final departure = event.endDateTime.add(Duration(hours: event.disassemblyTime)); + return Padding( + padding: const EdgeInsets.only(left: 36.0, bottom: 4.0), + child: Text( + 'Horaire de départ prévu : ${DateFormat('dd/MM/yyyy HH:mm').format(departure)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey[700]), + ), + ); + }, + ), const SizedBox(height: 16), Text( 'Description', diff --git a/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart b/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart index 4b162ee..2dac8ca 100644 --- a/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_basic_info_section.dart @@ -40,7 +40,7 @@ class EventBasicInfoSection extends StatelessWidget { TextFormField( controller: nameController, decoration: const InputDecoration( - labelText: 'Nom de l\'événement', + labelText: 'Nom de l\'événement*', border: OutlineInputBorder(), prefixIcon: Icon(Icons.event), ), @@ -50,29 +50,40 @@ class EventBasicInfoSection extends StatelessWidget { if (isLoadingEventTypes) const Center(child: CircularProgressIndicator()) else - DropdownButtonFormField( - value: selectedEventTypeId, - items: eventTypes - .map((type) => DropdownMenuItem( - value: type.id, - child: Text(type.name), - )) - .toList(), - onChanged: onEventTypeChanged, - decoration: const InputDecoration( - labelText: 'Type d\'événement', - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.category), - ), - validator: (v) => v == null ? 'Sélectionnez un type' : null, + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + fit: FlexFit.loose, + child: DropdownButtonFormField( + initialValue: selectedEventTypeId, + items: eventTypes + .map((type) => DropdownMenuItem( + value: type.id, + child: Text(type.name), + )) + .toList(), + onChanged: onEventTypeChanged, + decoration: const InputDecoration( + labelText: 'Type d\'événement*', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + validator: (v) => v == null ? 'Sélectionnez un type' : null, + ), + ), + const SizedBox(width: 16), + Flexible( + fit: FlexFit.loose, + child: _buildDateTimeRow(context), + ), + ], ), const SizedBox(height: 16), - _buildDateTimeRow(context), - const SizedBox(height: 16), TextFormField( controller: basePriceController, decoration: const InputDecoration( - labelText: 'Prix de base (€)', + labelText: 'Prix de base (€)*', border: OutlineInputBorder(), prefixIcon: Icon(Icons.euro), hintText: '1050.50', @@ -120,7 +131,7 @@ class EventBasicInfoSection extends StatelessWidget { child: TextFormField( readOnly: true, decoration: const InputDecoration( - labelText: 'Début', + labelText: 'Début*', border: OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today), suffixIcon: Icon(Icons.edit_calendar), @@ -143,7 +154,7 @@ class EventBasicInfoSection extends StatelessWidget { child: TextFormField( readOnly: true, decoration: const InputDecoration( - labelText: 'Fin', + labelText: 'Fin*', border: OutlineInputBorder(), prefixIcon: Icon(Icons.calendar_today), suffixIcon: Icon(Icons.edit_calendar), diff --git a/em2rp/lib/views/widgets/event_form/event_details_section.dart b/em2rp/lib/views/widgets/event_form/event_details_section.dart index 274f033..942907c 100644 --- a/em2rp/lib/views/widgets/event_form/event_details_section.dart +++ b/em2rp/lib/views/widgets/event_form/event_details_section.dart @@ -93,11 +93,11 @@ class _EventDetailsSectionState extends State { ), ], ), - _buildSectionTitle('Adresse'), + _buildSectionTitle('Adresse*'), TextFormField( controller: widget.addressController, decoration: const InputDecoration( - labelText: 'Adresse', + labelText: 'Adresse*', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_on), ), diff --git a/em2rp/lib/views/widgets/event_form/event_form_actions.dart b/em2rp/lib/views/widgets/event_form/event_form_actions.dart index ef906bf..f82f5fc 100644 --- a/em2rp/lib/views/widgets/event_form/event_form_actions.dart +++ b/em2rp/lib/views/widgets/event_form/event_form_actions.dart @@ -6,6 +6,7 @@ class EventFormActions extends StatelessWidget { final VoidCallback onCancel; final VoidCallback onSubmit; final VoidCallback? onSetConfirmed; + final VoidCallback? onDelete; // Nouveau paramètre pour la suppression const EventFormActions({ super.key, @@ -14,6 +15,7 @@ class EventFormActions extends StatelessWidget { required this.onCancel, required this.onSubmit, this.onSetConfirmed, + this.onDelete, // Paramètre optionnel pour la suppression }); @override @@ -22,23 +24,43 @@ class EventFormActions extends StatelessWidget { children: [ const SizedBox(height: 24), Row( - mainAxisAlignment: MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - TextButton( - onPressed: isLoading ? null : onCancel, - child: const Text('Annuler'), - ), - const SizedBox(width: 8), - ElevatedButton.icon( - icon: const Icon(Icons.check), - onPressed: isLoading ? null : onSubmit, - label: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text(isEditMode ? 'Enregistrer' : 'Créer'), + // Bouton de suppression en mode édition + if (isEditMode && onDelete != null) + ElevatedButton.icon( + icon: const Icon(Icons.delete, color: Colors.white), + label: const Text('Supprimer'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + onPressed: isLoading ? null : onDelete, + ) + else + const SizedBox.shrink(), // Espace vide si pas en mode édition + + // Boutons Annuler et Enregistrer/Créer + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: isLoading ? null : onCancel, + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.check), + onPressed: isLoading ? null : onSubmit, + label: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(isEditMode ? 'Enregistrer' : 'Créer'), + ), + ], ), ], ), 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 new file mode 100644 index 0000000..1ac05bc --- /dev/null +++ b/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart @@ -0,0 +1,202 @@ +import 'package:flutter/material.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/option_model.dart'; +import 'package:intl/intl.dart'; +import 'package:em2rp/utils/colors.dart'; + +class EventOptionsDisplayWidget extends StatelessWidget { + final List> optionsData; + final bool canViewPrices; + final bool showPriceCalculation; + + const EventOptionsDisplayWidget({ + super.key, + required this.optionsData, + this.canViewPrices = true, + this.showPriceCalculation = true, + }); + + @override + Widget build(BuildContext context) { + if (optionsData.isEmpty) { + return const SizedBox.shrink(); + } + + final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Text( + 'Options sélectionnées', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + FutureBuilder>>( + future: _loadOptionsWithDetails(optionsData), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Erreur lors du chargement des options: ${snapshot.error}', + style: const TextStyle(color: Colors.red), + ), + ); + } + + final enrichedOptions = snapshot.data ?? []; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...enrichedOptions.map((opt) { + final price = (opt['price'] ?? 0.0) as num; + final isNegative = price < 0; + + return ListTile( + leading: Icon(Icons.tune, color: AppColors.rouge), + title: Text( + opt['name'] ?? '', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: opt['details'] != null && opt['details'].toString().trim().isNotEmpty + ? Text( + opt['details'].toString().trim(), + style: const TextStyle( + fontWeight: FontWeight.normal, + color: Colors.black87, + fontSize: 13, + fontStyle: FontStyle.italic, + ), + ) + : Text( + 'Aucun détail disponible', + style: TextStyle( + fontWeight: FontWeight.normal, + color: Colors.grey[600], + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + trailing: canViewPrices + ? Text( + (isNegative ? '- ' : '+ ') + + currencyFormat.format(price.abs()), + style: TextStyle( + color: isNegative ? Colors.red : AppColors.noir, + fontWeight: FontWeight.bold, + ), + ) + : null, + contentPadding: EdgeInsets.zero, + dense: true, + ); + }), + if (canViewPrices && showPriceCalculation) ...[ + const SizedBox(height: 4), + _buildTotalPrice(context, enrichedOptions, currencyFormat), + ], + ], + ); + }, + ), + ], + ); + } + + Widget _buildTotalPrice(BuildContext context, List> options, NumberFormat currencyFormat) { + final optionsTotal = options.fold(0, (sum, opt) => sum + (opt['price'] ?? 0.0)); + + return Padding( + padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), + child: Row( + 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, + ), + ), + Text( + currencyFormat.format(optionsTotal), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } + + Future>> _loadOptionsWithDetails(List> optionsData) async { + List> enrichedOptions = []; + + for (final optionData in optionsData) { + try { + // Si l'option a un ID, récupérer les détails complets depuis Firestore + if (optionData['id'] != null) { + final doc = await FirebaseFirestore.instance + .collection('options') + .doc(optionData['id']) + .get(); + + if (doc.exists) { + final firestoreData = doc.data()!; + // Combiner les données Firestore avec le prix choisi + enrichedOptions.add({ + 'id': optionData['id'], + 'name': firestoreData['name'], // Récupéré depuis Firestore + 'details': firestoreData['details'] ?? '', // Récupéré depuis Firestore + 'price': optionData['price'], // Prix choisi par l'utilisateur + 'valMin': firestoreData['valMin'], + 'valMax': firestoreData['valMax'], + }); + } else { + // Option supprimée de Firestore, afficher avec des données par défaut + enrichedOptions.add({ + 'id': optionData['id'], + 'name': 'Option supprimée (ID: ${optionData['id']})', + 'details': 'Cette option n\'existe plus dans la base de données', + 'price': optionData['price'], + }); + } + } else { + // Ancien format sans ID (rétrocompatibilité) + // Utiliser les données locales disponibles + enrichedOptions.add({ + 'name': optionData['name'] ?? 'Option inconnue', + 'details': optionData['details'] ?? 'Aucun détail disponible', + 'price': optionData['price'] ?? 0.0, + }); + } + } catch (e) { + print('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'], + 'name': 'Erreur de chargement (ID: ${optionData['id']})', + 'details': 'Impossible de charger les détails de cette option', + 'price': optionData['price'] ?? 0.0, + }); + } + } + + return enrichedOptions; + } +} diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart index 1ffd6a1..de6a4cf 100644 --- a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -3,16 +3,16 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/option_model.dart'; class OptionSelectorWidget extends StatefulWidget { - final String? eventType; final List> selectedOptions; final ValueChanged>> onChanged; - final void Function(String name)? onRemove; + final void Function(String id)? onRemove; // Changé de 'name' à 'id' final bool eventTypeRequired; final bool isMobile; + final String? eventType; const OptionSelectorWidget({ - super.key, - required this.eventType, + Key? key, + this.eventType, required this.selectedOptions, required this.onChanged, this.onRemove, @@ -27,15 +27,11 @@ class OptionSelectorWidget extends StatefulWidget { class _OptionSelectorWidgetState extends State { List _allOptions = []; bool _loading = true; - final String _search = ''; - final List _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; + int _rebuildKey = 0; // Clé pour forcer la reconstruction du FutureBuilder @override void didUpdateWidget(covariant OptionSelectorWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.eventType != widget.eventType) { - _fetchOptions(); - } } @override @@ -62,7 +58,7 @@ class _OptionSelectorWidgetState extends State { context: context, builder: (ctx) => _OptionPickerDialog( allOptions: _allOptions, - eventType: widget.eventType, + eventType: widget.eventType, // Ajout du paramètre manquant ), ); if (selected != null) { @@ -81,61 +77,104 @@ class _OptionSelectorWidgetState extends State { Text('Options sélectionnées', style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), - Column( - children: widget.selectedOptions - .map((opt) => Card( - elevation: widget.isMobile ? 0 : 2, - margin: EdgeInsets.symmetric( - vertical: widget.isMobile ? 4 : 8, - horizontal: widget.isMobile ? 0 : 8), - shape: RoundedRectangleBorder( - borderRadius: - BorderRadius.circular(widget.isMobile ? 8 : 12)), - child: Padding( - padding: EdgeInsets.all(widget.isMobile ? 8.0 : 12.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(opt['name'] ?? '', - style: const TextStyle( - fontWeight: FontWeight.bold)), - if (opt['details'] != null && - opt['details'] != '') - Padding( - padding: const EdgeInsets.only(top: 2.0), - child: Text(opt['details'], - style: const TextStyle(fontSize: 13)), - ), - Text('Prix : ${opt['price'] ?? ''} €', - style: const TextStyle(fontSize: 13)), - ], - ), - ), - IconButton( - icon: const Icon(Icons.delete), - tooltip: 'Supprimer cette option', - onPressed: () { - if (widget.onRemove != null) { - widget.onRemove!(opt['name'] as String); - } else { - final newList = List>.from( - widget.selectedOptions) - ..removeWhere( - (o) => o['name'] == opt['name']); - widget.onChanged(newList); - } - }, - ), - ], + + // Affichage direct des options sélectionnées avec clé de reconstruction + widget.selectedOptions.isEmpty + ? const Padding( + padding: EdgeInsets.all(16.0), + child: Text('Aucune option sélectionnée'), + ) + : FutureBuilder>>( + key: ValueKey(_rebuildKey), // Clé pour forcer la reconstruction + future: _loadOptionsWithDetails(widget.selectedOptions), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.all(16.0), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + 'Erreur lors du chargement des options: ${snapshot.error}', + style: const TextStyle(color: Colors.red), ), - ), - )) - .toList(), - ), + ); + } + + final enrichedOptions = snapshot.data ?? []; + + return Column( + children: enrichedOptions + .map((opt) => Card( + elevation: widget.isMobile ? 0 : 2, + margin: EdgeInsets.symmetric( + vertical: widget.isMobile ? 4 : 8, + horizontal: widget.isMobile ? 0 : 8), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(widget.isMobile ? 8 : 12)), + child: Padding( + padding: EdgeInsets.all(widget.isMobile ? 8.0 : 12.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(opt['name'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.bold)), + if (opt['details'] != null && + opt['details'].toString().trim().isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2.0), + child: Text(opt['details'], + style: const TextStyle(fontSize: 13)), + ), + Text('Prix : ${opt['price'] ?? ''} €', + style: const TextStyle(fontSize: 13)), + ], + ), + ), + IconButton( + icon: const Icon(Icons.delete), + tooltip: 'Supprimer cette option', + onPressed: () { + final optionId = opt['id']; + + // Utiliser le callback onRemove si disponible + if (widget.onRemove != null && optionId != null) { + widget.onRemove!(optionId); + // Forcer la reconstruction du FutureBuilder + setState(() { + _rebuildKey++; + }); + } else { + // Sinon, supprimer directement par ID de la liste + final newList = List>.from(widget.selectedOptions); + newList.removeWhere((o) => o['id'] == optionId); + widget.onChanged(newList); + // Forcer la reconstruction du FutureBuilder + setState(() { + _rebuildKey++; + }); + } + }, + ), + ], + ), + ), + )) + .toList(), + ); + }, + ), + const SizedBox(height: 16), Center( child: ElevatedButton.icon( @@ -148,32 +187,105 @@ class _OptionSelectorWidgetState extends State { ], ); } + + // Méthode pour charger les détails des options depuis Firebase + Future>> _loadOptionsWithDetails(List> optionsData) async { + List> enrichedOptions = []; + + for (final optionData in optionsData) { + try { + // Si l'option a un ID, récupérer les détails depuis Firestore + if (optionData['id'] != null) { + final doc = await FirebaseFirestore.instance + .collection('options') + .doc(optionData['id']) + .get(); + + if (doc.exists) { + final firestoreData = doc.data()!; + enrichedOptions.add({ + 'id': optionData['id'], + 'name': firestoreData['name'], + 'details': firestoreData['details'] ?? '', + 'price': optionData['price'], + }); + } else { + enrichedOptions.add({ + 'id': optionData['id'], + 'name': 'Option supprimée', + 'details': 'Cette option n\'existe plus', + 'price': optionData['price'], + }); + } + } else { + // Ancien format, utiliser les données locales + enrichedOptions.add(optionData); + } + } catch (e) { + // En cas d'erreur, utiliser les données disponibles + enrichedOptions.add({ + 'id': optionData['id'], + 'name': optionData['name'] ?? 'Erreur de chargement', + 'details': 'Impossible de charger les détails', + 'price': optionData['price'], + }); + } + } + + return enrichedOptions; + } } class _OptionPickerDialog extends StatefulWidget { final List allOptions; final String? eventType; - const _OptionPickerDialog( - {required this.allOptions, required this.eventType}); + + const _OptionPickerDialog({ + required this.allOptions, + this.eventType, + }); @override State<_OptionPickerDialog> createState() => _OptionPickerDialogState(); } + class _OptionPickerDialogState extends State<_OptionPickerDialog> { String _search = ''; bool _creating = false; @override Widget build(BuildContext context) { + // Debug: Afficher les informations de filtrage + print('=== DEBUG OptionPickerDialog ==='); + print('widget.eventType: ${widget.eventType}'); + print('widget.allOptions.length: ${widget.allOptions.length}'); + final filtered = widget.allOptions.where((opt) { - if (widget.eventType == null) return false; - final matchesType = - opt.eventTypes.any((ref) => ref.id == widget.eventType); - final matchesSearch = - opt.name.toLowerCase().contains(_search.toLowerCase()); - return matchesType && matchesSearch; + print('Option: ${opt.name}'); + print(' opt.eventTypes: ${opt.eventTypes}'); + print(' widget.eventType: ${widget.eventType}'); + + if (widget.eventType == null) { + print(' -> Filtered out: eventType is null'); + return false; + } + + final matchesType = opt.eventTypes.contains(widget.eventType); + print(' -> matchesType: $matchesType'); + + final matchesSearch = opt.name.toLowerCase().contains(_search.toLowerCase()); + print(' -> matchesSearch: $matchesSearch'); + + final result = matchesType && matchesSearch; + print(' -> Final result: $result'); + + return result; }).toList(); + + print('Filtered options count: ${filtered.length}'); + print('==========================='); + return Dialog( child: SizedBox( width: 400, @@ -243,11 +355,8 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { ); if (price != null) { Navigator.pop(context, { - 'name': opt.name, - 'price': price, - 'compatibleTypes': opt.eventTypes - .map((ref) => ref.id) - .toList(), + 'id': opt.id, // ID de l'option (obligatoire pour récupérer les données) + 'price': price, // Prix choisi par l'utilisateur (obligatoire car personnalisé) }); } }, @@ -304,9 +413,10 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { final _minPriceController = TextEditingController(); final _maxPriceController = TextEditingController(); final List _selectedTypes = []; - final List _allTypes = ['Bal', 'Mariage', 'Anniversaire']; String? _error; bool _checkingName = false; + List> _allEventTypes = []; + bool _loading = true; Future _isNameUnique(String name) async { final snap = await FirebaseFirestore.instance @@ -316,6 +426,23 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { return snap.docs.isEmpty; } + Future _fetchEventTypes() async { + setState(() { + _loading=true; + }); + final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get(); + setState(() { + _allEventTypes = snapshot.docs.map((doc) => {'id': doc.id, 'name': doc['name']}).toList(); + _loading = false; + }); + } + + @override + void initState() { + super.initState(); + _fetchEventTypes(); + } + @override Widget build(BuildContext context) { return AlertDialog( @@ -374,21 +501,21 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { const Text('Types d\'événement associés :'), Wrap( spacing: 8, - children: _allTypes - .map((type) => FilterChip( - label: Text(type), - selected: _selectedTypes.contains(type), - onSelected: (selected) { - setState(() { - if (selected) { - _selectedTypes.add(type); - } else { - _selectedTypes.remove(type); - } - }); - }, - )) - .toList(), + children: _allEventTypes + .map((type) => FilterChip( + label: Text(type['name']), + selected: _selectedTypes.contains(type['id']), + onSelected: (selected) { + setState(() { + if (selected) { + _selectedTypes.add(type['id']); + } else { + _selectedTypes.remove(type['id']); + } + }); + }, + )) + .toList(), ), ], ), @@ -435,22 +562,19 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { () => _error = 'Ce nom d\'option est déjà utilisé.'); return; } - final eventTypeRefs = _selectedTypes - .map((type) => FirebaseFirestore.instance - .collection('eventTypes') - .doc(type)) - .toList(); try { + // Debug : afficher le contenu envoyé + print('Enregistrement option avec eventTypes : ' + _selectedTypes.toString() + '\u001b[0m'); await FirebaseFirestore.instance.collection('options').add({ 'name': name, 'details': _detailsController.text.trim(), 'valMin': min, 'valMax': max, - 'eventTypes': eventTypeRefs, + 'eventTypes': _selectedTypes, }); Navigator.pop(context, true); } catch (e) { - setState(() => _error = 'Erreur lors de la création : $e'); + setState(() => _error = 'Erreur lors de la création : ' + e.toString() + '\nEventTypes=' + _selectedTypes.toString()); } }, child: _checkingName