feat: Ajout de la gestion de la quantité pour les options d'événement

This commit is contained in:
ElPoyo
2025-12-16 19:23:48 +01:00
parent 08f046c89c
commit 28d9e008af
6 changed files with 240 additions and 28 deletions

View File

@@ -6,6 +6,7 @@ class EventOption {
final double valMin; final double valMin;
final double valMax; final double valMax;
final List<String> eventTypes; // Changé de List<DocumentReference> à List<String> final List<String> eventTypes; // Changé de List<DocumentReference> à List<String>
final bool isQuantitative; // Indique si l'option peut avoir une quantité
EventOption({ EventOption({
required this.id, required this.id,
@@ -15,6 +16,7 @@ class EventOption {
required this.valMin, required this.valMin,
required this.valMax, required this.valMax,
required this.eventTypes, required this.eventTypes,
this.isQuantitative = false,
}); });
factory EventOption.fromMap(Map<String, dynamic> map, String id) { factory EventOption.fromMap(Map<String, dynamic> map, String id) {
@@ -28,6 +30,7 @@ class EventOption {
eventTypes: (map['eventTypes'] as List<dynamic>? ?? []) eventTypes: (map['eventTypes'] as List<dynamic>? ?? [])
.map((e) => e.toString()) // Convertit en String (supporte IDs et références) .map((e) => e.toString()) // Convertit en String (supporte IDs et références)
.toList(), .toList(),
isQuantitative: map['isQuantitative'] ?? false,
); );
} }
@@ -39,6 +42,7 @@ class EventOption {
'valMin': valMin, 'valMin': valMin,
'valMax': valMax, 'valMax': valMax,
'eventTypes': eventTypes, 'eventTypes': eventTypes,
'isQuantitative': isQuantitative,
}; };
} }
} }

View File

