feat: Sécurisation Firestore, gestion des prix HT/TTC et refactorisation majeure

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.
This commit is contained in:
ElPoyo
2026-01-14 17:32:58 +01:00
parent fb3f41df4d
commit b30ae0f10a
40 changed files with 1759 additions and 308 deletions

View File

@@ -1,3 +1,4 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/event_model.dart';
@@ -137,7 +138,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
Future<void> _processSelection(Map<String, SelectedItem> selection) async {
print('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
DebugLog.info('[EventAssignedEquipmentSection] Processing selection of ${selection.length} items');
// Séparer équipements et conteneurs
final newEquipment = <EventEquipment>[];
@@ -154,7 +155,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
}
print('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
// Charger les équipements et conteneurs
final containerProvider = context.read<ContainerProvider>();
@@ -163,13 +164,13 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
final allContainers = await containerProvider.containersStream.first;
final allEquipment = await equipmentProvider.equipmentStream.first;
print('[EventAssignedEquipmentSection] Starting conflict checks...');
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
final allConflicts = <String, List<AvailabilityConflict>>{};
// 1. Vérifier les conflits pour les équipements directs
print('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
for (var eq in newEquipment) {
print('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
final equipment = allEquipment.firstWhere(
(e) => e.id == eq.equipmentId,
@@ -185,7 +186,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
),
);
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
// Pour les équipements quantifiables (consommables/câbles)
if (equipment.hasQuantity) {
@@ -225,16 +226,16 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
);
if (conflicts.isNotEmpty) {
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
allConflicts[eq.equipmentId] = conflicts;
} else {
print('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
}
}
}
// 2. Vérifier les conflits pour les boîtes et leur contenu
print('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
for (var containerId in newContainers) {
final container = allContainers.firstWhere(
(c) => c.id == containerId,
@@ -305,37 +306,37 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
if (containerConflicts.isNotEmpty) {
print('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
allConflicts[containerId] = containerConflicts;
} else {
print('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
}
}
print('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
if (allConflicts.isNotEmpty) {
print('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
// Afficher le dialog de conflits
final action = await showDialog<String>(
context: context,
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
);
print('[EventAssignedEquipmentSection] Conflict dialog result: $action');
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
if (action == 'cancel') {
return; // Annuler l'ajout
} else if (action == 'force_removed') {
// Identifier quels équipements/conteneurs retirer
final removedIds = allConflicts.keys.toSet();
// Retirer les équipements directs en conflit
newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId));
// Retirer les boîtes en conflit
newContainers.removeWhere((containerId) => removedIds.contains(containerId));
// Informer l'utilisateur des boîtes retirées
for (var containerId in removedIds.where((id) => newContainers.contains(id))) {
if (mounted) {
@@ -363,15 +364,15 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
}
// Si 'force_all', on garde tout
}
// Fusionner avec l'existant
final updatedEquipment = [...widget.assignedEquipment];
final updatedContainers = [...widget.assignedContainers];
// Pour chaque nouvel équipement
for (var eq in newEquipment) {
final existingIndex = updatedEquipment.indexWhere((e) => e.equipmentId == eq.equipmentId);
if (existingIndex != -1) {
// L'équipement existe déjà : mettre à jour la quantité
updatedEquipment[existingIndex] = EventEquipment(
@@ -386,19 +387,19 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
updatedEquipment.add(eq);
}
}
for (var containerId in newContainers) {
if (!updatedContainers.contains(containerId)) {
updatedContainers.add(containerId);
}
}
// Notifier le changement
widget.onChanged(updatedEquipment, updatedContainers);
// Recharger le cache
await _loadEquipmentAndContainers();
}
}
void _removeEquipment(String equipmentId) {
final updated = widget.assignedEquipment

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/models/event_type_model.dart';
import 'package:em2rp/views/widgets/event_form/price_ht_ttc_fields.dart';
class EventBasicInfoSection extends StatelessWidget {
final TextEditingController nameController;
@@ -80,29 +81,9 @@ class EventBasicInfoSection extends StatelessWidget {
],
),
const SizedBox(height: 16),
TextFormField(
controller: basePriceController,
decoration: const InputDecoration(
labelText: 'Prix de base (€)*',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
hintText: '1050.50',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Le prix de base est requis';
}
final price = double.tryParse(value.replaceAll(',', '.'));
if (price == null) {
return 'Veuillez entrer un nombre valide';
}
return null;
},
onChanged: (_) => onAnyFieldChanged(),
PriceHtTtcFields(
basePriceController: basePriceController,
onPriceChanged: onAnyFieldChanged,
),
],
);

View File

@@ -1,6 +1,8 @@
import 'package:em2rp/utils/debug_log.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/utils/price_helpers.dart';
import 'package:em2rp/services/data_service.dart';
import 'package:em2rp/services/api_service.dart';
@@ -62,10 +64,14 @@ class EventOptionsDisplayWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...enrichedOptions.map((opt) {
final price = (opt['price'] ?? 0.0) as num;
final priceTTC = (opt['price'] ?? 0.0) as num;
final quantity = (opt['quantity'] ?? 1) as int;
final totalPrice = price * quantity;
final isNegative = totalPrice < 0;
final totalPriceTTC = priceTTC * quantity;
final isNegative = totalPriceTTC < 0;
// Calculer le prix HT
final priceHT = PriceHelpers.calculateHT(priceTTC.toDouble());
final totalPriceHT = priceHT * quantity;
return ListTile(
leading: Icon(Icons.tune, color: AppColors.rouge),
@@ -98,28 +104,64 @@ class EventOptionsDisplayWidget extends StatelessWidget {
fontStyle: FontStyle.italic,
),
),
if (quantity > 1 && canViewPrices)
if (quantity > 1 && canViewPrices) ...[
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'${currencyFormat.format(price)} × $quantity',
'HT: ${currencyFormat.format(priceHT)} × $quantity',
style: TextStyle(
color: Colors.grey[700],
fontSize: 12,
fontWeight: FontWeight.w500,
fontSize: 11,
fontWeight: FontWeight.w400,
),
),
),
Text(
'TTC: ${currencyFormat.format(priceTTC)} × $quantity',
style: TextStyle(
color: Colors.grey[700],
fontSize: 11,
fontWeight: FontWeight.w500,
),
),
] else if (canViewPrices) ...[
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'HT: ${currencyFormat.format(priceHT)}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
fontStyle: FontStyle.italic,
),
),
),
],
],
),
trailing: canViewPrices
? Text(
(isNegative ? '- ' : '+ ') +
currencyFormat.format(totalPrice.abs()),
style: TextStyle(
color: isNegative ? Colors.red : AppColors.noir,
fontWeight: FontWeight.bold,
),
? Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
(isNegative ? '- ' : '+ ') +
currencyFormat.format(totalPriceTTC.abs()),
style: TextStyle(
color: isNegative ? Colors.red : AppColors.noir,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
Text(
'HT: ${currencyFormat.format(totalPriceHT.abs())}',
style: TextStyle(
color: Colors.grey[600],
fontSize: 10,
fontStyle: FontStyle.italic,
),
),
],
)
: null,
contentPadding: EdgeInsets.zero,
@@ -139,31 +181,71 @@ class EventOptionsDisplayWidget extends StatelessWidget {
}
Widget _buildTotalPrice(BuildContext context, List<Map<String, dynamic>> options, NumberFormat currencyFormat) {
final optionsTotal = options.fold<num>(0, (sum, opt) {
final price = opt['price'] ?? 0.0;
final optionsTotalTTC = options.fold<num>(0, (sum, opt) {
final priceTTC = opt['price'] ?? 0.0;
final quantity = opt['quantity'] ?? 1;
return sum + (price * quantity);
return sum + (priceTTC * quantity);
});
// Calculer le total HT
final optionsTotalHT = PriceHelpers.calculateHT(optionsTotalTTC.toDouble());
final optionsTVA = PriceHelpers.calculateTax(optionsTotalHT);
return Padding(
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
child: Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.tune, color: AppColors.rouge),
const SizedBox(width: 8),
Text(
'Total options : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
Row(
children: [
const Icon(Icons.tune, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
'Total options HT : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.w600,
),
),
Text(
currencyFormat.format(optionsTotalHT),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.w600,
),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 28.0, top: 2.0),
child: Text(
'TVA (20%) : ${currencyFormat.format(optionsTVA)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
),
Text(
currencyFormat.format(optionsTotal),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
Row(
children: [
const Icon(Icons.attach_money, color: AppColors.rouge, size: 20),
const SizedBox(width: 8),
Text(
'Total options TTC : ',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.noir,
fontWeight: FontWeight.bold,
),
),
Text(
currencyFormat.format(optionsTotalTTC),
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: AppColors.rouge,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
@@ -224,7 +306,7 @@ class EventOptionsDisplayWidget extends StatelessWidget {
});
}
} catch (e) {
print('Erreur lors du chargement de l\'option ${optionData['id']}: $e');
DebugLog.error('Erreur lors du chargement de l\'option ${optionData['id']}', e);
// En cas d'erreur, créer une entrée avec les données disponibles
enrichedOptions.add({
'id': optionData['id'],

View File

@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:em2rp/utils/price_helpers.dart';
/// Widget pour gérer les prix HT et TTC avec calcul automatique
/// Permet de saisir soit le prix HT, soit le prix TTC, l'autre est calculé automatiquement
class PriceHtTtcFields extends StatefulWidget {
final TextEditingController basePriceController;
final VoidCallback onPriceChanged;
final double taxRate;
const PriceHtTtcFields({
super.key,
required this.basePriceController,
required this.onPriceChanged,
this.taxRate = 0.20,
});
@override
State<PriceHtTtcFields> createState() => _PriceHtTtcFieldsState();
}
class _PriceHtTtcFieldsState extends State<PriceHtTtcFields> {
final TextEditingController _htController = TextEditingController();
final TextEditingController _ttcController = TextEditingController();
bool _updatingFromHT = false;
bool _updatingFromTTC = false;
@override
void initState() {
super.initState();
// Initialiser avec la valeur existante (considérée comme TTC)
if (widget.basePriceController.text.isNotEmpty) {
_ttcController.text = widget.basePriceController.text;
_updateHTFromTTC();
}
// Synchroniser basePriceController avec TTC
_htController.addListener(_onHTChanged);
_ttcController.addListener(_onTTCChanged);
// Écouter les changements externes du basePriceController (ex: changement de type d'événement)
widget.basePriceController.addListener(_onBasePriceControllerChanged);
}
@override
void dispose() {
_htController.removeListener(_onHTChanged);
_ttcController.removeListener(_onTTCChanged);
widget.basePriceController.removeListener(_onBasePriceControllerChanged);
_htController.dispose();
_ttcController.dispose();
super.dispose();
}
/// Appelé quand basePriceController change de l'extérieur (ex: sélection type d'événement)
void _onBasePriceControllerChanged() {
// Éviter la boucle infinie si le changement vient de nous
if (_updatingFromHT || _updatingFromTTC) return;
final newTTCText = widget.basePriceController.text;
// Si le texte est différent de ce qu'on a dans _ttcController, mettre à jour
if (newTTCText != _ttcController.text) {
_ttcController.text = newTTCText;
if (newTTCText.isNotEmpty) {
_updateHTFromTTC();
} else {
_htController.clear();
}
}
}
void _onHTChanged() {
if (_updatingFromTTC) return;
final htText = _htController.text.replaceAll(',', '.');
final htValue = double.tryParse(htText);
if (htValue != null) {
_updatingFromHT = true;
final ttcValue = PriceHelpers.calculateTTC(htValue, taxRate: widget.taxRate);
_ttcController.text = ttcValue.toStringAsFixed(2);
// Mettre à jour basePriceController (qui stocke le prix TTC)
widget.basePriceController.text = ttcValue.toStringAsFixed(2);
widget.onPriceChanged();
_updatingFromHT = false;
} else if (htText.isEmpty) {
_ttcController.clear();
widget.basePriceController.clear();
widget.onPriceChanged();
}
}
void _onTTCChanged() {
if (_updatingFromHT) return;
final ttcText = _ttcController.text.replaceAll(',', '.');
final ttcValue = double.tryParse(ttcText);
if (ttcValue != null) {
_updatingFromTTC = true;
final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate);
_htController.text = htValue.toStringAsFixed(2);
// Mettre à jour basePriceController (qui stocke le prix TTC)
widget.basePriceController.text = ttcValue.toStringAsFixed(2);
widget.onPriceChanged();
_updatingFromTTC = false;
} else if (ttcText.isEmpty) {
_htController.clear();
widget.basePriceController.clear();
widget.onPriceChanged();
}
}
void _updateHTFromTTC() {
final ttcText = _ttcController.text.replaceAll(',', '.');
final ttcValue = double.tryParse(ttcText);
if (ttcValue != null) {
final htValue = PriceHelpers.calculateHT(ttcValue, taxRate: widget.taxRate);
_htController.text = htValue.toStringAsFixed(2);
}
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Prix',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Row(
children: [
// Champ Prix HT
Expanded(
child: TextFormField(
controller: _htController,
decoration: InputDecoration(
labelText: 'Prix HT (€)*',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.euro),
hintText: '1000.00',
helperText: 'Hors taxes',
helperStyle: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Requis';
}
final price = double.tryParse(value.replaceAll(',', '.'));
if (price == null) {
return 'Nombre invalide';
}
return null;
},
),
),
const SizedBox(width: 16),
// Champ Prix TTC
Expanded(
child: TextFormField(
controller: _ttcController,
decoration: InputDecoration(
labelText: 'Prix TTC (€)*',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.attach_money),
hintText: '1200.00',
helperText: 'Toutes taxes comprises',
helperStyle: TextStyle(
fontSize: 11,
color: Colors.grey[600],
),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')),
],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Requis';
}
final price = double.tryParse(value.replaceAll(',', '.'));
if (price == null) {
return 'Nombre invalide';
}
return null;
},
),
),
],
),
const SizedBox(height: 4),
// Affichage du montant de TVA (en plus petit)
Builder(
builder: (context) {
final htText = _htController.text.replaceAll(',', '.');
final htValue = double.tryParse(htText);
if (htValue != null) {
final taxAmount = PriceHelpers.calculateTax(htValue, taxRate: widget.taxRate);
return Padding(
padding: const EdgeInsets.only(left: 8.0, top: 4.0),
child: Text(
'TVA (${(widget.taxRate * 100).toStringAsFixed(0)}%) : ${taxAmount.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 11,
color: Colors.grey[600],
fontStyle: FontStyle.italic,
),
),
);
}
return const SizedBox.shrink();
},
),
],
);
}
}