Refactor event type handling and add data management page (options and event types)
This commit is contained in:
		| @@ -62,7 +62,7 @@ class EventDetailsDocuments extends StatelessWidget { | ||||
|  | ||||
|             return ListTile( | ||||
|               leading: Icon(icon, color: Colors.blueGrey), | ||||
|               title: SelectableText( | ||||
|               title: Text( | ||||
|                 fileName, | ||||
|                 maxLines: 1, | ||||
|                 textAlign: TextAlign.left, | ||||
| @@ -70,6 +70,7 @@ class EventDetailsDocuments extends StatelessWidget { | ||||
|               ), | ||||
|               trailing: IconButton( | ||||
|                 icon: const Icon(Icons.download), | ||||
|                 tooltip: 'Télécharger le fichier', | ||||
|                 onPressed: () async { | ||||
|                   if (await canLaunchUrl(Uri.parse(url))) { | ||||
|                     await launchUrl( | ||||
| @@ -81,10 +82,32 @@ class EventDetailsDocuments extends StatelessWidget { | ||||
|               ), | ||||
|               onTap: () async { | ||||
|                 if (await canLaunchUrl(Uri.parse(url))) { | ||||
|                   await launchUrl( | ||||
|                     Uri.parse(url), | ||||
|                     mode: LaunchMode.externalApplication, | ||||
|                   ); | ||||
|                   // Pour les fichiers visualisables, utiliser différentes stratégies | ||||
|                   if (_isViewableInBrowser(ext)) { | ||||
|                     if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"].contains(ext)) { | ||||
|                       // Pour les images, afficher dans un dialog intégré | ||||
|                       _showImageDialog(context, url, fileName); | ||||
|                     } else if (ext == ".pdf") { | ||||
|                       // Pour les PDFs, utiliser Google Docs Viewer | ||||
|                       final viewerUrl = 'https://docs.google.com/viewer?url=${Uri.encodeComponent(url)}'; | ||||
|                       await launchUrl( | ||||
|                         Uri.parse(viewerUrl), | ||||
|                         mode: LaunchMode.platformDefault, | ||||
|                       ); | ||||
|                     } else { | ||||
|                       // Pour les autres fichiers texte, ouvrir directement | ||||
|                       await launchUrl( | ||||
|                         Uri.parse(url), | ||||
|                         mode: LaunchMode.platformDefault, | ||||
|                       ); | ||||
|                     } | ||||
|                   } else { | ||||
|                     // Pour les autres fichiers, télécharger directement | ||||
|                     await launchUrl( | ||||
|                       Uri.parse(url), | ||||
|                       mode: LaunchMode.externalApplication, | ||||
|                     ); | ||||
|                   } | ||||
|                 } | ||||
|               }, | ||||
|               contentPadding: EdgeInsets.zero, | ||||
| @@ -95,5 +118,93 @@ class EventDetailsDocuments extends StatelessWidget { | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   bool _isViewableInBrowser(String ext) { | ||||
|     // Extensions de fichiers qui peuvent être visualisées directement dans le navigateur | ||||
|     return [ | ||||
|       ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", // Images | ||||
|       ".pdf", // PDF | ||||
|       ".txt", ".md", ".json", ".xml", ".csv" // Texte | ||||
|     ].contains(ext); | ||||
|   } | ||||
|  | ||||
|   void _showImageDialog(BuildContext context, String imageUrl, String fileName) { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (BuildContext context) { | ||||
|         return Dialog( | ||||
|           child: Container( | ||||
|             width: MediaQuery.of(context).size.width * 0.9, | ||||
|             height: MediaQuery.of(context).size.height * 0.8, | ||||
|             child: Column( | ||||
|               children: [ | ||||
|                 // Header avec le nom du fichier | ||||
|                 Container( | ||||
|                   padding: const EdgeInsets.all(16), | ||||
|                   decoration: BoxDecoration( | ||||
|                     color: AppColors.rouge.withOpacity(0.1), | ||||
|                     borderRadius: const BorderRadius.vertical(top: Radius.circular(4)), | ||||
|                   ), | ||||
|                   child: Row( | ||||
|                     children: [ | ||||
|                       Expanded( | ||||
|                         child: Text( | ||||
|                           fileName, | ||||
|                           style: Theme.of(context).textTheme.titleMedium?.copyWith( | ||||
|                             fontWeight: FontWeight.bold, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                       IconButton( | ||||
|                         icon: const Icon(Icons.close), | ||||
|                         onPressed: () => Navigator.of(context).pop(), | ||||
|                       ), | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|                 // Image qui prend tout l'espace disponible | ||||
|                 Expanded( | ||||
|                   child: Container( | ||||
|                     width: double.infinity, | ||||
|                     padding: const EdgeInsets.all(8), | ||||
|                     child: ClipRRect( | ||||
|                       borderRadius: BorderRadius.circular(8), | ||||
|                       child: Image.network( | ||||
|                         imageUrl, | ||||
|                         fit: BoxFit.contain, | ||||
|                         width: double.infinity, | ||||
|                         height: double.infinity, | ||||
|                         loadingBuilder: (context, child, loadingProgress) { | ||||
|                           if (loadingProgress == null) return child; | ||||
|                           return Center( | ||||
|                             child: CircularProgressIndicator( | ||||
|                               value: loadingProgress.expectedTotalBytes != null | ||||
|                                   ? loadingProgress.cumulativeBytesLoaded / | ||||
|                                     loadingProgress.expectedTotalBytes! | ||||
|                                   : null, | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                         errorBuilder: (context, error, stackTrace) { | ||||
|                           return const Center( | ||||
|                             child: Column( | ||||
|                               mainAxisAlignment: MainAxisAlignment.center, | ||||
|                               children: [ | ||||
|                                 Icon(Icons.error, size: 64, color: Colors.red), | ||||
|                                 Text('Erreur lors du chargement de l\'image'), | ||||
|                               ], | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ), | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,517 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:cloud_firestore/cloud_firestore.dart'; | ||||
| import 'package:em2rp/models/event_type_model.dart'; | ||||
| import 'package:em2rp/utils/colors.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
|  | ||||
| class EventTypesManagement extends StatefulWidget { | ||||
|   const EventTypesManagement({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<EventTypesManagement> createState() => _EventTypesManagementState(); | ||||
| } | ||||
|  | ||||
| class _EventTypesManagementState extends State<EventTypesManagement> { | ||||
|   String _searchQuery = ''; | ||||
|   List<EventTypeModel> _eventTypes = []; | ||||
|   bool _loading = true; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _loadEventTypes(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _loadEventTypes() async { | ||||
|     setState(() => _loading = true); | ||||
|     try { | ||||
|       final snapshot = await FirebaseFirestore.instance | ||||
|           .collection('eventTypes') | ||||
|           .orderBy('name') | ||||
|           .get(); | ||||
|  | ||||
|       setState(() { | ||||
|         _eventTypes = snapshot.docs | ||||
|             .map((doc) => EventTypeModel.fromMap(doc.data(), doc.id)) | ||||
|             .toList(); | ||||
|         _loading = false; | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       setState(() => _loading = false); | ||||
|       ScaffoldMessenger.of(context).showSnackBar( | ||||
|         SnackBar(content: Text('Erreur lors du chargement : $e')), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   List<EventTypeModel> get _filteredEventTypes { | ||||
|     if (_searchQuery.isEmpty) return _eventTypes; | ||||
|     return _eventTypes.where((type) => | ||||
|         type.name.toLowerCase().contains(_searchQuery.toLowerCase()) | ||||
|     ).toList(); | ||||
|   } | ||||
|  | ||||
|   Future<bool> _canDeleteEventType(String eventTypeId) async { | ||||
|     final eventsSnapshot = await FirebaseFirestore.instance | ||||
|         .collection('events') | ||||
|         .where('eventTypeId', isEqualTo: eventTypeId) | ||||
|         .get(); | ||||
|  | ||||
|     return eventsSnapshot.docs.isEmpty; | ||||
|   } | ||||
|  | ||||
|   Future<List<Map<String, dynamic>>> _getBlockingEvents(String eventTypeId) async { | ||||
|     final eventsSnapshot = await FirebaseFirestore.instance | ||||
|         .collection('events') | ||||
|         .where('eventTypeId', isEqualTo: eventTypeId) | ||||
|         .get(); | ||||
|  | ||||
|     final now = DateTime.now(); | ||||
|     List<Map<String, dynamic>> futureEvents = []; | ||||
|     List<Map<String, dynamic>> pastEvents = []; | ||||
|  | ||||
|     for (final doc in eventsSnapshot.docs) { | ||||
|       final eventData = doc.data(); | ||||
|       final eventDate = eventData['startDateTime']?.toDate() ?? DateTime.now(); | ||||
|  | ||||
|       if (eventDate.isAfter(now)) { | ||||
|         futureEvents.add({ | ||||
|           'id': doc.id, | ||||
|           'name': eventData['name'], | ||||
|           'startDateTime': eventDate, | ||||
|         }); | ||||
|       } else { | ||||
|         pastEvents.add({ | ||||
|           'id': doc.id, | ||||
|           'name': eventData['name'], | ||||
|           'startDateTime': eventDate, | ||||
|         }); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return [...futureEvents, ...pastEvents]; | ||||
|   } | ||||
|  | ||||
|   Future<void> _deleteEventType(EventTypeModel eventType) async { | ||||
|     final events = await _getBlockingEvents(eventType.id); | ||||
|     final futureEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList(); | ||||
|  | ||||
|     if (futureEvents.isNotEmpty) { | ||||
|       // Il y a des événements futurs, empêcher la suppression | ||||
|       _showBlockingEventsDialog(eventType, events, canDelete: false); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (events.isNotEmpty) { | ||||
|       // Il n'y a que des événements passés, afficher un avertissement | ||||
|       _showBlockingEventsDialog(eventType, events, canDelete: true); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Aucun événement, suppression directe | ||||
|     _confirmAndDelete(eventType); | ||||
|   } | ||||
|  | ||||
|   void _showBlockingEventsDialog(EventTypeModel eventType, List<Map<String, dynamic>> events, {required bool canDelete}) { | ||||
|     final futureEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList(); | ||||
|     final pastEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isBefore(DateTime.now())).toList(); | ||||
|  | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => AlertDialog( | ||||
|         title: Text(canDelete ? 'Avertissement' : 'Suppression impossible'), | ||||
|         content: SizedBox( | ||||
|           width: double.maxFinite, | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               if (!canDelete) ...[ | ||||
|                 const Text( | ||||
|                   'Impossible de supprimer ce type d\'événement car il est utilisé par des événements futurs :', | ||||
|                   style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 const SizedBox(height: 12), | ||||
|                 ...futureEvents.map((event) => Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 4), | ||||
|                   child: Text( | ||||
|                     '• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}', | ||||
|                     style: const TextStyle(color: Colors.red), | ||||
|                   ), | ||||
|                 )), | ||||
|               ], | ||||
|               if (canDelete && pastEvents.isNotEmpty) ...[ | ||||
|                 const Text( | ||||
|                   'Ce type d\'événement est utilisé par des événements passés :', | ||||
|                   style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 const SizedBox(height: 12), | ||||
|                 ...pastEvents.take(5).map((event) => Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 4), | ||||
|                   child: Text( | ||||
|                     '• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}', | ||||
|                     style: const TextStyle(color: Colors.orange), | ||||
|                   ), | ||||
|                 )), | ||||
|                 if (pastEvents.length > 5) | ||||
|                   Text('... et ${pastEvents.length - 5} autres événements'), | ||||
|                 const SizedBox(height: 12), | ||||
|                 const Text('Voulez-vous vraiment continuer la suppression ?'), | ||||
|               ], | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(context), | ||||
|             child: const Text('Annuler'), | ||||
|           ), | ||||
|           if (canDelete) | ||||
|             ElevatedButton( | ||||
|               onPressed: () { | ||||
|                 Navigator.pop(context); | ||||
|                 _confirmAndDelete(eventType); | ||||
|               }, | ||||
|               style: ElevatedButton.styleFrom(backgroundColor: Colors.red), | ||||
|               child: const Text('Supprimer quand même', style: TextStyle(color: Colors.white)), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _confirmAndDelete(EventTypeModel eventType) { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => AlertDialog( | ||||
|         title: const Text('Confirmer la suppression'), | ||||
|         content: Text('Êtes-vous sûr de vouloir supprimer le type "${eventType.name}" ?'), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(context), | ||||
|             child: const Text('Annuler'), | ||||
|           ), | ||||
|           ElevatedButton( | ||||
|             onPressed: () async { | ||||
|               Navigator.pop(context); | ||||
|               try { | ||||
|                 await FirebaseFirestore.instance | ||||
|                     .collection('eventTypes') | ||||
|                     .doc(eventType.id) | ||||
|                     .delete(); | ||||
|  | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   const SnackBar(content: Text('Type d\'événement supprimé avec succès')), | ||||
|                 ); | ||||
|                 _loadEventTypes(); | ||||
|               } catch (e) { | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   SnackBar(content: Text('Erreur lors de la suppression : $e')), | ||||
|                 ); | ||||
|               } | ||||
|             }, | ||||
|             style: ElevatedButton.styleFrom(backgroundColor: Colors.red), | ||||
|             child: const Text('Supprimer', style: TextStyle(color: Colors.white)), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _showCreateEditDialog({EventTypeModel? eventType}) { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => _EventTypeFormDialog( | ||||
|         eventType: eventType, | ||||
|         onSaved: _loadEventTypes, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.all(16.0), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           // Header avec recherche et bouton ajouter | ||||
|           Row( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: TextField( | ||||
|                   decoration: const InputDecoration( | ||||
|                     labelText: 'Rechercher un type d\'événement', | ||||
|                     prefixIcon: Icon(Icons.search), | ||||
|                     border: OutlineInputBorder(), | ||||
|                   ), | ||||
|                   onChanged: (value) => setState(() => _searchQuery = value), | ||||
|                 ), | ||||
|               ), | ||||
|               const SizedBox(width: 16), | ||||
|               ElevatedButton.icon( | ||||
|                 onPressed: () => _showCreateEditDialog(), | ||||
|                 icon: const Icon(Icons.add), | ||||
|                 label: const Text('Nouveau type'), | ||||
|                 style: ElevatedButton.styleFrom( | ||||
|                   backgroundColor: AppColors.rouge, | ||||
|                   foregroundColor: Colors.white, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|           const SizedBox(height: 16), | ||||
|  | ||||
|           // Liste des types d'événements | ||||
|           Expanded( | ||||
|             child: _loading | ||||
|                 ? const Center(child: CircularProgressIndicator()) | ||||
|                 : _filteredEventTypes.isEmpty | ||||
|                     ? const Center( | ||||
|                         child: Text( | ||||
|                           'Aucun type d\'événement trouvé', | ||||
|                           style: TextStyle(fontSize: 16, color: Colors.grey), | ||||
|                         ), | ||||
|                       ) | ||||
|                     : ListView.builder( | ||||
|                         itemCount: _filteredEventTypes.length, | ||||
|                         itemBuilder: (context, index) { | ||||
|                           final eventType = _filteredEventTypes[index]; | ||||
|                           return Card( | ||||
|                             margin: const EdgeInsets.only(bottom: 8), | ||||
|                             child: ListTile( | ||||
|                               leading: CircleAvatar( | ||||
|                                 backgroundColor: AppColors.rouge, | ||||
|                                 child: const Icon(Icons.category, color: Colors.white), | ||||
|                               ), | ||||
|                               title: Text( | ||||
|                                 eventType.name, | ||||
|                                 style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                               ), | ||||
|                               subtitle: Column( | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 children: [ | ||||
|                                   Text( | ||||
|                                     'Prix par défaut : ${eventType.defaultPrice} €', | ||||
|                                     style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                                   ), | ||||
|                                   const SizedBox(height: 4), | ||||
|                                   Text( | ||||
|                                     'Créé le ${DateFormat('dd/MM/yyyy').format(eventType.createdAt)}', | ||||
|                                     style: TextStyle( | ||||
|                                       fontSize: 12, | ||||
|                                       color: Colors.grey[600], | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                               trailing: Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   IconButton( | ||||
|                                     icon: const Icon(Icons.edit, color: Colors.blue), | ||||
|                                     onPressed: () => _showCreateEditDialog(eventType: eventType), | ||||
|                                     tooltip: 'Modifier', | ||||
|                                   ), | ||||
|                                   IconButton( | ||||
|                                     icon: const Icon(Icons.delete, color: Colors.red), | ||||
|                                     onPressed: () => _deleteEventType(eventType), | ||||
|                                     tooltip: 'Supprimer', | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _EventTypeFormDialog extends StatefulWidget { | ||||
|   final EventTypeModel? eventType; | ||||
|   final VoidCallback onSaved; | ||||
|  | ||||
|   const _EventTypeFormDialog({ | ||||
|     this.eventType, | ||||
|     required this.onSaved, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<_EventTypeFormDialog> createState() => _EventTypeFormDialogState(); | ||||
| } | ||||
|  | ||||
| class _EventTypeFormDialogState extends State<_EventTypeFormDialog> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _defaultPriceController = TextEditingController(); | ||||
|   bool _loading = false; | ||||
|   String? _error; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.eventType != null) { | ||||
|       _nameController.text = widget.eventType!.name; | ||||
|       _defaultPriceController.text = widget.eventType!.defaultPrice.toString(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _nameController.dispose(); | ||||
|     _defaultPriceController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   Future<bool> _isNameUnique(String name) async { | ||||
|     final snapshot = await FirebaseFirestore.instance | ||||
|         .collection('eventTypes') | ||||
|         .where('name', isEqualTo: name) | ||||
|         .get(); | ||||
|  | ||||
|     // Si on modifie, exclure le document actuel | ||||
|     if (widget.eventType != null) { | ||||
|       return snapshot.docs | ||||
|           .where((doc) => doc.id != widget.eventType!.id) | ||||
|           .isEmpty; | ||||
|     } | ||||
|  | ||||
|     return snapshot.docs.isEmpty; | ||||
|   } | ||||
|  | ||||
|   Future<void> _submit() async { | ||||
|     if (!_formKey.currentState!.validate()) return; | ||||
|  | ||||
|     final name = _nameController.text.trim(); | ||||
|     final defaultPrice = double.tryParse(_defaultPriceController.text.replaceAll(',', '.')) ?? 0.0; | ||||
|  | ||||
|     setState(() => _loading = true); | ||||
|  | ||||
|     try { | ||||
|       // Vérifier l'unicité du nom | ||||
|       final isUnique = await _isNameUnique(name); | ||||
|       if (!isUnique) { | ||||
|         setState(() { | ||||
|           _error = 'Ce nom de type d\'événement existe déjà'; | ||||
|           _loading = false; | ||||
|         }); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       final data = { | ||||
|         'name': name, | ||||
|         'defaultPrice': defaultPrice, | ||||
|         'createdAt': widget.eventType?.createdAt ?? DateTime.now(), | ||||
|       }; | ||||
|  | ||||
|       if (widget.eventType == null) { | ||||
|         // Création | ||||
|         await FirebaseFirestore.instance.collection('eventTypes').add(data); | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           const SnackBar(content: Text('Type d\'événement créé avec succès')), | ||||
|         ); | ||||
|       } else { | ||||
|         // Modification | ||||
|         await FirebaseFirestore.instance | ||||
|             .collection('eventTypes') | ||||
|             .doc(widget.eventType!.id) | ||||
|             .update(data); | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           const SnackBar(content: Text('Type d\'événement modifié avec succès')), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       widget.onSaved(); | ||||
|       Navigator.pop(context); | ||||
|     } catch (e) { | ||||
|       setState(() { | ||||
|         _error = 'Erreur : $e'; | ||||
|         _loading = false; | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text(widget.eventType == null ? 'Nouveau type d\'événement' : 'Modifier le type'), | ||||
|       content: SizedBox( | ||||
|         width: 400, | ||||
|         child: Form( | ||||
|           key: _formKey, | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               TextFormField( | ||||
|                 controller: _nameController, | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Nom du type *', | ||||
|                   border: OutlineInputBorder(), | ||||
|                 ), | ||||
|                 validator: (value) { | ||||
|                   if (value == null || value.trim().isEmpty) { | ||||
|                     return 'Le nom est obligatoire'; | ||||
|                   } | ||||
|                   return null; | ||||
|                 }, | ||||
|               ), | ||||
|               const SizedBox(height: 16), | ||||
|               TextFormField( | ||||
|                 controller: _defaultPriceController, | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Prix par défaut (€) *', | ||||
|                   border: OutlineInputBorder(), | ||||
|                   hintText: '1100', | ||||
|                 ), | ||||
|                 keyboardType: const TextInputType.numberWithOptions(decimal: true), | ||||
|                 validator: (value) { | ||||
|                   if (value == null || value.trim().isEmpty) { | ||||
|                     return 'Le prix par défaut est obligatoire'; | ||||
|                   } | ||||
|                   final price = double.tryParse(value.replaceAll(',', '.')); | ||||
|                   if (price == null || price < 0) { | ||||
|                     return 'Veuillez entrer un prix valide'; | ||||
|                   } | ||||
|                   return null; | ||||
|                 }, | ||||
|               ), | ||||
|               if (_error != null) ...[ | ||||
|                 const SizedBox(height: 16), | ||||
|                 Text( | ||||
|                   _error!, | ||||
|                   style: const TextStyle(color: Colors.red), | ||||
|                 ), | ||||
|               ], | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: _loading ? null : () => Navigator.pop(context), | ||||
|           child: const Text('Annuler'), | ||||
|         ), | ||||
|         ElevatedButton( | ||||
|           onPressed: _loading ? null : _submit, | ||||
|           style: ElevatedButton.styleFrom( | ||||
|             backgroundColor: AppColors.rouge, | ||||
|             foregroundColor: Colors.white, | ||||
|           ), | ||||
|           child: _loading | ||||
|               ? const SizedBox( | ||||
|                   width: 20, | ||||
|                   height: 20, | ||||
|                   child: CircularProgressIndicator(strokeWidth: 2), | ||||
|                 ) | ||||
|               : Text(widget.eventType == null ? 'Créer' : 'Modifier'), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										649
									
								
								em2rp/lib/views/widgets/data_management/options_management.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										649
									
								
								em2rp/lib/views/widgets/data_management/options_management.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,649 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:cloud_firestore/cloud_firestore.dart'; | ||||
| import 'package:em2rp/models/option_model.dart'; | ||||
| import 'package:em2rp/utils/colors.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
|  | ||||
| class OptionsManagement extends StatefulWidget { | ||||
|   const OptionsManagement({super.key}); | ||||
|  | ||||
|   @override | ||||
|   State<OptionsManagement> createState() => _OptionsManagementState(); | ||||
| } | ||||
|  | ||||
| class _OptionsManagementState extends State<OptionsManagement> { | ||||
|   String _searchQuery = ''; | ||||
|   List<EventOption> _options = []; | ||||
|   Map<String, String> _eventTypeNames = {}; | ||||
|   bool _loading = true; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     _loadData(); | ||||
|   } | ||||
|  | ||||
|   Future<void> _loadData() async { | ||||
|     setState(() => _loading = true); | ||||
|     try { | ||||
|       // Charger les types d'événements pour les noms | ||||
|       final eventTypesSnapshot = await FirebaseFirestore.instance | ||||
|           .collection('eventTypes') | ||||
|           .get(); | ||||
|  | ||||
|       _eventTypeNames = { | ||||
|         for (var doc in eventTypesSnapshot.docs) | ||||
|           doc.id: doc.data()['name'] as String | ||||
|       }; | ||||
|  | ||||
|       // Charger les options | ||||
|       final optionsSnapshot = await FirebaseFirestore.instance | ||||
|           .collection('options') | ||||
|           .orderBy('code') | ||||
|           .get(); | ||||
|  | ||||
|       setState(() { | ||||
|         _options = optionsSnapshot.docs | ||||
|             .map((doc) => EventOption.fromMap(doc.data(), doc.id)) | ||||
|             .toList(); | ||||
|         _loading = false; | ||||
|       }); | ||||
|     } catch (e) { | ||||
|       setState(() => _loading = false); | ||||
|       ScaffoldMessenger.of(context).showSnackBar( | ||||
|         SnackBar(content: Text('Erreur lors du chargement : $e')), | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   List<EventOption> get _filteredOptions { | ||||
|     if (_searchQuery.isEmpty) return _options; | ||||
|     return _options.where((option) => | ||||
|         option.name.toLowerCase().contains(_searchQuery.toLowerCase()) || | ||||
|         option.code.toLowerCase().contains(_searchQuery.toLowerCase()) || | ||||
|         option.details.toLowerCase().contains(_searchQuery.toLowerCase()) | ||||
|     ).toList(); | ||||
|   } | ||||
|  | ||||
|   Future<List<Map<String, dynamic>>> _getBlockingEvents(String optionId) async { | ||||
|     final eventsSnapshot = await FirebaseFirestore.instance | ||||
|         .collection('events') | ||||
|         .get(); | ||||
|  | ||||
|     final now = DateTime.now(); | ||||
|     List<Map<String, dynamic>> futureEvents = []; | ||||
|     List<Map<String, dynamic>> pastEvents = []; | ||||
|  | ||||
|     for (final doc in eventsSnapshot.docs) { | ||||
|       final eventData = doc.data(); | ||||
|       final options = eventData['options'] as List<dynamic>? ?? []; | ||||
|  | ||||
|       // Vérifier si cette option est utilisée dans cet événement | ||||
|       bool optionUsed = options.any((opt) => opt['id'] == optionId); | ||||
|  | ||||
|       if (optionUsed) { | ||||
|         final eventDate = eventData['StartDateTime']?.toDate() ?? DateTime.now(); | ||||
|         // Corriger la récupération du nom - utiliser 'Name' au lieu de 'name' | ||||
|         final eventName = eventData['Name'] as String? ?? 'Événement sans nom'; | ||||
|  | ||||
|         if (eventDate.isAfter(now)) { | ||||
|           futureEvents.add({ | ||||
|             'id': doc.id, | ||||
|             'name': eventName, | ||||
|             'startDateTime': eventDate, | ||||
|           }); | ||||
|         } else { | ||||
|           pastEvents.add({ | ||||
|             'id': doc.id, | ||||
|             'name': eventName, | ||||
|             'startDateTime': eventDate, | ||||
|           }); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return [...futureEvents, ...pastEvents]; | ||||
|   } | ||||
|  | ||||
|   Future<void> _deleteOption(EventOption option) async { | ||||
|     final events = await _getBlockingEvents(option.id); | ||||
|     final futureEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList(); | ||||
|  | ||||
|     if (futureEvents.isNotEmpty) { | ||||
|       // Il y a des événements futurs, empêcher la suppression | ||||
|       _showBlockingEventsDialog(option, events, canDelete: false); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (events.isNotEmpty) { | ||||
|       // Il n'y a que des événements passés, afficher un avertissement | ||||
|       _showBlockingEventsDialog(option, events, canDelete: true); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Aucun événement, suppression directe | ||||
|     _confirmAndDelete(option); | ||||
|   } | ||||
|  | ||||
|   void _showBlockingEventsDialog(EventOption option, List<Map<String, dynamic>> events, {required bool canDelete}) { | ||||
|     final futureEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isAfter(DateTime.now())).toList(); | ||||
|     final pastEvents = events.where((e) => | ||||
|         (e['startDateTime'] as DateTime).isBefore(DateTime.now())).toList(); | ||||
|  | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => AlertDialog( | ||||
|         title: Text(canDelete ? 'Avertissement' : 'Suppression impossible'), | ||||
|         content: SizedBox( | ||||
|           width: double.maxFinite, | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               if (!canDelete) ...[ | ||||
|                 const Text( | ||||
|                   'Impossible de supprimer cette option car elle est utilisée par des événements futurs :', | ||||
|                   style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 const SizedBox(height: 12), | ||||
|                 ...futureEvents.map((event) => Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 4), | ||||
|                   child: Text( | ||||
|                     '• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}', | ||||
|                     style: const TextStyle(color: Colors.red), | ||||
|                   ), | ||||
|                 )), | ||||
|               ], | ||||
|               if (canDelete && pastEvents.isNotEmpty) ...[ | ||||
|                 const Text( | ||||
|                   'Cette option est utilisée par des événements passés :', | ||||
|                   style: TextStyle(color: Colors.orange, fontWeight: FontWeight.bold), | ||||
|                 ), | ||||
|                 const SizedBox(height: 12), | ||||
|                 ...pastEvents.take(5).map((event) => Padding( | ||||
|                   padding: const EdgeInsets.only(bottom: 4), | ||||
|                   child: Text( | ||||
|                     '• ${event['name']} - ${DateFormat('dd/MM/yyyy').format(event['startDateTime'])}', | ||||
|                     style: const TextStyle(color: Colors.orange), | ||||
|                   ), | ||||
|                 )), | ||||
|                 if (pastEvents.length > 5) | ||||
|                   Text('... et ${pastEvents.length - 5} autres événements'), | ||||
|                 const SizedBox(height: 12), | ||||
|                 const Text('Voulez-vous vraiment continuer la suppression ?'), | ||||
|               ], | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(context), | ||||
|             child: const Text('Annuler'), | ||||
|           ), | ||||
|           if (canDelete) | ||||
|             ElevatedButton( | ||||
|               onPressed: () { | ||||
|                 Navigator.pop(context); | ||||
|                 _confirmAndDelete(option); | ||||
|               }, | ||||
|               style: ElevatedButton.styleFrom(backgroundColor: Colors.red), | ||||
|               child: const Text('Supprimer quand même', style: TextStyle(color: Colors.white)), | ||||
|             ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _confirmAndDelete(EventOption option) { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => AlertDialog( | ||||
|         title: const Text('Confirmer la suppression'), | ||||
|         content: Text('Êtes-vous sûr de vouloir supprimer l\'option "${option.code} - ${option.name}" ?'), | ||||
|         actions: [ | ||||
|           TextButton( | ||||
|             onPressed: () => Navigator.pop(context), | ||||
|             child: const Text('Annuler'), | ||||
|           ), | ||||
|           ElevatedButton( | ||||
|             onPressed: () async { | ||||
|               Navigator.pop(context); | ||||
|               try { | ||||
|                 await FirebaseFirestore.instance | ||||
|                     .collection('options') | ||||
|                     .doc(option.id) | ||||
|                     .delete(); | ||||
|  | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   const SnackBar(content: Text('Option supprimée avec succès')), | ||||
|                 ); | ||||
|                 _loadData(); | ||||
|               } catch (e) { | ||||
|                 ScaffoldMessenger.of(context).showSnackBar( | ||||
|                   SnackBar(content: Text('Erreur lors de la suppression : $e')), | ||||
|                 ); | ||||
|               } | ||||
|             }, | ||||
|             style: ElevatedButton.styleFrom(backgroundColor: Colors.red), | ||||
|             child: const Text('Supprimer', style: TextStyle(color: Colors.white)), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void _showCreateEditDialog({EventOption? option}) { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (context) => _OptionFormDialog( | ||||
|         option: option, | ||||
|         eventTypeNames: _eventTypeNames, | ||||
|         onSaved: _loadData, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Padding( | ||||
|       padding: const EdgeInsets.all(16.0), | ||||
|       child: Column( | ||||
|         children: [ | ||||
|           // Header avec recherche et bouton ajouter | ||||
|           Row( | ||||
|             children: [ | ||||
|               Expanded( | ||||
|                 child: TextField( | ||||
|                   decoration: const InputDecoration( | ||||
|                     labelText: 'Rechercher une option (code, nom, détails)', | ||||
|                     prefixIcon: Icon(Icons.search), | ||||
|                     border: OutlineInputBorder(), | ||||
|                   ), | ||||
|                   onChanged: (value) => setState(() => _searchQuery = value), | ||||
|                 ), | ||||
|               ), | ||||
|               const SizedBox(width: 16), | ||||
|               ElevatedButton.icon( | ||||
|                 onPressed: () => _showCreateEditDialog(), | ||||
|                 icon: const Icon(Icons.add), | ||||
|                 label: const Text('Nouvelle option'), | ||||
|                 style: ElevatedButton.styleFrom( | ||||
|                   backgroundColor: AppColors.rouge, | ||||
|                   foregroundColor: Colors.white, | ||||
|                 ), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|           const SizedBox(height: 16), | ||||
|  | ||||
|           // Liste des options | ||||
|           Expanded( | ||||
|             child: _loading | ||||
|                 ? const Center(child: CircularProgressIndicator()) | ||||
|                 : _filteredOptions.isEmpty | ||||
|                     ? const Center( | ||||
|                         child: Text( | ||||
|                           'Aucune option trouvée', | ||||
|                           style: TextStyle(fontSize: 16, color: Colors.grey), | ||||
|                         ), | ||||
|                       ) | ||||
|                     : ListView.builder( | ||||
|                         itemCount: _filteredOptions.length, | ||||
|                         itemBuilder: (context, index) { | ||||
|                           final option = _filteredOptions[index]; | ||||
|                           final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); | ||||
|  | ||||
|                           return Card( | ||||
|                             margin: const EdgeInsets.only(bottom: 8), | ||||
|                             child: ListTile( | ||||
|                               leading: CircleAvatar( | ||||
|                                 backgroundColor: AppColors.rouge, | ||||
|                                 child: Text( | ||||
|                                   option.code.substring(0, 2).toUpperCase(), | ||||
|                                   style: const TextStyle( | ||||
|                                     color: Colors.white, | ||||
|                                     fontWeight: FontWeight.bold, | ||||
|                                     fontSize: 12, | ||||
|                                   ), | ||||
|                                 ), | ||||
|                               ), | ||||
|                               title: Text( | ||||
|                                 '${option.code} - ${option.name}', | ||||
|                                 style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                               ), | ||||
|                               subtitle: Column( | ||||
|                                 crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                                 children: [ | ||||
|                                   if (option.details.isNotEmpty) | ||||
|                                     Text(option.details), | ||||
|                                   const SizedBox(height: 4), | ||||
|                                   Text( | ||||
|                                     'Prix : ${currencyFormat.format(option.valMin)} - ${currencyFormat.format(option.valMax)}', | ||||
|                                     style: const TextStyle( | ||||
|                                       fontWeight: FontWeight.w500, | ||||
|                                       color: Colors.green, | ||||
|                                     ), | ||||
|                                   ), | ||||
|                                   const SizedBox(height: 4), | ||||
|                                   Wrap( | ||||
|                                     spacing: 4, | ||||
|                                     children: option.eventTypes.map((typeId) { | ||||
|                                       final typeName = _eventTypeNames[typeId] ?? typeId; | ||||
|                                       return Chip( | ||||
|                                         label: Text( | ||||
|                                           typeName, | ||||
|                                           style: const TextStyle(fontSize: 10), | ||||
|                                         ), | ||||
|                                         materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, | ||||
|                                         backgroundColor: AppColors.rouge.withOpacity(0.1), | ||||
|                                       ); | ||||
|                                     }).toList(), | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                               trailing: Row( | ||||
|                                 mainAxisSize: MainAxisSize.min, | ||||
|                                 children: [ | ||||
|                                   IconButton( | ||||
|                                     icon: const Icon(Icons.edit, color: Colors.blue), | ||||
|                                     onPressed: () => _showCreateEditDialog(option: option), | ||||
|                                     tooltip: 'Modifier', | ||||
|                                   ), | ||||
|                                   IconButton( | ||||
|                                     icon: const Icon(Icons.delete, color: Colors.red), | ||||
|                                     onPressed: () => _deleteOption(option), | ||||
|                                     tooltip: 'Supprimer', | ||||
|                                   ), | ||||
|                                 ], | ||||
|                               ), | ||||
|                             ), | ||||
|                           ); | ||||
|                         }, | ||||
|                       ), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class _OptionFormDialog extends StatefulWidget { | ||||
|   final EventOption? option; | ||||
|   final Map<String, String> eventTypeNames; | ||||
|   final VoidCallback onSaved; | ||||
|  | ||||
|   const _OptionFormDialog({ | ||||
|     this.option, | ||||
|     required this.eventTypeNames, | ||||
|     required this.onSaved, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<_OptionFormDialog> createState() => _OptionFormDialogState(); | ||||
| } | ||||
|  | ||||
| class _OptionFormDialogState extends State<_OptionFormDialog> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|   final _codeController = TextEditingController(); | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _detailsController = TextEditingController(); | ||||
|   final _minPriceController = TextEditingController(); | ||||
|   final _maxPriceController = TextEditingController(); | ||||
|   List<String> _selectedTypes = []; | ||||
|   bool _loading = false; | ||||
|   String? _error; | ||||
|  | ||||
|   @override | ||||
|   void initState() { | ||||
|     super.initState(); | ||||
|     if (widget.option != null) { | ||||
|       _codeController.text = widget.option!.code; | ||||
|       _nameController.text = widget.option!.name; | ||||
|       _detailsController.text = widget.option!.details; | ||||
|       _minPriceController.text = widget.option!.valMin.toString(); | ||||
|       _maxPriceController.text = widget.option!.valMax.toString(); | ||||
|       _selectedTypes = List.from(widget.option!.eventTypes); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     _codeController.dispose(); | ||||
|     _nameController.dispose(); | ||||
|     _detailsController.dispose(); | ||||
|     _minPriceController.dispose(); | ||||
|     _maxPriceController.dispose(); | ||||
|     super.dispose(); | ||||
|   } | ||||
|  | ||||
|   Future<bool> _isCodeUnique(String code) async { | ||||
|     final doc = await FirebaseFirestore.instance | ||||
|         .collection('options') | ||||
|         .doc(code) | ||||
|         .get(); | ||||
|  | ||||
|     // Si on modifie et que c'est le même document, c'est OK | ||||
|     if (widget.option != null && widget.option!.id == code) { | ||||
|       return true; | ||||
|     } | ||||
|  | ||||
|     return !doc.exists; | ||||
|   } | ||||
|  | ||||
|   Future<void> _submit() async { | ||||
|     if (!_formKey.currentState!.validate()) return; | ||||
|     if (_selectedTypes.isEmpty) { | ||||
|       setState(() => _error = 'Sélectionnez au moins un type d\'événement'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     final code = _codeController.text.trim().toUpperCase(); | ||||
|     final name = _nameController.text.trim(); | ||||
|     final min = double.tryParse(_minPriceController.text.replaceAll(',', '.')); | ||||
|     final max = double.tryParse(_maxPriceController.text.replaceAll(',', '.')); | ||||
|  | ||||
|     if (min == null || max == null) { | ||||
|       setState(() => _error = 'Les prix doivent être des nombres valides'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     if (min > max) { | ||||
|       setState(() => _error = 'Le prix minimum ne peut pas être supérieur au prix maximum'); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     setState(() => _loading = true); | ||||
|  | ||||
|     try { | ||||
|       // Vérifier l'unicité du code seulement pour les nouvelles options | ||||
|       if (widget.option == null) { | ||||
|         final isUnique = await _isCodeUnique(code); | ||||
|         if (!isUnique) { | ||||
|           setState(() { | ||||
|             _error = 'Ce code d\'option existe déjà'; | ||||
|             _loading = false; | ||||
|           }); | ||||
|           return; | ||||
|         } | ||||
|       } | ||||
|  | ||||
|       final data = { | ||||
|         'code': code, | ||||
|         'name': name, | ||||
|         'details': _detailsController.text.trim(), | ||||
|         'valMin': min, | ||||
|         'valMax': max, | ||||
|         'eventTypes': _selectedTypes, | ||||
|       }; | ||||
|  | ||||
|       if (widget.option == null) { | ||||
|         // Création - utiliser le code comme ID | ||||
|         await FirebaseFirestore.instance.collection('options').doc(code).set(data); | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           const SnackBar(content: Text('Option créée avec succès')), | ||||
|         ); | ||||
|       } else { | ||||
|         // Modification | ||||
|         await FirebaseFirestore.instance | ||||
|             .collection('options') | ||||
|             .doc(widget.option!.id) | ||||
|             .update(data); | ||||
|         ScaffoldMessenger.of(context).showSnackBar( | ||||
|           const SnackBar(content: Text('Option modifiée avec succès')), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       widget.onSaved(); | ||||
|       Navigator.pop(context); | ||||
|     } catch (e) { | ||||
|       setState(() { | ||||
|         _error = 'Erreur : $e'; | ||||
|         _loading = false; | ||||
|       }); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return AlertDialog( | ||||
|       title: Text(widget.option == null ? 'Nouvelle option' : 'Modifier l\'option'), | ||||
|       content: SizedBox( | ||||
|         width: 500, | ||||
|         child: SingleChildScrollView( | ||||
|           child: Form( | ||||
|             key: _formKey, | ||||
|             child: Column( | ||||
|               mainAxisSize: MainAxisSize.min, | ||||
|               children: [ | ||||
|                 TextFormField( | ||||
|                   controller: _codeController, | ||||
|                   decoration: const InputDecoration( | ||||
|                     labelText: 'Code de l\'option *', | ||||
|                     hintText: 'Ex: TENT_50M2', | ||||
|                     helperText: 'Max 16 caractères, lettres, chiffres, _ et -', | ||||
|                     border: OutlineInputBorder(), | ||||
|                   ), | ||||
|                   enabled: widget.option == null, // Code non modifiable en édition | ||||
|                   maxLength: 16, | ||||
|                   textCapitalization: TextCapitalization.characters, | ||||
|                   validator: (v) { | ||||
|                     if (v == null || v.isEmpty) return 'Le code est obligatoire'; | ||||
|                     if (v.length > 16) return 'Maximum 16 caractères'; | ||||
|                     if (!RegExp(r'^[A-Z0-9_-]+$').hasMatch(v)) { | ||||
|                       return 'Seuls les lettres, chiffres, _ et - sont autorisés'; | ||||
|                     } | ||||
|                     return null; | ||||
|                   }, | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 TextFormField( | ||||
|                   controller: _nameController, | ||||
|                   decoration: const InputDecoration( | ||||
|                     labelText: 'Nom de l\'option *', | ||||
|                     border: OutlineInputBorder(), | ||||
|                   ), | ||||
|                   validator: (v) => v == null || v.trim().isEmpty ? 'Le nom est obligatoire' : null, | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 TextFormField( | ||||
|                   controller: _detailsController, | ||||
|                   decoration: const InputDecoration( | ||||
|                     labelText: 'Détails', | ||||
|                     border: OutlineInputBorder(), | ||||
|                   ), | ||||
|                   maxLines: 3, | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 Row( | ||||
|                   children: [ | ||||
|                     Expanded( | ||||
|                       child: TextFormField( | ||||
|                         controller: _minPriceController, | ||||
|                         decoration: const InputDecoration( | ||||
|                           labelText: 'Prix min (€) *', | ||||
|                           border: OutlineInputBorder(), | ||||
|                         ), | ||||
|                         keyboardType: const TextInputType.numberWithOptions(decimal: true), | ||||
|                         validator: (v) => v == null || v.isEmpty ? 'Obligatoire' : null, | ||||
|                       ), | ||||
|                     ), | ||||
|                     const SizedBox(width: 8), | ||||
|                     Expanded( | ||||
|                       child: TextFormField( | ||||
|                         controller: _maxPriceController, | ||||
|                         decoration: const InputDecoration( | ||||
|                           labelText: 'Prix max (€) *', | ||||
|                           border: OutlineInputBorder(), | ||||
|                         ), | ||||
|                         keyboardType: const TextInputType.numberWithOptions(decimal: true), | ||||
|                         validator: (v) => v == null || v.isEmpty ? 'Obligatoire' : null, | ||||
|                       ), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 const SizedBox(height: 16), | ||||
|                 Column( | ||||
|                   crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                   children: [ | ||||
|                     const Text('Types d\'événement associés *', | ||||
|                         style: TextStyle(fontWeight: FontWeight.w500)), | ||||
|                     const SizedBox(height: 8), | ||||
|                     Wrap( | ||||
|                       spacing: 8, | ||||
|                       children: widget.eventTypeNames.entries.map((entry) { | ||||
|                         return FilterChip( | ||||
|                           label: Text(entry.value), | ||||
|                           selected: _selectedTypes.contains(entry.key), | ||||
|                           onSelected: (selected) { | ||||
|                             setState(() { | ||||
|                               if (selected) { | ||||
|                                 _selectedTypes.add(entry.key); | ||||
|                               } else { | ||||
|                                 _selectedTypes.remove(entry.key); | ||||
|                               } | ||||
|                               _error = null; // Effacer l'erreur lors de la sélection | ||||
|                             }); | ||||
|                           }, | ||||
|                           selectedColor: AppColors.rouge.withOpacity(0.3), | ||||
|                         ); | ||||
|                       }).toList(), | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|                 if (_error != null) ...[ | ||||
|                   const SizedBox(height: 16), | ||||
|                   Text( | ||||
|                     _error!, | ||||
|                     style: const TextStyle(color: Colors.red), | ||||
|                   ), | ||||
|                 ], | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ), | ||||
|       actions: [ | ||||
|         TextButton( | ||||
|           onPressed: _loading ? null : () => Navigator.pop(context), | ||||
|           child: const Text('Annuler'), | ||||
|         ), | ||||
|         ElevatedButton( | ||||
|           onPressed: _loading ? null : _submit, | ||||
|           style: ElevatedButton.styleFrom( | ||||
|             backgroundColor: AppColors.rouge, | ||||
|             foregroundColor: Colors.white, | ||||
|           ), | ||||
|           child: _loading | ||||
|               ? const SizedBox( | ||||
|                   width: 20, | ||||
|                   height: 20, | ||||
|                   child: CircularProgressIndicator(strokeWidth: 2), | ||||
|                 ) | ||||
|               : Text(widget.option == null ? 'Créer' : 'Modifier'), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -6,7 +6,7 @@ import 'package:em2rp/models/event_type_model.dart'; | ||||
| class EventBasicInfoSection extends StatelessWidget { | ||||
|   final TextEditingController nameController; | ||||
|   final TextEditingController basePriceController; | ||||
|   final List<EventType> eventTypes; | ||||
|   final List<EventTypeModel> eventTypes; | ||||
|   final bool isLoadingEventTypes; | ||||
|   final String? selectedEventTypeId; | ||||
|   final DateTime? startDateTime; | ||||
|   | ||||
| @@ -67,7 +67,9 @@ class EventOptionsDisplayWidget extends StatelessWidget { | ||||
|                   return ListTile( | ||||
|                     leading: Icon(Icons.tune, color: AppColors.rouge), | ||||
|                     title: Text( | ||||
|                       opt['name'] ?? '', | ||||
|                       opt['code'] != null && opt['code'].toString().isNotEmpty | ||||
|                           ? '${opt['code']} - ${opt['name'] ?? ''}' | ||||
|                           : opt['name'] ?? '', | ||||
|                       style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                     ), | ||||
|                     subtitle: opt['details'] != null && opt['details'].toString().trim().isNotEmpty | ||||
| @@ -160,6 +162,7 @@ class EventOptionsDisplayWidget extends StatelessWidget { | ||||
|             // Combiner les données Firestore avec le prix choisi | ||||
|             enrichedOptions.add({ | ||||
|               'id': optionData['id'], | ||||
|               'code': firestoreData['code'] ?? optionData['id'], // Récupérer le code depuis Firestore | ||||
|               'name': firestoreData['name'], // Récupéré depuis Firestore | ||||
|               'details': firestoreData['details'] ?? '', // Récupéré depuis Firestore | ||||
|               'price': optionData['price'], // Prix choisi par l'utilisateur | ||||
|   | ||||
| @@ -205,14 +205,17 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> { | ||||
|             final firestoreData = doc.data()!; | ||||
|             enrichedOptions.add({ | ||||
|               'id': optionData['id'], | ||||
|               'name': firestoreData['name'], | ||||
|               'code': firestoreData['code'] ?? optionData['id'], // Récupérer le code | ||||
|               'name': firestoreData['code'] != null && firestoreData['code'].toString().isNotEmpty | ||||
|                   ? '${firestoreData['code']} - ${firestoreData['name']}' | ||||
|                   : firestoreData['name'], // Affichage avec code | ||||
|               'details': firestoreData['details'] ?? '', | ||||
|               'price': optionData['price'], | ||||
|             }); | ||||
|           } else { | ||||
|             enrichedOptions.add({ | ||||
|               'id': optionData['id'], | ||||
|               'name': 'Option supprimée', | ||||
|               'name': 'Option supprimée (${optionData['id']})', | ||||
|               'details': 'Cette option n\'existe plus', | ||||
|               'price': optionData['price'], | ||||
|             }); | ||||
| @@ -274,7 +277,10 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { | ||||
|       final matchesType = opt.eventTypes.contains(widget.eventType); | ||||
|       print('  -> matchesType: $matchesType'); | ||||
|  | ||||
|       final matchesSearch = opt.name.toLowerCase().contains(_search.toLowerCase()); | ||||
|       // 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'); | ||||
|  | ||||
|       final result = matchesType && matchesSearch; | ||||
| @@ -296,7 +302,7 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { | ||||
|               padding: const EdgeInsets.all(12.0), | ||||
|               child: TextField( | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Rechercher une option', | ||||
|                   labelText: 'Rechercher par code ou nom', | ||||
|                   prefixIcon: Icon(Icons.search), | ||||
|                 ), | ||||
|                 onChanged: (v) => setState(() => _search = v), | ||||
| @@ -312,7 +318,7 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { | ||||
|                       itemBuilder: (context, i) { | ||||
|                         final opt = filtered[i]; | ||||
|                         return ListTile( | ||||
|                           title: Text(opt.name), | ||||
|                           title: Text('${opt.code} - ${opt.name}'), // Affichage avec code | ||||
|                           subtitle: Text('${opt.details}\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}€'), | ||||
|                           onTap: () async { | ||||
|                             final min = opt.valMin; | ||||
| @@ -325,7 +331,7 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> { | ||||
|                                 final priceController = | ||||
|                                     TextEditingController(text: defaultPrice); | ||||
|                                 return AlertDialog( | ||||
|                                   title: Text('Prix pour ${opt.name}'), | ||||
|                                   title: Text('Prix pour ${opt.code} - ${opt.name}'), // Affichage avec code | ||||
|                                   content: TextField( | ||||
|                                     controller: priceController, | ||||
|                                     keyboardType: | ||||
| @@ -408,22 +414,24 @@ class _CreateOptionDialog extends StatefulWidget { | ||||
|  | ||||
| class _CreateOptionDialogState extends State<_CreateOptionDialog> { | ||||
|   final _formKey = GlobalKey<FormState>(); | ||||
|   final _codeController = TextEditingController(); // Nouveau champ code | ||||
|   final _nameController = TextEditingController(); | ||||
|   final _detailsController = TextEditingController(); | ||||
|   final _minPriceController = TextEditingController(); | ||||
|   final _maxPriceController = TextEditingController(); | ||||
|   final List<String> _selectedTypes = []; | ||||
|   String? _error; | ||||
|   bool _checkingName = false; | ||||
|   bool _checkingCode = false; | ||||
|   List<Map<String,dynamic>> _allEventTypes = []; | ||||
|   bool _loading = true; | ||||
|  | ||||
|   Future<bool> _isNameUnique(String name) async { | ||||
|     final snap = await FirebaseFirestore.instance | ||||
|   Future<bool> _isCodeUnique(String code) async { | ||||
|     // Vérifier si le document avec ce code existe déjà | ||||
|     final doc = await FirebaseFirestore.instance | ||||
|         .collection('options') | ||||
|         .where('name', isEqualTo: name) | ||||
|         .doc(code) | ||||
|         .get(); | ||||
|     return snap.docs.isEmpty; | ||||
|     return !doc.exists; | ||||
|   } | ||||
|  | ||||
|   Future<void> _fetchEventTypes() async { | ||||
| @@ -453,6 +461,25 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { | ||||
|           child: Column( | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               TextFormField( | ||||
|                 controller: _codeController, | ||||
|                 decoration: const InputDecoration( | ||||
|                   labelText: 'Code de l\'option', | ||||
|                   hintText: 'Ex: M2', | ||||
|                   helperText: 'Max 16 caractères, lettres, chiffres, _ et -', | ||||
|                 ), | ||||
|                 maxLength: 16, | ||||
|                 textCapitalization: TextCapitalization.characters, | ||||
|                 validator: (v) { | ||||
|                   if (v == null || v.isEmpty) return 'Champ requis'; | ||||
|                   if (v.length > 16) return 'Maximum 16 caractères'; | ||||
|                   if (!RegExp(r'^[A-Z0-9_-]+$').hasMatch(v)) { | ||||
|                     return 'Seuls les lettres, chiffres, _ et - sont autorisés'; | ||||
|                   } | ||||
|                   return null; | ||||
|                 }, | ||||
|               ), | ||||
|               const SizedBox(height: 8), | ||||
|               TextFormField( | ||||
|                 controller: _nameController, | ||||
|                 decoration: | ||||
| @@ -535,7 +562,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { | ||||
|           child: const Text('Annuler'), | ||||
|         ), | ||||
|         ElevatedButton( | ||||
|           onPressed: _checkingName | ||||
|           onPressed: _checkingCode | ||||
|               ? null | ||||
|               : () async { | ||||
|                   if (!_formKey.currentState!.validate()) return; | ||||
| @@ -553,19 +580,21 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { | ||||
|                         () => _error = 'Prix min et max doivent être valides'); | ||||
|                     return; | ||||
|                   } | ||||
|                   final code = _codeController.text.trim().toUpperCase(); | ||||
|                   final name = _nameController.text.trim(); | ||||
|                   setState(() => _checkingName = true); | ||||
|                   final unique = await _isNameUnique(name); | ||||
|                   setState(() => _checkingName = false); | ||||
|  | ||||
|                   setState(() => _checkingCode = true); | ||||
|                   final unique = await _isCodeUnique(code); | ||||
|                   setState(() => _checkingCode = false); | ||||
|                   if (!unique) { | ||||
|                     setState( | ||||
|                         () => _error = 'Ce nom d\'option est déjà utilisé.'); | ||||
|                         () => _error = 'Ce code d\'option est déjà utilisé.'); | ||||
|                     return; | ||||
|                   } | ||||
|                   try { | ||||
|                     // Debug : afficher le contenu envoyé | ||||
|                     print('Enregistrement option avec eventTypes : $_selectedTypes\u001b'); | ||||
|                     await FirebaseFirestore.instance.collection('options').add({ | ||||
|                     // Utiliser le code comme identifiant du document | ||||
|                     await FirebaseFirestore.instance.collection('options').doc(code).set({ | ||||
|                       'code': code, | ||||
|                       'name': name, | ||||
|                       'details': _detailsController.text.trim(), | ||||
|                       'valMin': min, | ||||
| @@ -574,10 +603,10 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> { | ||||
|                     }); | ||||
|                     Navigator.pop(context, true); | ||||
|                   } catch (e) { | ||||
|                     setState(() => _error = 'Erreur lors de la création : $e\nEventTypes=$_selectedTypes'); | ||||
|                     setState(() => _error = 'Erreur lors de la création : $e'); | ||||
|                   } | ||||
|                 }, | ||||
|           child: _checkingName | ||||
|           child: _checkingCode | ||||
|               ? const SizedBox( | ||||
|                   width: 18, | ||||
|                   height: 18, | ||||
|   | ||||
| @@ -3,6 +3,7 @@ import 'package:em2rp/utils/colors.dart'; | ||||
| import 'package:em2rp/views/calendar_page.dart'; | ||||
| import 'package:em2rp/views/my_account_page.dart'; | ||||
| import 'package:em2rp/views/user_management_page.dart'; | ||||
| import 'package:em2rp/views/data_management_page.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:em2rp/views/widgets/image/profile_picture.dart'; | ||||
| import 'package:provider/provider.dart'; | ||||
| @@ -133,6 +134,20 @@ class MainDrawer extends StatelessWidget { | ||||
|                         }, | ||||
|                       ), | ||||
|                     ), | ||||
|                     ListTile( | ||||
|                       leading: const Icon(Icons.data_usage), | ||||
|                       title: const Text('Gestion des Données'), | ||||
|                       selected: currentPage == '/data_management', | ||||
|                       selectedColor: AppColors.rouge, | ||||
|                       onTap: () { | ||||
|                         Navigator.pop(context); | ||||
|                         Navigator.pushReplacement( | ||||
|                           context, | ||||
|                           MaterialPageRoute( | ||||
|                               builder: (context) => const DataManagementPage()), | ||||
|                         ); | ||||
|                       }, | ||||
|                     ), | ||||
|                   ], | ||||
|                 ), | ||||
|               ), | ||||
|   | ||||
		Reference in New Issue
	
	Block a user
	 ElPoyo
					ElPoyo