@@ -1,11 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:em2rp/models/event_model.dart'; import 'package:em2rp/models/event_model.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/views/event_add_page.dart'; import 'package:em2rp/views/event_add_page.dart';
class EventDetailsHeader extends StatelessWidget { class EventDetailsHeader extends StatefulWidget {
final EventModel event; final EventModel event;
const EventDetailsHeader({ const EventDetailsHeader({
@@ -13,6 +14,52 @@ class EventDetailsHeader extends StatelessWidget {
required this.event, required this.event,
}); });
@override
State<EventDetailsHeader> createState() => _EventDetailsHeaderState();
}
class _EventDetailsHeaderState extends State<EventDetailsHeader> {
String? _eventTypeName;
bool _isLoadingEventType = true;
@override
void initState() {
super.initState();
_fetchEventTypeName();
}
Future<void> _fetchEventTypeName() async {
try {
if (widget.event.eventTypeId.isEmpty) {
setState(() => _isLoadingEventType = false);
return;
}
final doc = await FirebaseFirestore.instance
.collection('eventTypes')
.doc(widget.event.eventTypeId)
.get();
if (doc.exists) {
setState(() {
_eventTypeName = doc.data()?['name'] as String? ?? widget.event.eventTypeId;
_isLoadingEventType = false;
});
} else {
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
} catch (e) {
print('Erreur lors du chargement du type d\'événement: $e');
setState(() {
_eventTypeName = widget.event.eventTypeId;
_isLoadingEventType = false;
});
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
@@ -25,7 +72,7 @@ class EventDetailsHeader extends StatelessWidget {
Container( Container(
constraints: const BoxConstraints(maxHeight: 80), constraints: const BoxConstraints(maxHeight: 80),
child: SelectableText( child: SelectableText(
event.name, widget.event.name,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.noir, color: AppColors.noir,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -34,7 +81,9 @@ class EventDetailsHeader extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
event.eventTypeId, _isLoadingEventType
? 'Chargement...'
: _eventTypeName ?? widget.event.eventTypeId,
style: Theme.of(context).textTheme.bodyMedium?.copyWith( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: AppColors.rouge, color: AppColors.rouge,
), ),
@@ -43,7 +92,7 @@ class EventDetailsHeader extends StatelessWidget {
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
_buildStatusIcon(event.status), _buildStatusIcon(widget.event.status),
if (Provider.of<LocalUserProvider>(context, listen: false) if (Provider.of<LocalUserProvider>(context, listen: false)
.hasPermission('edit_event')) ...[ .hasPermission('edit_event')) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -53,7 +102,7 @@ class EventDetailsHeader extends StatelessWidget {
onPressed: () { onPressed: () {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => EventAddEditPage(event: event), builder: (context) => EventAddEditPage(event: widget.event),
), ),
); );
}, },

View File

@@ -55,7 +55,11 @@ class EventDetailsInfo extends StatelessWidget {
final total = event.basePrice + final total = event.basePrice +
event.options.fold<num>( event.options.fold<num>(
0, 0,
(sum, opt) => sum + (opt['price'] ?? 0.0), (sum, opt) {
final price = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity);
},
); );
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),

View File

@@ -392,6 +392,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
final _minPriceController = TextEditingController(); final _minPriceController = TextEditingController();
final _maxPriceController = TextEditingController(); final _maxPriceController = TextEditingController();
List<String> _selectedTypes = []; List<String> _selectedTypes = [];
bool _isQuantitative = false;
bool _loading = false; bool _loading = false;
String? _error; String? _error;
@@ -405,6 +406,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
_minPriceController.text = widget.option!.valMin.toString(); _minPriceController.text = widget.option!.valMin.toString();
_maxPriceController.text = widget.option!.valMax.toString(); _maxPriceController.text = widget.option!.valMax.toString();
_selectedTypes = List.from(widget.option!.eventTypes); _selectedTypes = List.from(widget.option!.eventTypes);
_isQuantitative = widget.option!.isQuantitative;
} }
} }
@@ -476,6 +478,7 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
'valMin': min, 'valMin': min,
'valMax': max, 'valMax': max,
'eventTypes': _selectedTypes, 'eventTypes': _selectedTypes,
'isQuantitative': _isQuantitative,
}; };
if (widget.option == null) { if (widget.option == null) {
@@ -584,6 +587,18 @@ class _OptionFormDialogState extends State<_OptionFormDialog> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
CheckboxListTile(
title: const Text('Option quantitative'),
subtitle: const Text('Permet de spécifier une quantité lors de l\'ajout à un événement'),
value: _isQuantitative,
onChanged: (value) {
setState(() {
_isQuantitative = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
),
const SizedBox(height: 16),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [

View File

@@ -62,7 +62,9 @@ class EventOptionsDisplayWidget extends StatelessWidget {
children: [ children: [
...enrichedOptions.map((opt) { ...enrichedOptions.map((opt) {
final price = (opt['price'] ?? 0.0) as num; final price = (opt['price'] ?? 0.0) as num;
final isNegative = price < 0; final quantity = (opt['quantity'] ?? 1) as int;
final totalPrice = price * quantity;
final isNegative = totalPrice < 0;
return ListTile( return ListTile(
leading: Icon(Icons.tune, color: AppColors.rouge), leading: Icon(Icons.tune, color: AppColors.rouge),
@@ -72,8 +74,11 @@ class EventOptionsDisplayWidget extends StatelessWidget {
: opt['name'] ?? '', : opt['name'] ?? '',
style: const TextStyle(fontWeight: FontWeight.bold), style: const TextStyle(fontWeight: FontWeight.bold),
), ),
subtitle: opt['details'] != null && opt['details'].toString().trim().isNotEmpty subtitle: Column(
? Text( crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (opt['details'] != null && opt['details'].toString().trim().isNotEmpty)
Text(
opt['details'].toString().trim(), opt['details'].toString().trim(),
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@@ -82,7 +87,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
) )
: Text( else
Text(
'Aucun détail disponible', 'Aucun détail disponible',
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
@@ -91,10 +97,24 @@ class EventOptionsDisplayWidget extends StatelessWidget {
fontStyle: FontStyle.italic, fontStyle: FontStyle.italic,
), ),
), ),
if (quantity > 1 && canViewPrices)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${currencyFormat.format(price)} × $quantity',
style: TextStyle(
color: Colors.grey[700],
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
trailing: canViewPrices trailing: canViewPrices
? Text( ? Text(
(isNegative ? '- ' : '+ ') + (isNegative ? '- ' : '+ ') +
currencyFormat.format(price.abs()), currencyFormat.format(totalPrice.abs()),
style: TextStyle( style: TextStyle(
color: isNegative ? Colors.red : AppColors.noir, color: isNegative ? Colors.red : AppColors.noir,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -118,7 +138,11 @@ class EventOptionsDisplayWidget extends StatelessWidget {
} }
Widget _buildTotalPrice(BuildContext context, List<Map<String, dynamic>> options, NumberFormat currencyFormat) { Widget _buildTotalPrice(BuildContext context, List<Map<String, dynamic>> options, NumberFormat currencyFormat) {
final optionsTotal = options.fold<num>(0, (sum, opt) => sum + (opt['price'] ?? 0.0)); final optionsTotal = options.fold<num>(0, (sum, opt) {
final price = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity);
});
return Padding( return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0), padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
@@ -166,6 +190,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': firestoreData['name'], // Récupéré depuis Firestore 'name': firestoreData['name'], // Récupéré depuis Firestore
'details': firestoreData['details'] ?? '', // Récupéré depuis Firestore 'details': firestoreData['details'] ?? '', // Récupéré depuis Firestore
'price': optionData['price'], // Prix choisi par l'utilisateur 'price': optionData['price'], // Prix choisi par l'utilisateur
'quantity': optionData['quantity'] ?? 1, // Quantité
'isQuantitative': firestoreData['isQuantitative'] ?? false,
'valMin': firestoreData['valMin'], 'valMin': firestoreData['valMin'],
'valMax': firestoreData['valMax'], 'valMax': firestoreData['valMax'],
}); });
@@ -176,6 +202,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': 'Option supprimée (ID: ${optionData['id']})', 'name': 'Option supprimée (ID: ${optionData['id']})',
'details': 'Cette option n\'existe plus dans la base de données', 'details': 'Cette option n\'existe plus dans la base de données',
'price': optionData['price'], 'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
}); });
} }
} else { } else {
@@ -185,6 +213,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': optionData['name'] ?? 'Option inconnue', 'name': optionData['name'] ?? 'Option inconnue',
'details': optionData['details'] ?? 'Aucun détail disponible', 'details': optionData['details'] ?? 'Aucun détail disponible',
'price': optionData['price'] ?? 0.0, 'price': optionData['price'] ?? 0.0,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
}); });
} }
} catch (e) { } catch (e) {
@@ -195,6 +225,8 @@ class EventOptionsDisplayWidget extends StatelessWidget {
'name': 'Erreur de chargement (ID: ${optionData['id']})', 'name': 'Erreur de chargement (ID: ${optionData['id']})',
'details': 'Impossible de charger les détails de cette option', 'details': 'Impossible de charger les détails de cette option',
'price': optionData['price'] ?? 0.0, 'price': optionData['price'] ?? 0.0,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
}); });
} }
} }

