Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.
### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.
### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
- Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
- Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.
### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.
### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
- La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
- Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
- La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
489 lines
18 KiB
Dart
489 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:em2rp/models/event_model.dart';
|
|
import 'package:em2rp/models/event_type_model.dart';
|
|
import 'package:em2rp/models/user_model.dart';
|
|
import 'package:em2rp/services/event_form_service.dart';
|
|
import 'package:em2rp/services/data_service.dart';
|
|
import 'package:em2rp/services/api_service.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/providers/event_provider.dart';
|
|
import 'package:em2rp/providers/local_user_provider.dart';
|
|
|
|
class EventFormController extends ChangeNotifier {
|
|
// Controllers
|
|
final TextEditingController nameController = TextEditingController();
|
|
final TextEditingController descriptionController = TextEditingController();
|
|
final TextEditingController basePriceController = TextEditingController();
|
|
final TextEditingController installationController = TextEditingController();
|
|
final TextEditingController disassemblyController = TextEditingController();
|
|
final TextEditingController addressController = TextEditingController();
|
|
final TextEditingController jaugeController = TextEditingController();
|
|
final TextEditingController contactEmailController = TextEditingController();
|
|
final TextEditingController contactPhoneController = TextEditingController();
|
|
|
|
// State variables
|
|
DateTime? _startDateTime;
|
|
DateTime? _endDateTime;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _success;
|
|
String? _selectedEventTypeId;
|
|
List<EventTypeModel> _eventTypes = [];
|
|
bool _isLoadingEventTypes = true;
|
|
List<String> _selectedUserIds = [];
|
|
List<UserModel> _allUsers = [];
|
|
bool _isLoadingUsers = true;
|
|
List<Map<String, String>> _uploadedFiles = [];
|
|
List<Map<String, dynamic>> _selectedOptions = [];
|
|
bool _formChanged = false;
|
|
EventStatus _selectedStatus = EventStatus.waitingForApproval;
|
|
List<EventEquipment> _assignedEquipment = [];
|
|
List<String> _assignedContainers = [];
|
|
|
|
// Getters
|
|
DateTime? get startDateTime => _startDateTime;
|
|
DateTime? get endDateTime => _endDateTime;
|
|
bool get isLoading => _isLoading;
|
|
String? get error => _error;
|
|
String? get success => _success;
|
|
String? get selectedEventTypeId => _selectedEventTypeId;
|
|
List<EventTypeModel> get eventTypes => _eventTypes;
|
|
bool get isLoadingEventTypes => _isLoadingEventTypes;
|
|
List<String> get selectedUserIds => _selectedUserIds;
|
|
List<UserModel> get allUsers => _allUsers;
|
|
bool get isLoadingUsers => _isLoadingUsers;
|
|
List<Map<String, String>> get uploadedFiles => _uploadedFiles;
|
|
List<Map<String, dynamic>> get selectedOptions => _selectedOptions;
|
|
List<EventEquipment> get assignedEquipment => _assignedEquipment;
|
|
List<String> get assignedContainers => _assignedContainers;
|
|
bool get formChanged => _formChanged;
|
|
EventStatus get selectedStatus => _selectedStatus;
|
|
|
|
EventFormController() {
|
|
_setupListeners();
|
|
}
|
|
|
|
void _setupListeners() {
|
|
nameController.addListener(_onAnyFieldChanged);
|
|
basePriceController.addListener(_onAnyFieldChanged);
|
|
installationController.addListener(_onAnyFieldChanged);
|
|
disassemblyController.addListener(_onAnyFieldChanged);
|
|
addressController.addListener(_onAnyFieldChanged);
|
|
descriptionController.addListener(_onAnyFieldChanged);
|
|
jaugeController.addListener(_onAnyFieldChanged);
|
|
contactEmailController.addListener(_onAnyFieldChanged);
|
|
contactPhoneController.addListener(_onAnyFieldChanged);
|
|
}
|
|
|
|
void _onAnyFieldChanged() {
|
|
if (!_formChanged) {
|
|
_formChanged = true;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> initialize({EventModel? existingEvent, DateTime? selectedDate}) async {
|
|
await Future.wait([
|
|
_fetchUsers(),
|
|
_fetchEventTypes(),
|
|
]);
|
|
|
|
if (existingEvent != null) {
|
|
_populateFromEvent(existingEvent);
|
|
} else {
|
|
_selectedStatus = EventStatus.waitingForApproval;
|
|
|
|
// Préremplir les dates si une date est sélectionnée dans le calendrier
|
|
if (selectedDate != null) {
|
|
// Date de début : selectedDate à 20h00
|
|
_startDateTime = DateTime(
|
|
selectedDate.year,
|
|
selectedDate.month,
|
|
selectedDate.day,
|
|
20,
|
|
0,
|
|
);
|
|
// Date de fin : selectedDate + 4 heures
|
|
_endDateTime = _startDateTime!.add(const Duration(hours: 4));
|
|
}
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void _populateFromEvent(EventModel event) {
|
|
nameController.text = event.name;
|
|
descriptionController.text = event.description;
|
|
basePriceController.text = event.basePrice.toStringAsFixed(2);
|
|
installationController.text = event.installationTime.toString();
|
|
disassemblyController.text = event.disassemblyTime.toString();
|
|
addressController.text = event.address;
|
|
jaugeController.text = event.jauge?.toString() ?? '';
|
|
contactEmailController.text = event.contactEmail ?? '';
|
|
contactPhoneController.text = event.contactPhone ?? '';
|
|
_startDateTime = event.startDateTime;
|
|
_endDateTime = event.endDateTime;
|
|
_assignedEquipment = List<EventEquipment>.from(event.assignedEquipment);
|
|
_assignedContainers = List<String>.from(event.assignedContainers);
|
|
_selectedEventTypeId = event.eventTypeId.isNotEmpty ? event.eventTypeId : null;
|
|
|
|
// Gérer workforce qui peut contenir String ou DocumentReference
|
|
_selectedUserIds = event.workforce.map((ref) {
|
|
if (ref is String) return ref;
|
|
if (ref is DocumentReference) return ref.id;
|
|
return '';
|
|
}).where((id) => id.isNotEmpty).toList();
|
|
|
|
_uploadedFiles = List<Map<String, String>>.from(event.documents);
|
|
_selectedOptions = List<Map<String, dynamic>>.from(event.options);
|
|
_selectedStatus = event.status;
|
|
}
|
|
|
|
Future<void> _fetchUsers() async {
|
|
try {
|
|
_allUsers = await EventFormService.fetchUsers();
|
|
_isLoadingUsers = false;
|
|
} catch (e) {
|
|
_error = e.toString();
|
|
_isLoadingUsers = false;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _fetchEventTypes() async {
|
|
try {
|
|
_eventTypes = await EventFormService.fetchEventTypes();
|
|
_isLoadingEventTypes = false;
|
|
} catch (e) {
|
|
_error = e.toString();
|
|
_isLoadingEventTypes = false;
|
|
}
|
|
notifyListeners();
|
|
}
|
|
|
|
void setStartDateTime(DateTime? dateTime) {
|
|
_startDateTime = dateTime;
|
|
if (_endDateTime != null &&
|
|
dateTime != null &&
|
|
(_endDateTime!.isBefore(dateTime) || _endDateTime!.isAtSameMomentAs(dateTime))) {
|
|
_endDateTime = null;
|
|
}
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setEndDateTime(DateTime? dateTime) {
|
|
_endDateTime = dateTime;
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void onEventTypeChanged(String? newTypeId, BuildContext context) {
|
|
if (newTypeId == _selectedEventTypeId) return;
|
|
|
|
final oldEventTypeIndex = _selectedEventTypeId != null
|
|
? _eventTypes.indexWhere((et) => et.id == _selectedEventTypeId)
|
|
: -1;
|
|
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 (prix TTC stocké dans basePrice)
|
|
final defaultPriceTTC = selectedType.defaultPrice;
|
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
|
|
|
// Mettre à jour le prix TTC 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 = defaultPriceTTC.toStringAsFixed(2);
|
|
}
|
|
|
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
|
final before = _selectedOptions.length;
|
|
_selectedOptions.removeWhere((opt) {
|
|
// Vérifier si cette option est compatible avec le type d'événement sélectionné
|
|
final optionEventTypes = opt['eventTypes'] as List<dynamic>? ?? [];
|
|
return !optionEventTypes.contains(selectedType.id);
|
|
});
|
|
|
|
if (_selectedOptions.length < before) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Certaines options ont été retirées car non compatibles avec "${selectedType.name}".')),
|
|
);
|
|
}
|
|
} else {
|
|
_selectedOptions.clear();
|
|
}
|
|
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSelectedUserIds(List<String> userIds) {
|
|
_selectedUserIds = userIds;
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setUploadedFiles(List<Map<String, String>> files) {
|
|
_uploadedFiles = files;
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setSelectedOptions(List<Map<String, dynamic>> options) {
|
|
_selectedOptions = options;
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
void setAssignedEquipment(List<EventEquipment> equipment, List<String> containers) {
|
|
_assignedEquipment = equipment;
|
|
_assignedContainers = containers;
|
|
_onAnyFieldChanged();
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> pickAndUploadFiles() async {
|
|
final result = await FilePicker.platform.pickFiles(allowMultiple: true, withData: true);
|
|
if (result != null && result.files.isNotEmpty) {
|
|
_isLoading = true;
|
|
_error = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final files = await EventFormService.uploadFiles(result.files);
|
|
_uploadedFiles.addAll(files);
|
|
_onAnyFieldChanged();
|
|
} catch (e) {
|
|
_error = 'Erreur lors de l\'upload : $e';
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
}
|
|
|
|
bool validateForm() {
|
|
return nameController.text.isNotEmpty &&
|
|
_startDateTime != null &&
|
|
_endDateTime != null &&
|
|
_selectedEventTypeId != null &&
|
|
addressController.text.isNotEmpty &&
|
|
(_endDateTime!.isAfter(_startDateTime!));
|
|
}
|
|
|
|
Future<bool> submitForm(BuildContext context, {EventModel? existingEvent}) async {
|
|
if (!validateForm()) {
|
|
_error = "Veuillez remplir tous les champs obligatoires.";
|
|
notifyListeners();
|
|
return false;
|
|
}
|
|
|
|
_isLoading = true;
|
|
_error = null;
|
|
_success = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final eventTypeRef = _selectedEventTypeId != null
|
|
? null // Les références Firestore ne sont plus nécessaires, l'ID suffit
|
|
: null;
|
|
|
|
if (existingEvent != null) {
|
|
// Mode édition
|
|
// Gérer les nouveaux fichiers uploadés s'il y en a
|
|
List<Map<String, String>> finalDocuments = List<Map<String, String>>.from(_uploadedFiles);
|
|
|
|
// Identifier les nouveaux fichiers (ceux qui ont une URL temp)
|
|
final newFiles = _uploadedFiles.where((file) =>
|
|
file['url']?.contains('events/temp/') ?? false).toList();
|
|
|
|
if (newFiles.isNotEmpty) {
|
|
// Déplacer les nouveaux fichiers vers le dossier de l'événement
|
|
final movedFiles = await EventFormService.moveFilesToEvent(newFiles, existingEvent.id);
|
|
|
|
// Remplacer les URLs temporaires par les nouvelles URLs
|
|
for (int i = 0; i < finalDocuments.length; i++) {
|
|
final tempFile = finalDocuments[i];
|
|
final movedFile = movedFiles.firstWhere(
|
|
(moved) => moved['name'] == tempFile['name'],
|
|
orElse: () => tempFile,
|
|
);
|
|
finalDocuments[i] = movedFile;
|
|
}
|
|
}
|
|
|
|
final updatedEvent = EventModel(
|
|
id: existingEvent.id,
|
|
name: nameController.text.trim(),
|
|
description: descriptionController.text.trim(),
|
|
startDateTime: _startDateTime!,
|
|
endDateTime: _endDateTime!,
|
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
|
eventTypeId: _selectedEventTypeId!,
|
|
eventTypeRef: eventTypeRef,
|
|
customerId: existingEvent.customerId,
|
|
address: addressController.text.trim(),
|
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
|
workforce: _selectedUserIds,
|
|
latitude: existingEvent.latitude,
|
|
longitude: existingEvent.longitude,
|
|
documents: finalDocuments,
|
|
options: _selectedOptions,
|
|
status: _selectedStatus,
|
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
|
assignedEquipment: _assignedEquipment,
|
|
assignedContainers: _assignedContainers,
|
|
preparationStatus: existingEvent.preparationStatus,
|
|
returnStatus: existingEvent.returnStatus,
|
|
);
|
|
|
|
await EventFormService.updateEvent(updatedEvent);
|
|
|
|
// Recharger les événements après modification
|
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
final userId = localUserProvider.uid;
|
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
|
|
if (userId != null) {
|
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
}
|
|
|
|
_success = "Événement modifié avec succès !";
|
|
} else {
|
|
// Mode création
|
|
final newEvent = EventModel(
|
|
id: '',
|
|
name: nameController.text.trim(),
|
|
description: descriptionController.text.trim(),
|
|
startDateTime: _startDateTime!,
|
|
endDateTime: _endDateTime!,
|
|
basePrice: double.tryParse(basePriceController.text.replaceAll(',', '.')) ?? 0.0,
|
|
installationTime: int.tryParse(installationController.text) ?? 0,
|
|
disassemblyTime: int.tryParse(disassemblyController.text) ?? 0,
|
|
eventTypeId: _selectedEventTypeId!,
|
|
eventTypeRef: eventTypeRef,
|
|
customerId: '',
|
|
address: addressController.text.trim(),
|
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
|
workforce: _selectedUserIds,
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
documents: _uploadedFiles,
|
|
options: _selectedOptions,
|
|
status: _selectedStatus,
|
|
jauge: jaugeController.text.isNotEmpty ? int.tryParse(jaugeController.text) : null,
|
|
contactEmail: contactEmailController.text.isNotEmpty ? contactEmailController.text.trim() : null,
|
|
contactPhone: contactPhoneController.text.isNotEmpty ? contactPhoneController.text.trim() : null,
|
|
assignedContainers: _assignedContainers,
|
|
assignedEquipment: _assignedEquipment,
|
|
);
|
|
|
|
final eventId = await EventFormService.createEvent(newEvent);
|
|
|
|
// Déplacer et mettre à jour les fichiers uniquement s'il y en a
|
|
if (_uploadedFiles.isNotEmpty) {
|
|
final newFiles = await EventFormService.moveFilesToEvent(_uploadedFiles, eventId);
|
|
if (newFiles.isNotEmpty) {
|
|
await EventFormService.updateEventDocuments(eventId, newFiles);
|
|
}
|
|
}
|
|
|
|
// Reload events
|
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
final userId = localUserProvider.uid;
|
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
|
|
if (userId != null) {
|
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
}
|
|
|
|
_success = "Événement créé avec succès !";
|
|
}
|
|
|
|
_formChanged = false;
|
|
notifyListeners();
|
|
return true;
|
|
} catch (e) {
|
|
_error = "Erreur lors de la sauvegarde : $e";
|
|
notifyListeners();
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<bool> deleteEvent(BuildContext context, String eventId) async {
|
|
_isLoading = true;
|
|
_error = null;
|
|
_success = null;
|
|
notifyListeners();
|
|
|
|
try {
|
|
// Supprimer l'événement via l'API
|
|
final dataService = DataService(FirebaseFunctionsApiService());
|
|
await dataService.deleteEvent(eventId);
|
|
|
|
// Recharger la liste des événements
|
|
final localUserProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
final userId = localUserProvider.uid;
|
|
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
|
|
|
if (userId != null) {
|
|
await eventProvider.loadUserEvents(userId, canViewAllEvents: canViewAllEvents);
|
|
}
|
|
|
|
_success = "Événement supprimé avec succès !";
|
|
notifyListeners();
|
|
return true;
|
|
} catch (e) {
|
|
_error = "Erreur lors de la suppression : $e";
|
|
notifyListeners();
|
|
return false;
|
|
} finally {
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
void clearError() {
|
|
_error = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void clearSuccess() {
|
|
_success = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
nameController.dispose();
|
|
descriptionController.dispose();
|
|
basePriceController.dispose();
|
|
installationController.dispose();
|
|
disassemblyController.dispose();
|
|
addressController.dispose();
|
|
jaugeController.dispose();
|
|
contactEmailController.dispose();
|
|
contactPhoneController.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|