diff --git a/em2rp/lib/controllers/event_form_controller.dart b/em2rp/lib/controllers/event_form_controller.dart index 984debf..0e26cca 100644 --- a/em2rp/lib/controllers/event_form_controller.dart +++ b/em2rp/lib/controllers/event_form_controller.dart @@ -25,7 +25,7 @@ class EventFormController extends ChangeNotifier { String? _error; String? _success; String? _selectedEventTypeId; - List _eventTypes = []; + List _eventTypes = []; bool _isLoadingEventTypes = true; List _selectedUserIds = []; List _allUsers = []; @@ -42,7 +42,7 @@ class EventFormController extends ChangeNotifier { String? get error => _error; String? get success => _success; String? get selectedEventTypeId => _selectedEventTypeId; - List get eventTypes => _eventTypes; + List get eventTypes => _eventTypes; bool get isLoadingEventTypes => _isLoadingEventTypes; List get selectedUserIds => _selectedUserIds; List get allUsers => _allUsers; @@ -147,26 +147,30 @@ class EventFormController extends ChangeNotifier { final oldEventTypeIndex = _selectedEventTypeId != null ? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId) : -1; - final EventType? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null; + final EventTypeModel? oldEventType = oldEventTypeIndex != -1 ? _eventTypes[oldEventTypeIndex] : null; _selectedEventTypeId = newTypeId; if (newTypeId != null) { final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId); + + // Utiliser le prix par défaut du type d'événement final defaultPrice = selectedType.defaultPrice; final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.')); final oldDefaultPrice = oldEventType?.defaultPrice; + // Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut if (basePriceController.text.isEmpty || (currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) { basePriceController.text = defaultPrice.toStringAsFixed(2); } + // Filtrer les options qui ne sont plus compatibles avec le nouveau type final before = _selectedOptions.length; _selectedOptions.removeWhere((opt) { - final types = opt['compatibleTypes'] as List?; - if (types == null) return true; - return !types.contains(selectedType.name); + // Vérifier si cette option est compatible avec le type d'événement sélectionné + final optionEventTypes = opt['eventTypes'] as List? ?? []; + return !optionEventTypes.contains(selectedType.id); }); if (_selectedOptions.length < before) { diff --git a/em2rp/lib/models/event_type_model.dart b/em2rp/lib/models/event_type_model.dart index 1acb99d..72f5766 100644 --- a/em2rp/lib/models/event_type_model.dart +++ b/em2rp/lib/models/event_type_model.dart @@ -1,32 +1,30 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -class EventType { +class EventTypeModel { final String id; final String name; final double defaultPrice; + final DateTime createdAt; - EventType({ + EventTypeModel({ required this.id, required this.name, required this.defaultPrice, + required this.createdAt, }); - factory EventType.fromFirestore(DocumentSnapshot doc) { - Map data = doc.data() as Map; - - double price = 0.0; - final priceData = data['defaultPrice']; - if (priceData is num) { - price = priceData.toDouble(); - } else if (priceData is String) { - price = double.tryParse(priceData.replaceAll(',', '.')) ?? 0.0; - } - - return EventType( - id: doc.id, - name: data['name'] ?? '', - defaultPrice: price, + factory EventTypeModel.fromMap(Map map, String id) { + return EventTypeModel( + id: id, + name: map['name'] ?? '', + defaultPrice: (map['defaultPrice'] ?? 0.0).toDouble(), + createdAt: map['createdAt']?.toDate() ?? DateTime.now(), ); } -} + Map toMap() { + return { + 'name': name, + 'defaultPrice': defaultPrice, + 'createdAt': createdAt, + }; + } +} diff --git a/em2rp/lib/models/option_model.dart b/em2rp/lib/models/option_model.dart index 595f18d..8745537 100644 --- a/em2rp/lib/models/option_model.dart +++ b/em2rp/lib/models/option_model.dart @@ -1,6 +1,6 @@ - class EventOption { final String id; + final String code; // Nouveau champ code final String name; final String details; final double valMin; @@ -9,6 +9,7 @@ class EventOption { EventOption({ required this.id, + required this.code, required this.name, required this.details, required this.valMin, @@ -19,6 +20,7 @@ class EventOption { factory EventOption.fromMap(Map map, String id) { return EventOption( id: id, + code: map['code'] ?? id, // Utilise le code ou l'ID en fallback name: map['name'] ?? '', details: map['details'] ?? '', valMin: (map['valMin'] ?? 0.0).toDouble(), @@ -31,6 +33,7 @@ class EventOption { Map toMap() { return { + 'code': code, 'name': name, 'details': details, 'valMin': valMin, diff --git a/em2rp/lib/services/event_form_service.dart b/em2rp/lib/services/event_form_service.dart index bc147c6..f50c68f 100644 --- a/em2rp/lib/services/event_form_service.dart +++ b/em2rp/lib/services/event_form_service.dart @@ -10,11 +10,11 @@ import 'package:em2rp/models/user_model.dart'; import 'dart:developer' as developer; class EventFormService { - static Future> fetchEventTypes() async { + static Future> fetchEventTypes() async { developer.log('Fetching event types from Firestore...', name: 'EventFormService'); try { final snapshot = await FirebaseFirestore.instance.collection('eventTypes').get(); - final eventTypes = snapshot.docs.map((doc) => EventType.fromFirestore(doc)).toList(); + final eventTypes = snapshot.docs.map((doc) => EventTypeModel.fromMap(doc.data(), doc.id)).toList(); developer.log('${eventTypes.length} event types loaded.', name: 'EventFormService'); return eventTypes; } catch (e, s) { diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index dbaa4ec..0500d26 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -1,7 +1,7 @@ import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/event_provider.dart'; import 'package:flutter/material.dart'; -import 'package:em2rp/views/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; diff --git a/em2rp/lib/views/data_management_page.dart b/em2rp/lib/views/data_management_page.dart new file mode 100644 index 0000000..7857cdc --- /dev/null +++ b/em2rp/lib/views/data_management_page.dart @@ -0,0 +1,171 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/views/widgets/data_management/event_types_management.dart'; +import 'package:em2rp/views/widgets/data_management/options_management.dart'; +import 'package:em2rp/views/widgets/nav/main_drawer.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +class DataManagementPage extends StatefulWidget { + const DataManagementPage({super.key}); + + @override + State createState() => _DataManagementPageState(); +} + +class _DataManagementPageState extends State { + int _selectedIndex = 0; + + final List _categories = [ + DataCategory( + title: 'Types d\'événements', + icon: Icons.category, + widget: const EventTypesManagement(), + ), + DataCategory( + title: 'Options', + icon: Icons.tune, + widget: const OptionsManagement(), + ), + ]; + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 800; + + return Scaffold( + appBar: CustomAppBar(title: 'Gestion des données'), + drawer: const MainDrawer(currentPage: '/data_management'), // Ajout du drawer + body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + // Menu horizontal en mobile + Container( + height: 60, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: _categories.length, + itemBuilder: (context, index) { + final isSelected = index == _selectedIndex; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _categories[index].icon, + size: 16, + color: isSelected ? Colors.white : AppColors.rouge, + ), + const SizedBox(width: 8), + Text(_categories[index].title), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() => _selectedIndex = index); + } + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: isSelected ? Colors.white : AppColors.rouge, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + }, + ), + ), + const Divider(), + // Contenu + Expanded( + child: _categories[_selectedIndex].widget, + ), + ], + ); + } + + Widget _buildDesktopLayout() { + return Row( + children: [ + // Sidebar gauche + Container( + width: 280, + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border( + right: BorderSide(color: Colors.grey, width: 1), + ), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.rouge.withOpacity(0.1), + ), + child: Row( + children: [ + Icon(Icons.settings, color: AppColors.rouge), + const SizedBox(width: 12), + Text( + 'Catégories de données', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.rouge, + ), + ), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: _categories.length, + itemBuilder: (context, index) { + final isSelected = index == _selectedIndex; + return ListTile( + leading: Icon( + _categories[index].icon, + color: isSelected ? AppColors.rouge : Colors.grey[600], + ), + title: Text( + _categories[index].title, + style: TextStyle( + color: isSelected ? AppColors.rouge : Colors.black87, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + selected: isSelected, + selectedTileColor: AppColors.rouge.withOpacity(0.1), + onTap: () => setState(() => _selectedIndex = index), + ); + }, + ), + ), + ], + ), + ), + // Contenu principal + Expanded( + child: _categories[_selectedIndex].widget, + ), + ], + ); + } +} + +class DataCategory { + final String title; + final IconData icon; + final Widget widget; + + DataCategory({ + required this.title, + required this.icon, + required this.widget, + }); +} diff --git a/em2rp/lib/views/my_account_page.dart b/em2rp/lib/views/my_account_page.dart index c9d4e2c..ec4ab68 100644 --- a/em2rp/lib/views/my_account_page.dart +++ b/em2rp/lib/views/my_account_page.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/views/widgets/inputs/styled_text_field.dart'; import 'package:em2rp/views/widgets/image/profile_picture_selector.dart'; -import 'package:em2rp/views/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; class MyAccountPage extends StatelessWidget { const MyAccountPage({super.key}); diff --git a/em2rp/lib/views/user_management_page.dart b/em2rp/lib/views/user_management_page.dart index 56afdf8..1de8ab0 100644 --- a/em2rp/lib/views/user_management_page.dart +++ b/em2rp/lib/views/user_management_page.dart @@ -8,7 +8,7 @@ import 'package:em2rp/views/widgets/user_management/edit_user_dialog.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/models/role_model.dart'; -import 'package:em2rp/views/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; class UserManagementPage extends StatefulWidget { diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart index a4c1a35..55baf02 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart @@ -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'), + ], + ), + ); + }, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/em2rp/lib/views/widgets/data_management/event_types_management.dart b/em2rp/lib/views/widgets/data_management/event_types_management.dart new file mode 100644 index 0000000..53a721e --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/event_types_management.dart @@ -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 createState() => _EventTypesManagementState(); +} + +class _EventTypesManagementState extends State { + String _searchQuery = ''; + List _eventTypes = []; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadEventTypes(); + } + + Future _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 get _filteredEventTypes { + if (_searchQuery.isEmpty) return _eventTypes; + return _eventTypes.where((type) => + type.name.toLowerCase().contains(_searchQuery.toLowerCase()) + ).toList(); + } + + Future _canDeleteEventType(String eventTypeId) async { + final eventsSnapshot = await FirebaseFirestore.instance + .collection('events') + .where('eventTypeId', isEqualTo: eventTypeId) + .get(); + + return eventsSnapshot.docs.isEmpty; + } + + Future>> _getBlockingEvents(String eventTypeId) async { + final eventsSnapshot = await FirebaseFirestore.instance + .collection('events') + .where('eventTypeId', isEqualTo: eventTypeId) + .get(); + + final now = DateTime.now(); + List> futureEvents = []; + List> 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 _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> 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(); + 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 _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 _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'), + ), + ], + ); + } +} diff --git a/em2rp/lib/views/widgets/data_management/options_management.dart b/em2rp/lib/views/widgets/data_management/options_management.dart new file mode 100644 index 0000000..210aaf1 --- /dev/null +++ b/em2rp/lib/views/widgets/data_management/options_management.dart @@ -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 createState() => _OptionsManagementState(); +} + +class _OptionsManagementState extends State { + String _searchQuery = ''; + List _options = []; + Map _eventTypeNames = {}; + bool _loading = true; + + @override + void initState() { + super.initState(); + _loadData(); + } + + Future _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 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>> _getBlockingEvents(String optionId) async { + final eventsSnapshot = await FirebaseFirestore.instance + .collection('events') + .get(); + + final now = DateTime.now(); + List> futureEvents = []; + List> pastEvents = []; + + for (final doc in eventsSnapshot.docs) { + final eventData = doc.data(); + final options = eventData['options'] as List? ?? []; + + // 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 _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> 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 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(); + final _codeController = TextEditingController(); + final _nameController = TextEditingController(); + final _detailsController = TextEditingController(); + final _minPriceController = TextEditingController(); + final _maxPriceController = TextEditingController(); + List _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 _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 _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'), + ), + ], + ); + } +} 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 2dac8ca..72a7adc 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 @@ -6,7 +6,7 @@ import 'package:em2rp/models/event_type_model.dart'; class EventBasicInfoSection extends StatelessWidget { final TextEditingController nameController; final TextEditingController basePriceController; - final List eventTypes; + final List eventTypes; final bool isLoadingEventTypes; final String? selectedEventTypeId; final DateTime? startDateTime; diff --git a/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart b/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart index c885603..2659b21 100644 --- a/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart +++ b/em2rp/lib/views/widgets/event_form/event_options_display_widget.dart @@ -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 diff --git a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart index cef2b3b..8467069 100644 --- a/em2rp/lib/views/widgets/inputs/option_selector_widget.dart +++ b/em2rp/lib/views/widgets/inputs/option_selector_widget.dart @@ -205,14 +205,17 @@ class _OptionSelectorWidgetState extends State { 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(); + final _codeController = TextEditingController(); // Nouveau champ code final _nameController = TextEditingController(); final _detailsController = TextEditingController(); final _minPriceController = TextEditingController(); final _maxPriceController = TextEditingController(); final List _selectedTypes = []; String? _error; - bool _checkingName = false; + bool _checkingCode = false; List> _allEventTypes = []; bool _loading = true; - Future _isNameUnique(String name) async { - final snap = await FirebaseFirestore.instance + Future _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 _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, diff --git a/em2rp/lib/views/widgets/custom_app_bar.dart b/em2rp/lib/views/widgets/nav/custom_app_bar.dart similarity index 100% rename from em2rp/lib/views/widgets/custom_app_bar.dart rename to em2rp/lib/views/widgets/nav/custom_app_bar.dart diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 3e31caf..3e2917f 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -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()), + ); + }, + ), ], ), ),