View File

@@ -149,8 +149,65 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
child: Text(opt['details'], child: Text(opt['details'],
style: const TextStyle(fontSize: 13)), style: const TextStyle(fontSize: 13)),
), ),
Text('Prix : ${opt['price'] ?? ''}', Row(
children: [
Text('Prix unitaire : ${opt['price'] ?? ''}',
style: const TextStyle(fontSize: 13)), style: const TextStyle(fontSize: 13)),
if (opt['isQuantitative'] == true && opt['quantity'] != null && opt['quantity'] > 1) ...[
const Text(' × ', style: TextStyle(fontSize: 13)),
Text('${opt['quantity']}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold)),
Text(' = ${((opt['price'] ?? 0) * (opt['quantity'] ?? 1)).toStringAsFixed(2)}',
style: const TextStyle(fontSize: 13, color: Colors.green, fontWeight: FontWeight.bold)),
],
],
),
if (opt['isQuantitative'] == true)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
const Text('Quantité : ', style: TextStyle(fontSize: 12)),
IconButton(
icon: const Icon(Icons.remove_circle_outline, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final currentQty = opt['quantity'] ?? 1;
if (currentQty > 1) {
final newList = List<Map<String, dynamic>>.from(widget.selectedOptions);
final index = newList.indexWhere((o) => o['id'] == opt['id']);
if (index != -1) {
newList[index] = {...newList[index], 'quantity': currentQty - 1};
widget.onChanged(newList);
setState(() => _rebuildKey++);
}
}
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text('${opt['quantity'] ?? 1}',
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
),
IconButton(
icon: const Icon(Icons.add_circle_outline, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
final currentQty = opt['quantity'] ?? 1;
final newList = List<Map<String, dynamic>>.from(widget.selectedOptions);
final index = newList.indexWhere((o) => o['id'] == opt['id']);
if (index != -1) {
newList[index] = {...newList[index], 'quantity': currentQty + 1};
widget.onChanged(newList);
setState(() => _rebuildKey++);
}
},
),
],
),
),
], ],
), ),
), ),
@@ -224,6 +281,8 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
: firestoreData['name'], // Affichage avec code : firestoreData['name'], // Affichage avec code
'details': firestoreData['details'] ?? '', 'details': firestoreData['details'] ?? '',
'price': optionData['price'], 'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': firestoreData['isQuantitative'] ?? false,
}); });
} else { } else {
enrichedOptions.add({ enrichedOptions.add({
@@ -231,11 +290,17 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
'name': 'Option supprimée (${optionData['id']})', 'name': 'Option supprimée (${optionData['id']})',
'details': 'Cette option n\'existe plus', 'details': 'Cette option n\'existe plus',
'price': optionData['price'], 'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
}); });
} }
} else { } else {
// Ancien format, utiliser les données locales // Ancien format, utiliser les données locales
enrichedOptions.add(optionData); enrichedOptions.add({
...optionData,
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': optionData['isQuantitative'] ?? false,
});
} }
} catch (e) { } catch (e) {
// En cas d'erreur, utiliser les données disponibles // En cas d'erreur, utiliser les données disponibles
@@ -244,6 +309,8 @@ class _OptionSelectorWidgetState extends State<OptionSelectorWidget> {
'name': optionData['name'] ?? 'Erreur de chargement', 'name': optionData['name'] ?? 'Erreur de chargement',
'details': 'Impossible de charger les détails', 'details': 'Impossible de charger les détails',
'price': optionData['price'], 'price': optionData['price'],
'quantity': optionData['quantity'] ?? 1,
'isQuantitative': false,
}); });
} }
} }
@@ -354,27 +421,46 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
itemBuilder: (context, i) { itemBuilder: (context, i) {
final opt = filtered[i]; final opt = filtered[i];
return ListTile( return ListTile(
title: Text('${opt.code} - ${opt.name}'), // Affichage avec code title: Text('${opt.code} - ${opt.name}${opt.isQuantitative ? ' (Quantitatif)' : ''}'), // Affichage avec code
subtitle: Text('${opt.details}\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}'), subtitle: Text('${opt.details}\nFourchette: ${opt.valMin}€ ~ ${opt.valMax}'),
onTap: () async { onTap: () async {
final min = opt.valMin; final min = opt.valMin;
final max = opt.valMax; final max = opt.valMax;
final defaultPrice = final defaultPrice =
((min + max) / 2).toStringAsFixed(2); ((min + max) / 2).toStringAsFixed(2);
final price = await showDialog<double>(
final result = await showDialog<Map<String, dynamic>>(
context: context, context: context,
builder: (ctx) { builder: (ctx) {
final priceController = final priceController =
TextEditingController(text: defaultPrice); TextEditingController(text: defaultPrice);
final quantityController =
TextEditingController(text: '1');
return AlertDialog( return AlertDialog(
title: Text('Prix pour ${opt.code} - ${opt.name}'), // Affichage avec code title: Text('Ajouter ${opt.code} - ${opt.name}'), // Affichage avec code
content: TextField( content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: priceController, controller: priceController,
keyboardType: keyboardType:
const TextInputType.numberWithOptions( const TextInputType.numberWithOptions(
decimal: true), decimal: true),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Prix (€)'), labelText: 'Prix unitaire (€)'),
),
if (opt.isQuantitative) ...[
const SizedBox(height: 16),
TextField(
controller: quantityController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Quantité',
helperText: 'Le prix sera multiplié par la quantité',
),
),
],
],
), ),
actions: [ actions: [
TextButton( TextButton(
@@ -387,7 +473,13 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
priceController.text priceController.text
.replaceAll(',', '.')) ?? .replaceAll(',', '.')) ??
0.0; 0.0;
Navigator.pop(ctx, price); final quantity = opt.isQuantitative
? (int.tryParse(quantityController.text) ?? 1)
: 1;
Navigator.pop(ctx, {
'price': price,
'quantity': quantity,
});
}, },
child: const Text('Ajouter'), child: const Text('Ajouter'),
), ),
@@ -395,10 +487,11 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
); );
}, },
); );
if (price != null) { if (result != null) {
Navigator.pop(context, { Navigator.pop(context, {
'id': opt.id, // ID de l'option (obligatoire pour récupérer les données) 'id': opt.id, // ID de l'option (obligatoire pour récupérer les données)
'price': price, // Prix choisi par l'utilisateur (obligatoire car personnalisé) 'price': result['price'], // Prix choisi par l'utilisateur (obligatoire car personnalisé)
'quantity': result['quantity'] ?? 1, // Quantité (par défaut 1)
}); });
} }
}, },
@@ -457,6 +550,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
final _minPriceController = TextEditingController(); final _minPriceController = TextEditingController();
final _maxPriceController = TextEditingController(); final _maxPriceController = TextEditingController();
final List<String> _selectedTypes = []; final List<String> _selectedTypes = [];
bool _isQuantitative = false;
String? _error; String? _error;
bool _checkingCode = false; bool _checkingCode = false;
List<Map<String,dynamic>> _allEventTypes = []; List<Map<String,dynamic>> _allEventTypes = [];
@@ -559,6 +653,19 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
CheckboxListTile(
title: const Text('Option quantitative'),
subtitle: const Text('Permet de spécifier une quantité'),
value: _isQuantitative,
onChanged: (value) {
setState(() {
_isQuantitative = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
),
const SizedBox(height: 8),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -642,6 +749,7 @@ class _CreateOptionDialogState extends State<_CreateOptionDialog> {
'valMin': min, 'valMin': min,
'valMax': max, 'valMax': max,
'eventTypes': _selectedTypes, 'eventTypes': _selectedTypes,
'isQuantitative': _isQuantitative,
}); });
Navigator.pop(context, true); Navigator.pop(context, true);
} catch (e) { } catch (e) {