650 lines
23 KiB
Dart
650 lines
23 KiB
Dart
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|