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:
@@ -49,6 +49,10 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"firestore": {
|
||||||
|
"rules": "firestore.rules",
|
||||||
|
"indexes": "firestore.indexes.json"
|
||||||
|
},
|
||||||
"emulators": {
|
"emulators": {
|
||||||
"functions": {
|
"functions": {
|
||||||
"port": 5051
|
"port": 5051
|
||||||
|
|||||||
46
em2rp/firestore.indexes.json
Normal file
46
em2rp/firestore.indexes.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"indexes": [
|
||||||
|
{
|
||||||
|
"collectionGroup": "events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "EndDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "StartDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "__name__",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"collectionGroup": "events",
|
||||||
|
"queryScope": "COLLECTION",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "StartDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "EndDateTime",
|
||||||
|
"order": "ASCENDING"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldOverrides": []
|
||||||
|
}
|
||||||
|
|
||||||
172
em2rp/firestore.rules
Normal file
172
em2rp/firestore.rules
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
rules_version = '2';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// RÈGLES FIRESTORE SÉCURISÉES - VERSION PRODUCTION
|
||||||
|
// ============================================================================
|
||||||
|
// Date de création : 14 janvier 2026
|
||||||
|
// Objectif : Bloquer tous les accès directs à Firestore depuis les clients
|
||||||
|
// Seules les Cloud Functions (côté serveur) peuvent lire/écrire les données
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
service cloud.firestore {
|
||||||
|
match /databases/{database}/documents {
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RÈGLE GLOBALE PAR DÉFAUT : TOUT BLOQUER
|
||||||
|
// ========================================================================
|
||||||
|
// Cette règle empêche tout accès direct depuis les clients (web/mobile)
|
||||||
|
// Les Cloud Functions ont un accès admin et ne sont pas affectées
|
||||||
|
|
||||||
|
match /{document=**} {
|
||||||
|
// ❌ REFUSER TOUS LES ACCÈS directs depuis les clients
|
||||||
|
allow read, write: if false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// EXCEPTIONS OPTIONNELLES pour les listeners temps réel
|
||||||
|
// ========================================================================
|
||||||
|
// Si vous avez besoin de listeners en temps réel pour certaines collections,
|
||||||
|
// décommentez les règles ci-dessous.
|
||||||
|
//
|
||||||
|
// ⚠️ IMPORTANT : Ces règles permettent UNIQUEMENT la LECTURE.
|
||||||
|
// Toutes les ÉCRITURES doivent passer par les Cloud Functions.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Événements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alertes : Lecture seule pour utilisateurs authentifiés
|
||||||
|
match /alerts/{alertId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisateurs : Lecture de son propre profil uniquement
|
||||||
|
match /users/{userId} {
|
||||||
|
allow read: if request.auth != null && request.auth.uid == userId;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types d'événements : Lecture seule
|
||||||
|
match /eventTypes/{typeId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options : Lecture seule
|
||||||
|
match /options/{optionId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clients : Lecture seule
|
||||||
|
match /customers/{customerId} {
|
||||||
|
allow read: if request.auth != null;
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RÈGLES AVANCÉES avec vérification des permissions (OPTIONNEL)
|
||||||
|
// ========================================================================
|
||||||
|
// Décommentez ces règles si vous voulez des permissions basées sur les rôles
|
||||||
|
// pour la lecture en temps réel
|
||||||
|
//
|
||||||
|
// ⚠️ ATTENTION : Ces règles nécessitent une lecture supplémentaire dans
|
||||||
|
// la collection users, ce qui peut impacter les performances et les coûts.
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
/*
|
||||||
|
// Fonction helper : Récupérer les permissions de l'utilisateur
|
||||||
|
function getUserPermissions() {
|
||||||
|
return get(/databases/$(database)/documents/users/$(request.auth.uid)).data.permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fonction helper : Vérifier si l'utilisateur a une permission
|
||||||
|
function hasPermission(permission) {
|
||||||
|
return request.auth != null && permission in getUserPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Équipements : Lecture uniquement si permission view_equipment
|
||||||
|
match /equipments/{equipmentId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Événements : Lecture selon permissions
|
||||||
|
match /events/{eventId} {
|
||||||
|
allow read: if hasPermission('view_events') || hasPermission('edit_event');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conteneurs : Lecture uniquement si permission view_equipment
|
||||||
|
match /containers/{containerId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
|
||||||
|
// Maintenances : Lecture uniquement si permission view_equipment
|
||||||
|
match /maintenances/{maintenanceId} {
|
||||||
|
allow read: if hasPermission('view_equipment') || hasPermission('manage_equipment');
|
||||||
|
allow write: if false; // ❌ Écriture interdite
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// NOTES DE SÉCURITÉ
|
||||||
|
// ============================================================================
|
||||||
|
//
|
||||||
|
// 1. RÈGLE PAR DÉFAUT (allow read, write: if false)
|
||||||
|
// - Bloque TOUS les accès directs depuis les clients
|
||||||
|
// - Les Cloud Functions ne sont PAS affectées (elles ont un accès admin)
|
||||||
|
// - C'est la configuration la PLUS SÉCURISÉE
|
||||||
|
//
|
||||||
|
// 2. EXCEPTIONS DE LECTURE (commentées par défaut)
|
||||||
|
// - Permettent les listeners en temps réel pour certaines collections
|
||||||
|
// - UNIQUEMENT la LECTURE est autorisée
|
||||||
|
// - Les ÉCRITURES restent bloquées (doivent passer par Cloud Functions)
|
||||||
|
//
|
||||||
|
// 3. RÈGLES BASÉES SUR LES RÔLES (commentées par défaut)
|
||||||
|
// - Permettent un contrôle plus fin basé sur les permissions utilisateur
|
||||||
|
// - ⚠️ Impact sur les performances (lecture supplémentaire de la collection users)
|
||||||
|
// - À utiliser uniquement si nécessaire
|
||||||
|
//
|
||||||
|
// 4. TESTS APRÈS DÉPLOIEMENT
|
||||||
|
// - Vérifier que les Cloud Functions fonctionnent toujours
|
||||||
|
// - Tester qu'un accès direct depuis la console échoue
|
||||||
|
// - Surveiller les logs : firebase functions:log
|
||||||
|
//
|
||||||
|
// 5. ROLLBACK EN CAS DE PROBLÈME
|
||||||
|
// - Remplacer temporairement par :
|
||||||
|
// match /{document=**} {
|
||||||
|
// allow read, write: if request.auth != null;
|
||||||
|
// }
|
||||||
|
// - Déployer rapidement : firebase deploy --only firestore:rules
|
||||||
|
//
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
@@ -153,6 +153,11 @@ exports.updateEquipment = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!data || typeof data !== 'object' || Object.keys(data).length === 0) {
|
||||||
|
res.status(400).json({ error: 'Update data is required and must be a non-empty object' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Empêcher la modification de l'ID
|
// Empêcher la modification de l'ID
|
||||||
delete data.id;
|
delete data.id;
|
||||||
|
|
||||||
@@ -703,10 +708,14 @@ exports.createEvent = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
|
|
||||||
const eventData = req.body.data;
|
const eventData = req.body.data;
|
||||||
|
|
||||||
const dataToSave = helpers.deserializeTimestamps(eventData, [
|
// Désérialiser les timestamps
|
||||||
|
let dataToSave = helpers.deserializeTimestamps(eventData, [
|
||||||
'StartDateTime', 'EndDateTime', 'createdAt', 'updatedAt'
|
'StartDateTime', 'EndDateTime', 'createdAt', 'updatedAt'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format
|
||||||
|
dataToSave = helpers.convertIdsToReferences(dataToSave);
|
||||||
|
|
||||||
const docRef = await db.collection('events').add(dataToSave);
|
const docRef = await db.collection('events').add(dataToSave);
|
||||||
|
|
||||||
res.status(201).json({ id: docRef.id, message: 'Event created successfully' });
|
res.status(201).json({ id: docRef.id, message: 'Event created successfully' });
|
||||||
@@ -750,10 +759,14 @@ exports.updateEvent = onRequest(httpOptions, withCors(async (req, res) => {
|
|||||||
delete data.id;
|
delete data.id;
|
||||||
data.updatedAt = admin.firestore.Timestamp.now();
|
data.updatedAt = admin.firestore.Timestamp.now();
|
||||||
|
|
||||||
const dataToSave = helpers.deserializeTimestamps(data, [
|
// Désérialiser les timestamps
|
||||||
|
let dataToSave = helpers.deserializeTimestamps(data, [
|
||||||
'StartDateTime', 'EndDateTime'
|
'StartDateTime', 'EndDateTime'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Convertir les IDs en DocumentReference pour compatibilité avec l'ancien format
|
||||||
|
dataToSave = helpers.convertIdsToReferences(dataToSave);
|
||||||
|
|
||||||
await db.collection('events').doc(eventId).update(dataToSave);
|
await db.collection('events').doc(eventId).update(dataToSave);
|
||||||
|
|
||||||
res.status(200).json({ message: 'Event updated successfully' });
|
res.status(200).json({ message: 'Event updated successfully' });
|
||||||
|
|||||||
@@ -146,6 +146,39 @@ function filterCancelledEvents(events) {
|
|||||||
return events.filter(event => event.status !== 'CANCELLED');
|
return events.filter(event => event.status !== 'CANCELLED');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit les IDs en DocumentReference pour maintenir la compatibilité avec l'ancien format
|
||||||
|
* @param {Object} data - Données de l'événement
|
||||||
|
* @returns {Object} - Données avec DocumentReference
|
||||||
|
*/
|
||||||
|
function convertIdsToReferences(data) {
|
||||||
|
if (!data) return data;
|
||||||
|
|
||||||
|
const result = { ...data };
|
||||||
|
|
||||||
|
// Convertir EventType (ID → DocumentReference)
|
||||||
|
if (result.EventType && typeof result.EventType === 'string' && !result.EventType.includes('/')) {
|
||||||
|
result.EventType = admin.firestore().collection('eventTypes').doc(result.EventType);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir customer (ID → DocumentReference)
|
||||||
|
if (result.customer && typeof result.customer === 'string' && !result.customer.includes('/')) {
|
||||||
|
result.customer = admin.firestore().collection('customers').doc(result.customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convertir workforce (IDs → DocumentReference)
|
||||||
|
if (Array.isArray(result.workforce)) {
|
||||||
|
result.workforce = result.workforce.map(item => {
|
||||||
|
if (typeof item === 'string' && !item.includes('/')) {
|
||||||
|
return admin.firestore().collection('users').doc(item);
|
||||||
|
}
|
||||||
|
return item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
serializeTimestamps,
|
serializeTimestamps,
|
||||||
deserializeTimestamps,
|
deserializeTimestamps,
|
||||||
@@ -153,5 +186,6 @@ module.exports = {
|
|||||||
maskSensitiveFields,
|
maskSensitiveFields,
|
||||||
paginate,
|
paginate,
|
||||||
filterCancelledEvents,
|
filterCancelledEvents,
|
||||||
|
convertIdsToReferences,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/// Configuration de la version de l'application
|
/// Configuration de la version de l'application
|
||||||
class AppVersion {
|
class AppVersion {
|
||||||
static const String version = '0.3.7';
|
static const String version = '0.3.8';
|
||||||
|
|
||||||
/// Retourne la version complète de l'application
|
/// Retourne la version complète de l'application
|
||||||
static String get fullVersion => 'v$version';
|
static String get fullVersion => 'v$version';
|
||||||
|
|||||||
@@ -192,15 +192,15 @@ class EventFormController extends ChangeNotifier {
|
|||||||
if (newTypeId != null) {
|
if (newTypeId != null) {
|
||||||
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
final selectedType = _eventTypes.firstWhere((et) => et.id == newTypeId);
|
||||||
|
|
||||||
// Utiliser le prix par défaut du type d'événement
|
// Utiliser le prix par défaut du type d'événement (prix TTC stocké dans basePrice)
|
||||||
final defaultPrice = selectedType.defaultPrice;
|
final defaultPriceTTC = selectedType.defaultPrice;
|
||||||
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
final currentPrice = double.tryParse(basePriceController.text.replaceAll(',', '.'));
|
||||||
final oldDefaultPrice = oldEventType?.defaultPrice;
|
final oldDefaultPrice = oldEventType?.defaultPrice;
|
||||||
|
|
||||||
// Mettre à jour le prix si le champ est vide ou si c'était l'ancien prix par défaut
|
// Mettre à jour le prix TTC si le champ est vide ou si c'était l'ancien prix par défaut
|
||||||
if (basePriceController.text.isEmpty ||
|
if (basePriceController.text.isEmpty ||
|
||||||
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
(currentPrice != null && oldDefaultPrice != null && currentPrice == oldDefaultPrice)) {
|
||||||
basePriceController.text = defaultPrice.toStringAsFixed(2);
|
basePriceController.text = defaultPriceTTC.toStringAsFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
// Filtrer les options qui ne sont plus compatibles avec le nouveau type
|
||||||
@@ -334,9 +334,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: existingEvent.customerId,
|
customerId: existingEvent.customerId,
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.doc('users/$id'))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: existingEvent.latitude,
|
latitude: existingEvent.latitude,
|
||||||
longitude: existingEvent.longitude,
|
longitude: existingEvent.longitude,
|
||||||
documents: finalDocuments,
|
documents: finalDocuments,
|
||||||
@@ -379,9 +378,8 @@ class EventFormController extends ChangeNotifier {
|
|||||||
eventTypeRef: eventTypeRef,
|
eventTypeRef: eventTypeRef,
|
||||||
customerId: '',
|
customerId: '',
|
||||||
address: addressController.text.trim(),
|
address: addressController.text.trim(),
|
||||||
workforce: _selectedUserIds
|
// Envoyer directement les IDs au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
.map((id) => FirebaseFirestore.instance.doc('users/$id'))
|
workforce: _selectedUserIds,
|
||||||
.toList(),
|
|
||||||
latitude: 0.0,
|
latitude: 0.0,
|
||||||
longitude: 0.0,
|
longitude: 0.0,
|
||||||
documents: _uploadedFiles,
|
documents: _uploadedFiles,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ enum EquipmentCategory {
|
|||||||
structure, // Structure
|
structure, // Structure
|
||||||
consumable, // Consommable
|
consumable, // Consommable
|
||||||
cable, // Câble
|
cable, // Câble
|
||||||
|
vehicle, // Véhicule
|
||||||
other // Autre
|
other // Autre
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +73,8 @@ String equipmentCategoryToString(EquipmentCategory category) {
|
|||||||
return 'CONSUMABLE';
|
return 'CONSUMABLE';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'CABLE';
|
return 'CABLE';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'VEHICLE';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'OTHER';
|
return 'OTHER';
|
||||||
case EquipmentCategory.effect:
|
case EquipmentCategory.effect:
|
||||||
@@ -93,6 +96,8 @@ EquipmentCategory equipmentCategoryFromString(String? category) {
|
|||||||
return EquipmentCategory.consumable;
|
return EquipmentCategory.consumable;
|
||||||
case 'CABLE':
|
case 'CABLE':
|
||||||
return EquipmentCategory.cable;
|
return EquipmentCategory.cable;
|
||||||
|
case 'VEHICLE':
|
||||||
|
return EquipmentCategory.vehicle;
|
||||||
case 'EFFECT':
|
case 'EFFECT':
|
||||||
return EquipmentCategory.effect;
|
return EquipmentCategory.effect;
|
||||||
case 'OTHER':
|
case 'OTHER':
|
||||||
@@ -120,6 +125,8 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -142,6 +149,8 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Icons.inventory_2;
|
return Icons.inventory_2;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.more_horiz;
|
return Icons.more_horiz;
|
||||||
}
|
}
|
||||||
@@ -164,6 +173,8 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return Colors.orange;
|
return Colors.orange;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Colors.grey;
|
return Colors.grey;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Colors.teal;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Colors.blueGrey;
|
return Colors.blueGrey;
|
||||||
}
|
}
|
||||||
@@ -176,7 +187,13 @@ extension EquipmentCategoryExtension on EquipmentCategory {
|
|||||||
return 'assets/icons/truss.svg';
|
return 'assets/icons/truss.svg';
|
||||||
case EquipmentCategory.consumable:
|
case EquipmentCategory.consumable:
|
||||||
return 'assets/icons/tape.svg';
|
return 'assets/icons/tape.svg';
|
||||||
default:
|
case EquipmentCategory.lighting:
|
||||||
|
case EquipmentCategory.sound:
|
||||||
|
case EquipmentCategory.video:
|
||||||
|
case EquipmentCategory.effect:
|
||||||
|
case EquipmentCategory.cable:
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
case EquipmentCategory.other:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,12 +489,10 @@ class EventModel {
|
|||||||
'BasePrice': basePrice,
|
'BasePrice': basePrice,
|
||||||
'InstallationTime': installationTime,
|
'InstallationTime': installationTime,
|
||||||
'DisassemblyTime': disassemblyTime,
|
'DisassemblyTime': disassemblyTime,
|
||||||
'EventType': eventTypeId.isNotEmpty
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
? FirebaseFirestore.instance.collection('eventTypes').doc(eventTypeId)
|
'EventType': eventTypeId.isNotEmpty ? eventTypeId : null,
|
||||||
: null,
|
// Envoyer l'ID au lieu de DocumentReference pour compatibilité Cloud Functions
|
||||||
'customer': customerId.isNotEmpty
|
'customer': customerId.isNotEmpty ? customerId : null,
|
||||||
? FirebaseFirestore.instance.collection('customers').doc(customerId)
|
|
||||||
: null,
|
|
||||||
'Address': address,
|
'Address': address,
|
||||||
'Position': GeoPoint(latitude, longitude),
|
'Position': GeoPoint(latitude, longitude),
|
||||||
'Latitude': latitude,
|
'Latitude': latitude,
|
||||||
|
|||||||
@@ -26,10 +26,8 @@ class ContainerProvider with ChangeNotifier {
|
|||||||
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
/// S'assure que les conteneurs sont chargés (charge si nécessaire)
|
||||||
Future<void> ensureLoaded() async {
|
Future<void> ensureLoaded() async {
|
||||||
if (_isInitialized || _isLoading) {
|
if (_isInitialized || _isLoading) {
|
||||||
print('[ContainerProvider] Containers already loaded or loading, skipping...');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
print('[ContainerProvider] Containers not loaded, loading now...');
|
|
||||||
await loadContainers();
|
await loadContainers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,4 +154,16 @@ class LocalUserProvider with ChangeNotifier {
|
|||||||
bool hasPermission(String permission) {
|
bool hasPermission(String permission) {
|
||||||
return _currentRole?.permissions.contains(permission) ?? false;
|
return _currentRole?.permissions.contains(permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.every((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si l'utilisateur a au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<String> permissions) {
|
||||||
|
if (_currentRole == null) return false;
|
||||||
|
return permissions.any((p) => _currentRole!.permissions.contains(p));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:em2rp/config/api_config.dart';
|
import 'package:em2rp/config/api_config.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
|
|
||||||
/// Interface abstraite pour les opérations API
|
/// Interface abstraite pour les opérations API
|
||||||
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
|
/// Permet de changer facilement de backend (Firebase Functions, REST API personnalisé, etc.)
|
||||||
@@ -107,8 +108,8 @@ class FirebaseFunctionsApiService implements ApiService {
|
|||||||
return Map<String, dynamic>.from(safeData as Map);
|
return Map<String, dynamic>.from(safeData as Map);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
|
// Si l'encodage échoue, essayer de créer une copie profonde manuelle
|
||||||
print('[API] Error in _prepareForJson: $e');
|
DebugLog.error('[API] Error in _prepareForJson', e);
|
||||||
print('[API] Trying manual deep copy...');
|
DebugLog.info('[API] Trying manual deep copy...');
|
||||||
return _deepCopyMap(data);
|
return _deepCopyMap(data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,8 +150,8 @@ class FirebaseFunctionsApiService implements ApiService {
|
|||||||
// Préparer les données avec double passage pour éviter les _JsonMap
|
// Préparer les données avec double passage pour éviter les _JsonMap
|
||||||
final preparedData = _prepareForJson(data);
|
final preparedData = _prepareForJson(data);
|
||||||
|
|
||||||
// Log pour débogage
|
// Log pour débogage (seulement en mode debug)
|
||||||
print('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
|
DebugLog.info('[API] Calling $functionName with eventId: ${preparedData['eventId']}');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Encoder directement avec jsonEncode standard
|
// Encoder directement avec jsonEncode standard
|
||||||
@@ -173,8 +174,7 @@ class FirebaseFunctionsApiService implements ApiService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[API] Error during request: $e');
|
DebugLog.error('[API] Error during request: $functionName', e);
|
||||||
print('[API] Error type: ${e.runtimeType}');
|
|
||||||
throw ApiException(
|
throw ApiException(
|
||||||
message: 'Error calling $functionName: $e',
|
message: 'Error calling $functionName: $e',
|
||||||
statusCode: 0,
|
statusCode: 0,
|
||||||
|
|||||||
@@ -103,11 +103,14 @@ class DataService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Crée un nouvel équipement
|
/// Crée un équipement
|
||||||
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
Future<void> createEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'equipmentId': equipmentId, ...data};
|
// S'assurer que l'ID est dans les données
|
||||||
await _apiService.call('createEquipment', requestData);
|
final equipmentData = Map<String, dynamic>.from(data);
|
||||||
|
equipmentData['id'] = equipmentId;
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', equipmentData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
throw Exception('Erreur lors de la création de l\'équipement: $e');
|
||||||
}
|
}
|
||||||
@@ -116,8 +119,10 @@ class DataService {
|
|||||||
/// Met à jour un équipement
|
/// Met à jour un équipement
|
||||||
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(String equipmentId, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
final requestData = {'equipmentId': equipmentId, ...data};
|
await _apiService.call('updateEquipment', {
|
||||||
await _apiService.call('updateEquipment', requestData);
|
'equipmentId': equipmentId,
|
||||||
|
'data': data,
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
throw Exception('Erreur lors de la mise à jour de l\'équipement: $e');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ class EquipmentService {
|
|||||||
/// Créer un nouvel équipement (via Cloud Function)
|
/// Créer un nouvel équipement (via Cloud Function)
|
||||||
Future<void> createEquipment(EquipmentModel equipment) async {
|
Future<void> createEquipment(EquipmentModel equipment) async {
|
||||||
try {
|
try {
|
||||||
await _apiService.call('createEquipment', equipment.toMap()..['id'] = equipment.id);
|
if (equipment.id.isEmpty) {
|
||||||
|
throw Exception('L\'ID de l\'équipement est requis pour la création');
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = equipment.toMap();
|
||||||
|
data['id'] = equipment.id; // S'assurer que l'ID est inclus
|
||||||
|
|
||||||
|
await _apiService.call('createEquipment', data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error creating equipment: $e');
|
print('Error creating equipment: $e');
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -26,6 +33,10 @@ class EquipmentService {
|
|||||||
/// Mettre à jour un équipement (via Cloud Function)
|
/// Mettre à jour un équipement (via Cloud Function)
|
||||||
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
Future<void> updateEquipment(String id, Map<String, dynamic> data) async {
|
||||||
try {
|
try {
|
||||||
|
if (data.isEmpty) {
|
||||||
|
throw Exception('Aucune donnée à mettre à jour');
|
||||||
|
}
|
||||||
|
|
||||||
await _apiService.call('updateEquipment', {
|
await _apiService.call('updateEquipment', {
|
||||||
'equipmentId': id,
|
'equipmentId': id,
|
||||||
'data': data,
|
'data': data,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||||
|
|
||||||
class IcsExportService {
|
class IcsExportService {
|
||||||
|
|||||||
274
em2rp/lib/utils/app_permissions.dart
Normal file
274
em2rp/lib/utils/app_permissions.dart
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
/// Énumération centralisée de toutes les permissions de l'application
|
||||||
|
/// Chaque permission contrôle l'accès à une fonctionnalité spécifique
|
||||||
|
enum AppPermission {
|
||||||
|
// ============= ÉVÉNEMENTS =============
|
||||||
|
/// Permet de voir les événements
|
||||||
|
viewEvents('view_events'),
|
||||||
|
|
||||||
|
/// Permet de créer de nouveaux événements
|
||||||
|
createEvents('create_events'),
|
||||||
|
|
||||||
|
/// Permet de modifier les événements existants
|
||||||
|
editEvents('edit_events'),
|
||||||
|
|
||||||
|
/// Permet de supprimer des événements
|
||||||
|
deleteEvents('delete_events'),
|
||||||
|
|
||||||
|
/// Permet de voir tous les événements de tous les utilisateurs
|
||||||
|
/// (nécessaire pour le filtre par utilisateur dans le calendrier)
|
||||||
|
viewAllUserEvents('view_all_user_events'),
|
||||||
|
|
||||||
|
// ============= ÉQUIPEMENTS =============
|
||||||
|
/// Permet de voir la liste des équipements
|
||||||
|
viewEquipment('view_equipment'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des équipements
|
||||||
|
/// Inclut aussi la gestion des prix d'achat/location
|
||||||
|
manageEquipment('manage_equipment'),
|
||||||
|
|
||||||
|
// ============= CONTENEURS =============
|
||||||
|
/// Permet de voir les conteneurs
|
||||||
|
viewContainers('view_containers'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des conteneurs
|
||||||
|
manageContainers('manage_containers'),
|
||||||
|
|
||||||
|
// ============= MAINTENANCE =============
|
||||||
|
/// Permet de voir les maintenances
|
||||||
|
viewMaintenance('view_maintenance'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des maintenances
|
||||||
|
manageMaintenance('manage_maintenance'),
|
||||||
|
|
||||||
|
// ============= UTILISATEURS =============
|
||||||
|
/// Permet de voir la liste de tous les utilisateurs
|
||||||
|
viewAllUsers('view_all_users'),
|
||||||
|
|
||||||
|
/// Permet de créer, modifier et supprimer des utilisateurs
|
||||||
|
/// Inclut la gestion des rôles
|
||||||
|
manageUsers('manage_users'),
|
||||||
|
|
||||||
|
// ============= ALERTES =============
|
||||||
|
/// Reçoit les alertes de maintenance
|
||||||
|
receiveMaintenanceAlerts('receive_maintenance_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes d'événements (création, modification)
|
||||||
|
receiveEventAlerts('receive_event_alerts'),
|
||||||
|
|
||||||
|
/// Reçoit les alertes de stock faible
|
||||||
|
receiveStockAlerts('receive_stock_alerts'),
|
||||||
|
|
||||||
|
// ============= NOTIFICATIONS =============
|
||||||
|
/// Peut recevoir des notifications par email
|
||||||
|
receiveEmailNotifications('receive_email_notifications'),
|
||||||
|
|
||||||
|
/// Peut recevoir des notifications push dans le navigateur
|
||||||
|
receivePushNotifications('receive_push_notifications'),
|
||||||
|
|
||||||
|
// ============= PRÉPARATION/CHARGEMENT =============
|
||||||
|
/// Permet d'accéder aux pages de préparation d'événements
|
||||||
|
accessPreparation('access_preparation'),
|
||||||
|
|
||||||
|
/// Permet de valider les étapes de préparation
|
||||||
|
validatePreparation('validate_preparation'),
|
||||||
|
|
||||||
|
// ============= EXPORTS/RAPPORTS =============
|
||||||
|
/// Permet d'exporter des données (ICS, PDF, etc.)
|
||||||
|
exportData('export_data'),
|
||||||
|
|
||||||
|
/// Permet de générer des rapports
|
||||||
|
generateReports('generate_reports');
|
||||||
|
|
||||||
|
/// L'identifiant de la permission tel qu'il est stocké dans Firestore
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
const AppPermission(this.id);
|
||||||
|
|
||||||
|
/// Convertit une string en AppPermission
|
||||||
|
static AppPermission? fromString(String? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
try {
|
||||||
|
return AppPermission.values.firstWhere((p) => p.id == value);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne une description lisible de la permission (pour l'UI admin)
|
||||||
|
String get description {
|
||||||
|
switch (this) {
|
||||||
|
// Événements
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
return 'Voir les événements';
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
return 'Créer des événements';
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
return 'Modifier des événements';
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
return 'Supprimer des événements';
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Voir les événements de tous les utilisateurs';
|
||||||
|
|
||||||
|
// Équipements
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
return 'Voir les équipements';
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Gérer les équipements';
|
||||||
|
|
||||||
|
// Conteneurs
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
return 'Voir les conteneurs';
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Gérer les conteneurs';
|
||||||
|
|
||||||
|
// Maintenance
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
return 'Voir les maintenances';
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Gérer les maintenances';
|
||||||
|
|
||||||
|
// Utilisateurs
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
return 'Voir tous les utilisateurs';
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Gérer les utilisateurs';
|
||||||
|
|
||||||
|
// Alertes
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
return 'Recevoir les alertes de maintenance';
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
return 'Recevoir les alertes d\'événements';
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Recevoir les alertes de stock';
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
return 'Recevoir les notifications par email';
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Recevoir les notifications push';
|
||||||
|
|
||||||
|
// Préparation
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
return 'Accéder aux préparations d\'événements';
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Valider les préparations';
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
case AppPermission.exportData:
|
||||||
|
return 'Exporter des données';
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Générer des rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne la catégorie de la permission (pour l'UI de gestion des rôles)
|
||||||
|
String get category {
|
||||||
|
switch (this) {
|
||||||
|
case AppPermission.viewEvents:
|
||||||
|
case AppPermission.createEvents:
|
||||||
|
case AppPermission.editEvents:
|
||||||
|
case AppPermission.deleteEvents:
|
||||||
|
case AppPermission.viewAllUserEvents:
|
||||||
|
return 'Événements';
|
||||||
|
|
||||||
|
case AppPermission.viewEquipment:
|
||||||
|
case AppPermission.manageEquipment:
|
||||||
|
return 'Équipements';
|
||||||
|
|
||||||
|
case AppPermission.viewContainers:
|
||||||
|
case AppPermission.manageContainers:
|
||||||
|
return 'Conteneurs';
|
||||||
|
|
||||||
|
case AppPermission.viewMaintenance:
|
||||||
|
case AppPermission.manageMaintenance:
|
||||||
|
return 'Maintenance';
|
||||||
|
|
||||||
|
case AppPermission.viewAllUsers:
|
||||||
|
case AppPermission.manageUsers:
|
||||||
|
return 'Utilisateurs';
|
||||||
|
|
||||||
|
case AppPermission.receiveMaintenanceAlerts:
|
||||||
|
case AppPermission.receiveEventAlerts:
|
||||||
|
case AppPermission.receiveStockAlerts:
|
||||||
|
return 'Alertes';
|
||||||
|
|
||||||
|
case AppPermission.receiveEmailNotifications:
|
||||||
|
case AppPermission.receivePushNotifications:
|
||||||
|
return 'Notifications';
|
||||||
|
|
||||||
|
case AppPermission.accessPreparation:
|
||||||
|
case AppPermission.validatePreparation:
|
||||||
|
return 'Préparation';
|
||||||
|
|
||||||
|
case AppPermission.exportData:
|
||||||
|
case AppPermission.generateReports:
|
||||||
|
return 'Exports & Rapports';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extension pour faciliter les vérifications de permissions
|
||||||
|
extension PermissionListExtension on List<String> {
|
||||||
|
/// Vérifie si la liste contient une permission donnée
|
||||||
|
bool hasPermission(AppPermission permission) {
|
||||||
|
return contains(permission.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient toutes les permissions données
|
||||||
|
bool hasAllPermissions(List<AppPermission> permissions) {
|
||||||
|
return permissions.every((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si la liste contient au moins une des permissions données
|
||||||
|
bool hasAnyPermission(List<AppPermission> permissions) {
|
||||||
|
return permissions.any((p) => contains(p.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rôles prédéfinis avec leurs permissions
|
||||||
|
class PredefinedRoles {
|
||||||
|
/// Rôle ADMIN : Accès complet à toutes les fonctionnalités
|
||||||
|
static List<String> get admin => AppPermission.values.map((p) => p.id).toList();
|
||||||
|
|
||||||
|
/// Rôle TECHNICIEN : Gestion des équipements et préparation
|
||||||
|
static List<String> get technician => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.manageEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.manageContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.manageMaintenance.id,
|
||||||
|
AppPermission.receiveMaintenanceAlerts.id,
|
||||||
|
AppPermission.receiveStockAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.validatePreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle MANAGER : Gestion des événements et vue d'ensemble
|
||||||
|
static List<String> get manager => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.createEvents.id,
|
||||||
|
AppPermission.editEvents.id,
|
||||||
|
AppPermission.deleteEvents.id,
|
||||||
|
AppPermission.viewAllUserEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.viewMaintenance.id,
|
||||||
|
AppPermission.viewAllUsers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
AppPermission.accessPreparation.id,
|
||||||
|
AppPermission.exportData.id,
|
||||||
|
AppPermission.generateReports.id,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Rôle USER : Consultation uniquement
|
||||||
|
static List<String> get user => [
|
||||||
|
AppPermission.viewEvents.id,
|
||||||
|
AppPermission.viewEquipment.id,
|
||||||
|
AppPermission.viewContainers.id,
|
||||||
|
AppPermission.receiveEventAlerts.id,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
33
em2rp/lib/utils/debug_log.dart
Normal file
33
em2rp/lib/utils/debug_log.dart
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Helper pour gérer les logs de debug
|
||||||
|
/// Les logs sont automatiquement désactivés en mode release
|
||||||
|
class DebugLog {
|
||||||
|
/// Flag pour activer/désactiver les logs manuellement
|
||||||
|
static const bool _forceEnableLogs = false;
|
||||||
|
|
||||||
|
/// Vérifie si les logs doivent être affichés
|
||||||
|
static bool get _shouldLog => kDebugMode || _forceEnableLogs;
|
||||||
|
|
||||||
|
/// Log une information
|
||||||
|
static void info(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log une erreur (toujours affiché, même en production)
|
||||||
|
static void error(String message, [Object? error, StackTrace? stackTrace]) {
|
||||||
|
print('ERROR: $message');
|
||||||
|
if (error != null) print(' Error: $error');
|
||||||
|
if (stackTrace != null && kDebugMode) print(' StackTrace: $stackTrace');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log un warning
|
||||||
|
static void warning(String message) {
|
||||||
|
if (_shouldLog) {
|
||||||
|
print('WARNING: $message');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
35
em2rp/lib/utils/equipment_helpers.dart
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
|
||||||
|
/// Helpers pour la gestion et l'affichage des équipements
|
||||||
|
class EquipmentHelpers {
|
||||||
|
/// Détermine si un équipement devrait avoir une quantité par défaut
|
||||||
|
/// Retourne true pour câbles, consommables et structures
|
||||||
|
static bool shouldBeQuantifiableByDefault(EquipmentCategory category) {
|
||||||
|
return category == EquipmentCategory.cable ||
|
||||||
|
category == EquipmentCategory.consumable ||
|
||||||
|
category == EquipmentCategory.structure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule la quantité disponible d'un équipement
|
||||||
|
/// Prend en compte la quantité totale et la quantité déjà assignée
|
||||||
|
static int calculateAvailableQuantity(
|
||||||
|
EquipmentModel equipment,
|
||||||
|
int assignedQuantity,
|
||||||
|
) {
|
||||||
|
if (!equipment.hasQuantity) return 0;
|
||||||
|
|
||||||
|
final total = equipment.availableQuantity ?? equipment.totalQuantity ?? 0;
|
||||||
|
return (total - assignedQuantity).clamp(0, total);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vérifie si un équipement est en stock faible
|
||||||
|
/// (quantité disponible en dessous du seuil critique)
|
||||||
|
static bool isLowStock(EquipmentModel equipment) {
|
||||||
|
if (!equipment.hasQuantity) return false;
|
||||||
|
if (equipment.criticalThreshold == null) return false;
|
||||||
|
|
||||||
|
final available = equipment.availableQuantity ?? 0;
|
||||||
|
return available <= equipment.criticalThreshold!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
86
em2rp/lib/utils/price_helpers.dart
Normal file
86
em2rp/lib/utils/price_helpers.dart
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import 'package:em2rp/models/event_model.dart';
|
||||||
|
|
||||||
|
/// Helper pour la gestion des prix HT et TTC
|
||||||
|
class PriceHelpers {
|
||||||
|
/// Taux de TVA par défaut (20%)
|
||||||
|
static const double defaultTaxRate = 0.20;
|
||||||
|
|
||||||
|
/// Calcule le prix TTC à partir du prix HT
|
||||||
|
static double calculateTTC(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le prix HT à partir du prix TTC
|
||||||
|
static double calculateHT(double priceTTC, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceTTC / (1 + taxRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Calcule le montant de TVA
|
||||||
|
static double calculateTax(double priceHT, {double taxRate = defaultTaxRate}) {
|
||||||
|
return priceHT * taxRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formate un prix en euros avec deux décimales
|
||||||
|
static String formatPrice(double price) {
|
||||||
|
return '${price.toStringAsFixed(2)} €';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retourne un objet EventPricing avec HT, TVA et TTC calculés
|
||||||
|
static EventPricing getPricing(EventModel event, {double taxRate = defaultTaxRate}) {
|
||||||
|
// basePrice dans Firestore est le prix TTC (avec TVA 20% déjà incluse)
|
||||||
|
final priceTTC = event.basePrice;
|
||||||
|
final priceHT = calculateHT(priceTTC, taxRate: taxRate);
|
||||||
|
final taxAmount = calculateTax(priceHT, taxRate: taxRate);
|
||||||
|
|
||||||
|
return EventPricing(
|
||||||
|
priceHT: priceHT,
|
||||||
|
taxAmount: taxAmount,
|
||||||
|
priceTTC: priceTTC,
|
||||||
|
taxRate: taxRate,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classe pour stocker les différentes composantes du prix d'un événement
|
||||||
|
class EventPricing {
|
||||||
|
final double priceHT;
|
||||||
|
final double taxAmount;
|
||||||
|
final double priceTTC;
|
||||||
|
final double taxRate;
|
||||||
|
|
||||||
|
const EventPricing({
|
||||||
|
required this.priceHT,
|
||||||
|
required this.taxAmount,
|
||||||
|
required this.priceTTC,
|
||||||
|
required this.taxRate,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Retourne le taux de TVA en pourcentage (ex: 20.0 pour 20%)
|
||||||
|
double get taxRatePercentage => taxRate * 100;
|
||||||
|
|
||||||
|
/// Formate le prix HT
|
||||||
|
String get formattedHT => PriceHelpers.formatPrice(priceHT);
|
||||||
|
|
||||||
|
/// Formate le montant de TVA
|
||||||
|
String get formattedTax => PriceHelpers.formatPrice(taxAmount);
|
||||||
|
|
||||||
|
/// Formate le prix TTC
|
||||||
|
String get formattedTTC => PriceHelpers.formatPrice(priceTTC);
|
||||||
|
|
||||||
|
/// Retourne un résumé complet du pricing
|
||||||
|
String get summary => 'HT: $formattedHT | TVA (${taxRatePercentage.toStringAsFixed(0)}%): $formattedTax | TTC: $formattedTTC';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Widget helper pour afficher les prix
|
||||||
|
class PriceDisplay {
|
||||||
|
/// Génère un Map avec les composantes de prix pour affichage
|
||||||
|
static Map<String, String> getPriceComponents(EventModel event) {
|
||||||
|
final pricing = PriceHelpers.getPricing(event);
|
||||||
|
return {
|
||||||
|
'HT': pricing.formattedHT,
|
||||||
|
'TVA': '${pricing.formattedTax} (${pricing.taxRatePercentage.toStringAsFixed(0)}%)',
|
||||||
|
'TTC': pricing.formattedTTC,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
|||||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||||
import 'package:em2rp/views/event_add_page.dart';
|
import 'package:em2rp/views/event_add_page.dart';
|
||||||
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
import 'package:em2rp/views/widgets/calendar_widgets/mobile_calendar_view.dart';
|
||||||
|
import 'package:em2rp/views/widgets/calendar_widgets/user_filter_dropdown.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
class CalendarPage extends StatefulWidget {
|
class CalendarPage extends StatefulWidget {
|
||||||
@@ -28,6 +29,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
EventModel? _selectedEvent;
|
EventModel? _selectedEvent;
|
||||||
bool _calendarCollapsed = false;
|
bool _calendarCollapsed = false;
|
||||||
int _selectedEventIndex = 0;
|
int _selectedEventIndex = 0;
|
||||||
|
String? _selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -94,6 +96,26 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
|
||||||
|
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
|
||||||
|
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||||
|
if (_selectedUserId == null) {
|
||||||
|
return allEvents; // Pas de filtre, retourner tous les événements
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtrer les événements où l'utilisateur sélectionné fait partie de la workforce
|
||||||
|
return allEvents.where((event) {
|
||||||
|
return event.workforce.any((worker) {
|
||||||
|
if (worker is String) {
|
||||||
|
return worker == _selectedUserId;
|
||||||
|
}
|
||||||
|
// Si c'est une DocumentReference, on ne peut pas facilement comparer
|
||||||
|
// On suppose que les données sont chargées correctement en String
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
void _changeWeek(int delta) {
|
void _changeWeek(int delta) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
||||||
@@ -104,9 +126,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
final eventProvider = Provider.of<EventProvider>(context);
|
||||||
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
||||||
final isAdmin = localUserProvider.hasPermission('view_all_users');
|
final canCreateEvents = localUserProvider.hasPermission('create_events');
|
||||||
|
final canViewAllUserEvents = localUserProvider.hasPermission('view_all_user_events');
|
||||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
|
||||||
|
// Appliquer le filtre utilisateur si actif
|
||||||
|
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||||
|
|
||||||
if (eventProvider.isLoading) {
|
if (eventProvider.isLoading) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(
|
body: Center(
|
||||||
@@ -120,8 +146,42 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
title: "Calendrier",
|
title: "Calendrier",
|
||||||
),
|
),
|
||||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
body: Column(
|
||||||
floatingActionButton: isAdmin
|
children: [
|
||||||
|
// Filtre utilisateur dans le corps de la page
|
||||||
|
if (canViewAllUserEvents && !isMobile)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.grey[100],
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.filter_list, color: AppColors.rouge),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'Filtrer par utilisateur :',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: UserFilterDropdown(
|
||||||
|
selectedUserId: _selectedUserId,
|
||||||
|
onUserSelected: (userId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedUserId = userId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Corps du calendrier
|
||||||
|
Expanded(
|
||||||
|
child: isMobile ? _buildMobileLayout(filteredEvents) : _buildDesktopLayout(filteredEvents),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
floatingActionButton: canCreateEvents
|
||||||
? FloatingActionButton(
|
? FloatingActionButton(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -140,14 +200,13 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopLayout() {
|
Widget _buildDesktopLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Calendrier (65% de la largeur)
|
// Calendrier (65% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 65,
|
flex: 65,
|
||||||
child: _buildCalendar(),
|
child: _buildCalendar(filteredEvents),
|
||||||
),
|
),
|
||||||
// Détails de l'événement (35% de la largeur)
|
// Détails de l'événement (35% de la largeur)
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -156,7 +215,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
? EventDetails(
|
? EventDetails(
|
||||||
event: _selectedEvent!,
|
event: _selectedEvent!,
|
||||||
selectedDate: _selectedDay,
|
selectedDate: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onSelectEvent: (event, date) {
|
onSelectEvent: (event, date) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_selectedEvent = event;
|
_selectedEvent = event;
|
||||||
@@ -175,11 +234,10 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileLayout() {
|
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
final eventsForSelectedDay = _selectedDay == null
|
final eventsForSelectedDay = _selectedDay == null
|
||||||
? []
|
? []
|
||||||
: eventProvider.events
|
: filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == _selectedDay!.year &&
|
e.startDateTime.year == _selectedDay!.year &&
|
||||||
e.startDateTime.month == _selectedDay!.month &&
|
e.startDateTime.month == _selectedDay!.month &&
|
||||||
@@ -264,9 +322,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
child: MobileCalendarView(
|
child: MobileCalendarView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (day) {
|
onDaySelected: (day) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == day.year &&
|
e.startDateTime.year == day.year &&
|
||||||
e.startDateTime.month == day.month &&
|
e.startDateTime.month == day.month &&
|
||||||
@@ -502,13 +560,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendar() {
|
Widget _buildCalendar(List<EventModel> filteredEvents) {
|
||||||
final eventProvider = Provider.of<EventProvider>(context);
|
|
||||||
|
|
||||||
if (_calendarFormat == CalendarFormat.week) {
|
if (_calendarFormat == CalendarFormat.week) {
|
||||||
return WeekView(
|
return WeekView(
|
||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onWeekChange: _changeWeek,
|
onWeekChange: _changeWeek,
|
||||||
onEventSelected: (event) {
|
onEventSelected: (event) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -522,7 +578,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
onDaySelected: (selectedDay) {
|
onDaySelected: (selectedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((e) =>
|
.where((e) =>
|
||||||
e.startDateTime.year == selectedDay.year &&
|
e.startDateTime.year == selectedDay.year &&
|
||||||
e.startDateTime.month == selectedDay.month &&
|
e.startDateTime.month == selectedDay.month &&
|
||||||
@@ -554,9 +610,9 @@ class _CalendarPageState extends State<CalendarPage> {
|
|||||||
focusedDay: _focusedDay,
|
focusedDay: _focusedDay,
|
||||||
selectedDay: _selectedDay,
|
selectedDay: _selectedDay,
|
||||||
calendarFormat: _calendarFormat,
|
calendarFormat: _calendarFormat,
|
||||||
events: eventProvider.events,
|
events: filteredEvents,
|
||||||
onDaySelected: (selectedDay, focusedDay) {
|
onDaySelected: (selectedDay, focusedDay) {
|
||||||
final eventsForDay = eventProvider.events
|
final eventsForDay = filteredEvents
|
||||||
.where((event) =>
|
.where((event) =>
|
||||||
event.startDateTime.year == selectedDay.year &&
|
event.startDateTime.year == selectedDay.year &&
|
||||||
event.startDateTime.month == selectedDay.month &&
|
event.startDateTime.month == selectedDay.month &&
|
||||||
|
|||||||
@@ -622,6 +622,8 @@ class _ContainerDetailPageState extends State<ContainerDetailPage> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:em2rp/models/container_model.dart';
|
|||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
import 'package:em2rp/providers/container_provider.dart';
|
import 'package:em2rp/providers/container_provider.dart';
|
||||||
import 'package:em2rp/providers/equipment_provider.dart';
|
import 'package:em2rp/providers/equipment_provider.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
|
||||||
class ContainerFormPage extends StatefulWidget {
|
class ContainerFormPage extends StatefulWidget {
|
||||||
@@ -534,7 +535,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +581,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors de l\'ajout de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -593,7 +594,7 @@ class _ContainerFormPageState extends State<ContainerFormPage> {
|
|||||||
equipmentId: equipmentId,
|
equipmentId: equipmentId,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Erreur lors du retrait de l\'équipement $equipmentId: $e');
|
DebugLog.error('Erreur lors du retrait de l\'équipement $equipmentId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,6 +912,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
@@ -932,6 +935,8 @@ class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> {
|
|||||||
return Icons.inventory;
|
return Icons.inventory;
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return Icons.cable;
|
return Icons.cable;
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return Icons.local_shipping;
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return Icons.category;
|
return Icons.category;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
|
||||||
import 'package:em2rp/utils/id_generator.dart';
|
import 'package:em2rp/utils/id_generator.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.dart';
|
import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.dart';
|
||||||
|
|
||||||
class EquipmentFormPage extends StatefulWidget {
|
class EquipmentFormPage extends StatefulWidget {
|
||||||
@@ -86,7 +87,7 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
_notesController.text = equipment.notes ?? '';
|
_notesController.text = equipment.notes ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
print('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
||||||
|
|
||||||
// Charger les containers contenant cet équipement depuis Firestore
|
// Charger les containers contenant cet équipement depuis Firestore
|
||||||
_loadCurrentContainers(equipment.id);
|
_loadCurrentContainers(equipment.id);
|
||||||
@@ -103,26 +104,26 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_selectedParentBoxIds = containers.map((c) => c.id).toList();
|
_selectedParentBoxIds = containers.map((c) => c.id).toList();
|
||||||
});
|
});
|
||||||
print('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId');
|
DebugLog.info('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId');
|
||||||
print('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds');
|
DebugLog.info('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentForm] Error loading containers for equipment: $e');
|
DebugLog.error('[EquipmentForm] Error loading containers for equipment', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadAvailableBoxes() async {
|
Future<void> _loadAvailableBoxes() async {
|
||||||
try {
|
try {
|
||||||
final boxes = await _equipmentService.getBoxes();
|
final boxes = await _equipmentService.getBoxes();
|
||||||
print('[EquipmentForm] Loaded ${boxes.length} boxes from service');
|
DebugLog.info('[EquipmentForm] Loaded ${boxes.length} boxes from service');
|
||||||
for (var box in boxes) {
|
for (var box in boxes) {
|
||||||
print('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}');
|
DebugLog.info('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}');
|
||||||
}
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_availableBoxes = boxes;
|
_availableBoxes = boxes;
|
||||||
_isLoadingBoxes = false;
|
_isLoadingBoxes = false;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentForm] Error loading boxes: $e');
|
DebugLog.error('[EquipmentForm] Error loading boxes', e);
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingBoxes = false;
|
_isLoadingBoxes = false;
|
||||||
});
|
});
|
||||||
@@ -660,9 +661,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
containerId: boxId,
|
containerId: boxId,
|
||||||
equipmentId: equipment.id,
|
equipmentId: equipment.id,
|
||||||
);
|
);
|
||||||
print('[EquipmentForm] Added equipment ${equipment.id} to container $boxId');
|
DebugLog.info('[EquipmentForm] Added equipment ${equipment.id} to container $boxId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentForm] Error adding equipment to container $boxId: $e');
|
DebugLog.error('[EquipmentForm] Error adding equipment to container $boxId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -675,9 +676,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
containerId: boxId,
|
containerId: boxId,
|
||||||
equipmentId: equipment.id,
|
equipmentId: equipment.id,
|
||||||
);
|
);
|
||||||
print('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId');
|
DebugLog.info('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentForm] Error removing equipment from container $boxId: $e');
|
DebugLog.error('[EquipmentForm] Error removing equipment from container $boxId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -691,9 +692,9 @@ class _EquipmentFormPageState extends State<EquipmentFormPage> {
|
|||||||
containerId: boxId,
|
containerId: boxId,
|
||||||
equipmentId: equipment.id,
|
equipmentId: equipment.id,
|
||||||
);
|
);
|
||||||
print('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId');
|
DebugLog.info('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentForm] Error adding new equipment to container $boxId: $e');
|
DebugLog.error('[EquipmentForm] Error adding new equipment to container $boxId', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:em2rp/views/equipment_detail_page.dart';
|
|||||||
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
import 'package:em2rp/views/widgets/management/management_list.dart';
|
||||||
|
|
||||||
@@ -32,10 +33,10 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
print('[EquipmentManagementPage] initState called');
|
DebugLog.info('[EquipmentManagementPage] initState called');
|
||||||
// Charger les équipements au démarrage
|
// Charger les équipements au démarrage
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
print('[EquipmentManagementPage] Loading equipments...');
|
DebugLog.info('[EquipmentManagementPage] Loading equipments...');
|
||||||
context.read<EquipmentProvider>().loadEquipments();
|
context.read<EquipmentProvider>().loadEquipments();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -431,17 +432,17 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
Widget _buildEquipmentList() {
|
Widget _buildEquipmentList() {
|
||||||
return Consumer<EquipmentProvider>(
|
return Consumer<EquipmentProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
print('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||||
|
|
||||||
if (provider.isLoading && _cachedEquipment == null) {
|
if (provider.isLoading && _cachedEquipment == null) {
|
||||||
print('[EquipmentManagementPage] Showing loading indicator');
|
DebugLog.info('[EquipmentManagementPage] Showing loading indicator');
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
final equipments = provider.equipment;
|
final equipments = provider.equipment;
|
||||||
|
|
||||||
if (equipments.isEmpty && !provider.isLoading) {
|
if (equipments.isEmpty && !provider.isLoading) {
|
||||||
print('[EquipmentManagementPage] No equipment found');
|
DebugLog.info('[EquipmentManagementPage] No equipment found');
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -464,7 +465,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: equipments.length,
|
itemCount: equipments.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'package:em2rp/services/data_service.dart';
|
|||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
import 'package:em2rp/views/widgets/equipment/equipment_checklist_item.dart' show EquipmentChecklistItem, ChecklistStep;
|
||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
import 'package:em2rp/views/widgets/equipment/missing_equipment_dialog.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
@@ -203,7 +204,7 @@ class _EventPreparationPageState extends State<EventPreparationPage> with Single
|
|||||||
_containerCache[containerId] = container;
|
_containerCache[containerId] = container;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EventPreparationPage] Error: $e');
|
DebugLog.error('[EventPreparationPage] Error', e);
|
||||||
} finally {
|
} finally {
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:firebase_auth/firebase_auth.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -41,8 +42,6 @@ class _ForgotPasswordDialogState extends State<ForgotPasswordDialogWidget> {
|
|||||||
_errorMessage = "Erreur : ${e.message}";
|
_errorMessage = "Erreur : ${e.message}";
|
||||||
_emailSent = false;
|
_emailSent = false;
|
||||||
});
|
});
|
||||||
print(
|
|
||||||
"Erreur de réinitialisation du mot de passe: ${e.code} - ${e.message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.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:em2rp/utils/price_helpers.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
|
import 'package:em2rp/views/widgets/event_form/event_options_display_widget.dart';
|
||||||
|
|
||||||
@@ -34,13 +35,48 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
'Horaire de fin',
|
'Horaire de fin',
|
||||||
dateFormat.format(event.endDateTime),
|
dateFormat.format(event.endDateTime),
|
||||||
),
|
),
|
||||||
if (canViewPrices)
|
if (canViewPrices) ...[
|
||||||
_buildInfoRow(
|
// Calcul des prix HT/TVA/TTC
|
||||||
context,
|
Builder(
|
||||||
Icons.euro,
|
builder: (context) {
|
||||||
'Prix de base',
|
final pricing = PriceHelpers.getPricing(event);
|
||||||
currencyFormat.format(event.basePrice),
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.euro,
|
||||||
|
'Prix HT',
|
||||||
|
pricing.formattedHT,
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.percent, size: 16, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'TVA (${pricing.taxRatePercentage.toStringAsFixed(0)}%) : ${pricing.formattedTax}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInfoRow(
|
||||||
|
context,
|
||||||
|
Icons.attach_money,
|
||||||
|
'Prix TTC',
|
||||||
|
pricing.formattedTTC,
|
||||||
|
highlighted: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
if (event.options.isNotEmpty) ...[
|
if (event.options.isNotEmpty) ...[
|
||||||
EventOptionsDisplayWidget(
|
EventOptionsDisplayWidget(
|
||||||
optionsData: event.options,
|
optionsData: event.options,
|
||||||
@@ -52,34 +88,85 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Builder(
|
Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final total = event.basePrice +
|
// Total TTC = basePrice (TTC) + options (TTC)
|
||||||
|
final totalTTC = event.basePrice +
|
||||||
event.options.fold<num>(
|
event.options.fold<num>(
|
||||||
0,
|
0,
|
||||||
(sum, opt) {
|
(sum, opt) {
|
||||||
final price = opt['price'] ?? 0.0;
|
final priceTTC = opt['price'] ?? 0.0;
|
||||||
final quantity = opt['quantity'] ?? 1;
|
final quantity = opt['quantity'] ?? 1;
|
||||||
return sum + (price * quantity);
|
return sum + (priceTTC * quantity);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Calculer le total HT
|
||||||
|
final totalHT = PriceHelpers.calculateHT(totalTTC.toDouble());
|
||||||
|
final totalTVA = PriceHelpers.calculateTax(totalHT);
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.attach_money, color: AppColors.rouge),
|
// Séparateur visuel
|
||||||
const SizedBox(width: 8),
|
const Divider(thickness: 1),
|
||||||
Text(
|
const SizedBox(height: 8),
|
||||||
'Prix total : ',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
// Prix total HT
|
||||||
color: AppColors.noir,
|
Row(
|
||||||
fontWeight: FontWeight.bold,
|
children: [
|
||||||
),
|
const Icon(Icons.euro, color: AppColors.rouge, size: 22),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Prix total HT : ',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
currencyFormat.format(totalHT),
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
Text(
|
|
||||||
currencyFormat.format(total),
|
// TVA en petit
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
Padding(
|
||||||
color: AppColors.rouge,
|
padding: const EdgeInsets.only(left: 30.0, top: 4.0, bottom: 4.0),
|
||||||
fontWeight: FontWeight.bold,
|
child: Text(
|
||||||
),
|
'TVA (20%) : ${currencyFormat.format(totalTVA)}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
fontStyle: FontStyle.italic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Prix total TTC en surbrillance
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.attach_money, color: AppColors.rouge, size: 24),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Prix total TTC : ',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.noir,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
currencyFormat.format(totalTTC),
|
||||||
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.rouge,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -139,24 +226,28 @@ class EventDetailsInfo extends StatelessWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
IconData icon,
|
IconData icon,
|
||||||
String label,
|
String label,
|
||||||
String value,
|
String value, {
|
||||||
) {
|
bool highlighted = false,
|
||||||
|
}) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: AppColors.rouge),
|
Icon(icon, color: highlighted ? AppColors.rouge : AppColors.rouge),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'$label : ',
|
'$label : ',
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
color: AppColors.noir,
|
color: AppColors.noir,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: highlighted ? FontWeight.w900 : FontWeight.bold,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: highlighted ? AppColors.rouge : null,
|
||||||
|
fontWeight: highlighted ? FontWeight.bold : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:em2rp/models/user_model.dart';
|
||||||
|
import 'package:em2rp/providers/users_provider.dart';
|
||||||
|
import 'package:em2rp/utils/colors.dart';
|
||||||
|
|
||||||
|
/// Widget de filtre par utilisateur pour le calendrier
|
||||||
|
/// Affiche un dropdown permettant de filtrer les événements par utilisateur
|
||||||
|
class UserFilterDropdown extends StatefulWidget {
|
||||||
|
final String? selectedUserId;
|
||||||
|
final ValueChanged<String?> onUserSelected;
|
||||||
|
|
||||||
|
const UserFilterDropdown({
|
||||||
|
super.key,
|
||||||
|
required this.selectedUserId,
|
||||||
|
required this.onUserSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserFilterDropdown> createState() => _UserFilterDropdownState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserFilterDropdownState extends State<UserFilterDropdown> {
|
||||||
|
List<UserModel> _users = [];
|
||||||
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Charger après le premier frame pour éviter setState pendant build
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) {
|
||||||
|
_loadUsers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadUsers() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final usersProvider = Provider.of<UsersProvider>(context, listen: false);
|
||||||
|
|
||||||
|
// Ne pas appeler fetchUsers si les utilisateurs sont déjà chargés
|
||||||
|
if (usersProvider.users.isEmpty) {
|
||||||
|
await usersProvider.fetchUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_users = usersProvider.users;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 250,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
value: widget.selectedUserId,
|
||||||
|
hint: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.filter_list, size: 18, color: Colors.grey),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Tous les utilisateurs', style: TextStyle(fontSize: 14)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
isExpanded: true,
|
||||||
|
icon: const Icon(Icons.arrow_drop_down, size: 24),
|
||||||
|
style: const TextStyle(fontSize: 14, color: Colors.black87),
|
||||||
|
onChanged: widget.onUserSelected,
|
||||||
|
items: [
|
||||||
|
// Option "Tous les utilisateurs"
|
||||||
|
const DropdownMenuItem<String?>(
|
||||||
|
value: null,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.people, size: 18, color: AppColors.rouge),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Tous les utilisateurs', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Liste des utilisateurs
|
||||||
|
..._users.map((user) {
|
||||||
|
return DropdownMenuItem<String?>(
|
||||||
|
value: user.uid,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 12,
|
||||||
|
backgroundImage: user.profilePhotoUrl.isNotEmpty
|
||||||
|
? NetworkImage(user.profilePhotoUrl)
|
||||||
|
: null,
|
||||||
|
child: user.profilePhotoUrl.isEmpty
|
||||||
|
? Text(
|
||||||
|
user.firstName.isNotEmpty
|
||||||
|
? user.firstName[0].toUpperCase()
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(fontSize: 10),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${user.firstName} ${user.lastName}',
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -102,6 +102,8 @@ class ContainerEquipmentTile extends StatelessWidget {
|
|||||||
return 'Consommable';
|
return 'Consommable';
|
||||||
case EquipmentCategory.cable:
|
case EquipmentCategory.cable:
|
||||||
return 'Câble';
|
return 'Câble';
|
||||||
|
case EquipmentCategory.vehicle:
|
||||||
|
return 'Véhicule';
|
||||||
case EquipmentCategory.other:
|
case EquipmentCategory.other:
|
||||||
return 'Autre';
|
return 'Autre';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
@@ -60,7 +61,7 @@ class _EquipmentAssociatedEventsSectionState
|
|||||||
containersWithEquipment.add(containerData['id'] as String);
|
containersWithEquipment.add(containerData['id'] as String);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}: $e');
|
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ class _EquipmentAssociatedEventsSectionState
|
|||||||
events.add(event);
|
events.add(event);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}: $e');
|
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing event ${eventData['id']}', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/event_model.dart';
|
import 'package:em2rp/models/event_model.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
@@ -54,7 +55,7 @@ class _EquipmentCurrentEventsSectionState
|
|||||||
containersWithEquipment.add(containerData['id'] as String);
|
containersWithEquipment.add(containerData['id'] as String);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}: $e');
|
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +90,7 @@ class _EquipmentCurrentEventsSectionState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentCurrentEventsSection] Error parsing event $eventData: $e');
|
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing event $eventData', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class EquipmentStatusBadge extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||||
print('[EquipmentStatusBadge] Building badge for ${equipment.id}');
|
// Logs désactivés en production
|
||||||
|
|
||||||
return FutureBuilder<EquipmentStatus>(
|
return FutureBuilder<EquipmentStatus>(
|
||||||
// On calcule le statut réel de manière asynchrone
|
// On calcule le statut réel de manière asynchrone
|
||||||
@@ -26,7 +26,7 @@ class EquipmentStatusBadge extends StatelessWidget {
|
|||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
|
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
|
||||||
final status = snapshot.data ?? equipment.status;
|
final status = snapshot.data ?? equipment.status;
|
||||||
print('[EquipmentStatusBadge] ${equipment.id} - Status: ${status.label} (hasData: ${snapshot.hasData}, connectionState: ${snapshot.connectionState})');
|
// Logs désactivés en production
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/container_model.dart';
|
import 'package:em2rp/models/container_model.dart';
|
||||||
import 'package:em2rp/utils/colors.dart';
|
import 'package:em2rp/utils/colors.dart';
|
||||||
@@ -26,22 +27,11 @@ class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
print('[ParentBoxesSelector] initState');
|
|
||||||
print('[ParentBoxesSelector] Available boxes: ${widget.availableBoxes.length}');
|
|
||||||
print('[ParentBoxesSelector] Selected box IDs: ${widget.selectedBoxIds}');
|
|
||||||
|
|
||||||
// Log détaillé de chaque boîte
|
|
||||||
for (var box in widget.availableBoxes) {
|
|
||||||
print('[ParentBoxesSelector] Box - ID: ${box.id}, Name: ${box.name}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(ParentBoxesSelector oldWidget) {
|
void didUpdateWidget(ParentBoxesSelector oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
print('[ParentBoxesSelector] didUpdateWidget');
|
|
||||||
print('[ParentBoxesSelector] Old selected: ${oldWidget.selectedBoxIds}');
|
|
||||||
print('[ParentBoxesSelector] New selected: ${widget.selectedBoxIds}');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -283,10 +273,10 @@ class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
|
|||||||
final box = filteredBoxes[index];
|
final box = filteredBoxes[index];
|
||||||
final isSelected = widget.selectedBoxIds.contains(box.id);
|
final isSelected = widget.selectedBoxIds.contains(box.id);
|
||||||
if (index == 0) {
|
if (index == 0) {
|
||||||
print('[ParentBoxesSelector] Building item $index');
|
DebugLog.info('[ParentBoxesSelector] Building item $index');
|
||||||
print('[ParentBoxesSelector] Box ID: ${box.id}');
|
DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}');
|
||||||
print('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
|
DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
|
||||||
print('[ParentBoxesSelector] Is selected: $isSelected');
|
DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected');
|
||||||
}
|
}
|
||||||
return _buildBoxCard(box, isSelected);
|
return _buildBoxCard(box, isSelected);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/models/equipment_model.dart';
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
@@ -88,12 +89,14 @@ class EquipmentSelectionDialog extends StatefulWidget {
|
|||||||
|
|
||||||
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController(); // Préserve la position de scroll
|
||||||
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
final EventAvailabilityService _availabilityService = EventAvailabilityService();
|
||||||
final DataService _dataService = DataService(apiService);
|
final DataService _dataService = DataService(apiService);
|
||||||
|
|
||||||
EquipmentCategory? _selectedCategory;
|
EquipmentCategory? _selectedCategory;
|
||||||
|
|
||||||
Map<String, SelectedItem> _selectedItems = {};
|
Map<String, SelectedItem> _selectedItems = {};
|
||||||
|
final ValueNotifier<int> _selectionChangeNotifier = ValueNotifier<int>(0); // Pour notifier les changements de sélection sans setState
|
||||||
Map<String, int> _availableQuantities = {}; // Pour consommables
|
Map<String, int> _availableQuantities = {}; // Pour consommables
|
||||||
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
Map<String, List<ContainerModel>> _recommendedContainers = {}; // Recommandations
|
||||||
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
Map<String, List<AvailabilityConflict>> _equipmentConflicts = {}; // Conflits de disponibilité (détaillés)
|
||||||
@@ -108,8 +111,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
bool _isLoadingQuantities = false;
|
bool _isLoadingQuantities = false;
|
||||||
bool _isLoadingConflicts = false;
|
bool _isLoadingConflicts = false;
|
||||||
|
bool _conflictsLoaded = false; // Flag pour éviter de recharger indéfiniment
|
||||||
String _searchQuery = '';
|
String _searchQuery = '';
|
||||||
|
|
||||||
|
// Cache pour éviter les rebuilds inutiles
|
||||||
|
List<ContainerModel> _cachedContainers = [];
|
||||||
|
List<EquipmentModel> _cachedEquipment = [];
|
||||||
|
bool _initialDataLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@@ -186,18 +195,19 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentSelectionDialog] Error loading already assigned containers: $e');
|
DebugLog.error('[EquipmentSelectionDialog] Error loading already assigned containers', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
||||||
|
}
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose(); // Nettoyer le ScrollController
|
||||||
|
_selectionChangeNotifier.dispose(); // Nettoyer le ValueNotifier
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,7 +236,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
_availableQuantities[eq.id] = available;
|
_availableQuantities[eq.id] = available;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error loading quantities: $e');
|
DebugLog.error('Error loading quantities', e);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoadingQuantities = false);
|
if (mounted) setState(() => _isLoadingQuantities = false);
|
||||||
}
|
}
|
||||||
@@ -238,7 +248,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
setState(() => _isLoadingConflicts = true);
|
setState(() => _isLoadingConflicts = true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
print('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
|
DebugLog.info('[EquipmentSelectionDialog] Loading conflicts (optimized batch method)...');
|
||||||
|
|
||||||
final startTime = DateTime.now();
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
@@ -254,7 +264,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
final endTime = DateTime.now();
|
final endTime = DateTime.now();
|
||||||
final duration = endTime.difference(startTime);
|
final duration = endTime.difference(startTime);
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
|
DebugLog.info('[EquipmentSelectionDialog] Conflicts loaded in ${duration.inMilliseconds}ms');
|
||||||
|
|
||||||
// Extraire les IDs en conflit
|
// Extraire les IDs en conflit
|
||||||
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
|
final conflictingEquipmentIds = (result['conflictingEquipmentIds'] as List<dynamic>?)
|
||||||
@@ -268,8 +278,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
|
final conflictDetails = result['conflictDetails'] as Map<String, dynamic>? ?? {};
|
||||||
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
|
final equipmentQuantities = result['equipmentQuantities'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
|
DebugLog.info('[EquipmentSelectionDialog] Found ${conflictingEquipmentIds.length} equipment(s) and ${conflictingContainerIds.length} container(s) in conflict');
|
||||||
print('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
|
DebugLog.info('[EquipmentSelectionDialog] Quantity info for ${equipmentQuantities.length} equipment(s)');
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -277,6 +287,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
_conflictingContainerIds = conflictingContainerIds;
|
_conflictingContainerIds = conflictingContainerIds;
|
||||||
_conflictDetails = conflictDetails;
|
_conflictDetails = conflictDetails;
|
||||||
_equipmentQuantities = equipmentQuantities;
|
_equipmentQuantities = equipmentQuantities;
|
||||||
|
_conflictsLoaded = true; // Marquer comme chargé
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +295,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
await _updateContainerConflictStatus();
|
await _updateContainerConflictStatus();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentSelectionDialog] Error loading conflicts: $e');
|
DebugLog.error('[EquipmentSelectionDialog] Error loading conflicts', e);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _isLoadingConflicts = false);
|
if (mounted) setState(() => _isLoadingConflicts = false);
|
||||||
}
|
}
|
||||||
@@ -292,10 +303,14 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
||||||
Future<void> _updateContainerConflictStatus() async {
|
Future<void> _updateContainerConflictStatus() async {
|
||||||
|
if (!mounted) return; // Vérifier si le widget est toujours monté
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
final containers = await containerProvider.containersStream.first;
|
final containers = await containerProvider.containersStream.first;
|
||||||
|
|
||||||
|
if (!mounted) return; // Vérifier à nouveau après l'async
|
||||||
|
|
||||||
for (var container in containers) {
|
for (var container in containers) {
|
||||||
// Vérifier si le conteneur lui-même est en conflit
|
// Vérifier si le conteneur lui-même est en conflit
|
||||||
if (_conflictingContainerIds.contains(container.id)) {
|
if (_conflictingContainerIds.contains(container.id)) {
|
||||||
@@ -323,13 +338,13 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
totalChildren: container.equipmentIds.length,
|
totalChildren: container.equipmentIds.length,
|
||||||
);
|
);
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
|
DebugLog.info('[EquipmentSelectionDialog] Container ${container.id}: ${status.name} conflict (${conflictingChildren.length}/${container.equipmentIds.length} children)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[EquipmentSelectionDialog] Error updating container conflicts: $e');
|
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +370,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
if (availableQty == null) return const SizedBox.shrink();
|
if (availableQty == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
return Text(
|
return Text(
|
||||||
'Disponible : $availableQty ${equipment.category == EquipmentCategory.cable ? "m" : ""}',
|
'Disponible : $availableQty',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: availableQty > 0 ? Colors.green : Colors.red,
|
color: availableQty > 0 ? Colors.green : Colors.red,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -580,7 +595,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error finding recommended containers: $e');
|
DebugLog.error('Error finding recommended containers', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,26 +617,26 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
|
|
||||||
if (_selectedItems.containsKey(id)) {
|
if (_selectedItems.containsKey(id)) {
|
||||||
// Désélectionner
|
// Désélectionner
|
||||||
print('[EquipmentSelectionDialog] Deselecting $type: $id');
|
DebugLog.info('[EquipmentSelectionDialog] Deselecting $type: $id');
|
||||||
print('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
|
DebugLog.info('[EquipmentSelectionDialog] Before deselection, _selectedItems count: ${_selectedItems.length}');
|
||||||
|
|
||||||
if (type == SelectionType.container) {
|
if (type == SelectionType.container) {
|
||||||
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
|
// Si c'est un conteneur, désélectionner d'abord ses enfants de manière asynchrone
|
||||||
await _deselectContainerChildren(id);
|
await _deselectContainerChildren(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mise à jour sans setState pour éviter le flash
|
// Mise à jour sans setState - utiliser ValueNotifier pour notifier uniquement les cards concernées
|
||||||
_selectedItems.remove(id);
|
_selectedItems.remove(id);
|
||||||
print('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
|
DebugLog.info('[EquipmentSelectionDialog] After deselection, _selectedItems count: ${_selectedItems.length}');
|
||||||
print('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
|
DebugLog.info('[EquipmentSelectionDialog] Remaining items: ${_selectedItems.keys.toList()}');
|
||||||
|
|
||||||
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
|
// Notifier le changement sans rebuilder toute la liste
|
||||||
if (mounted) setState(() {});
|
_selectionChangeNotifier.value++;
|
||||||
} else {
|
} else {
|
||||||
// Sélectionner
|
// Sélectionner
|
||||||
print('[EquipmentSelectionDialog] Selecting $type: $id');
|
DebugLog.info('[EquipmentSelectionDialog] Selecting $type: $id');
|
||||||
|
|
||||||
// Mise à jour sans setState pour éviter le flash
|
// Mise à jour sans setState - utiliser ValueNotifier
|
||||||
_selectedItems[id] = SelectedItem(
|
_selectedItems[id] = SelectedItem(
|
||||||
id: id,
|
id: id,
|
||||||
name: name,
|
name: name,
|
||||||
@@ -639,8 +654,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
await _selectContainerChildren(id);
|
await _selectContainerChildren(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forcer uniquement la reconstruction du panneau de sélection et de la card concernée
|
// Notifier le changement sans rebuilder toute la liste
|
||||||
if (mounted) setState(() {});
|
_selectionChangeNotifier.value++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -699,7 +714,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error selecting container children: $e');
|
DebugLog.error('Error selecting container children', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -733,9 +748,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
// Retirer de la liste des conteneurs expandés
|
// Retirer de la liste des conteneurs expandés
|
||||||
_expandedContainers.remove(containerId);
|
_expandedContainers.remove(containerId);
|
||||||
|
|
||||||
print('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
|
DebugLog.info('[EquipmentSelectionDialog] Deselected container $containerId and ${container.equipmentIds.length} children');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('Error deselecting container children: $e');
|
DebugLog.error('Error deselecting container children', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -843,7 +858,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: _buildSelectionPanel()),
|
Expanded(
|
||||||
|
child: ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _selectionChangeNotifier,
|
||||||
|
builder: (context, _, __) => _buildSelectionPanel(),
|
||||||
|
),
|
||||||
|
),
|
||||||
if (_hasRecommendations)
|
if (_hasRecommendations)
|
||||||
Container(
|
Container(
|
||||||
height: 200,
|
height: 200,
|
||||||
@@ -997,34 +1017,41 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
return _buildHierarchicalList();
|
return _buildHierarchicalList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vue hiérarchique unique
|
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
|
||||||
Widget _buildHierarchicalList() {
|
Widget _buildHierarchicalList() {
|
||||||
return Consumer2<ContainerProvider, EquipmentProvider>(
|
return Consumer2<ContainerProvider, EquipmentProvider>(
|
||||||
builder: (context, containerProvider, equipmentProvider, child) {
|
builder: (context, containerProvider, equipmentProvider, child) {
|
||||||
return StreamBuilder<List<ContainerModel>>(
|
// Charger les données initiales dans le cache si pas encore fait
|
||||||
stream: containerProvider.containersStream,
|
if (!_initialDataLoaded) {
|
||||||
builder: (context, containerSnapshot) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
return StreamBuilder<List<EquipmentModel>>(
|
if (mounted) {
|
||||||
stream: equipmentProvider.equipmentStream,
|
setState(() {
|
||||||
builder: (context, equipmentSnapshot) {
|
_cachedContainers = containerProvider.containers;
|
||||||
if (containerSnapshot.connectionState == ConnectionState.waiting ||
|
_cachedEquipment = equipmentProvider.equipment;
|
||||||
equipmentSnapshot.connectionState == ConnectionState.waiting) {
|
_initialDataLoaded = true;
|
||||||
return const Center(child: CircularProgressIndicator());
|
});
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
final allContainers = containerSnapshot.data ?? [];
|
// Utiliser les données du cache au lieu des streams
|
||||||
final allEquipment = equipmentSnapshot.data ?? [];
|
final allContainers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||||
|
final allEquipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.equipment;
|
||||||
|
|
||||||
// Charger les conflits une seule fois après le chargement des données
|
// Charger les conflits une seule fois après le chargement des données
|
||||||
if (!_isLoadingConflicts && _conflictingEquipmentIds.isEmpty && allEquipment.isNotEmpty) {
|
if (!_isLoadingConflicts && !_conflictsLoaded && allEquipment.isNotEmpty) {
|
||||||
// Lancer le chargement des conflits en arrière-plan
|
// Lancer le chargement des conflits en arrière-plan
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_loadEquipmentConflicts();
|
_loadEquipmentConflicts();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtrage des boîtes
|
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
|
||||||
final filteredContainers = allContainers.where((container) {
|
return ValueListenableBuilder<int>(
|
||||||
|
valueListenable: _selectionChangeNotifier,
|
||||||
|
builder: (context, _, __) {
|
||||||
|
// Filtrage des boîtes
|
||||||
|
final filteredContainers = allContainers.where((container) {
|
||||||
if (_searchQuery.isNotEmpty) {
|
if (_searchQuery.isNotEmpty) {
|
||||||
final searchLower = _searchQuery.toLowerCase();
|
final searchLower = _searchQuery.toLowerCase();
|
||||||
return container.id.toLowerCase().contains(searchLower) ||
|
return container.id.toLowerCase().contains(searchLower) ||
|
||||||
@@ -1052,7 +1079,9 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
|
controller: _scrollController, // Préserve la position de scroll
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll
|
||||||
children: [
|
children: [
|
||||||
// SECTION 1 : BOÎTES
|
// SECTION 1 : BOÎTES
|
||||||
if (filteredContainers.isNotEmpty) ...[
|
if (filteredContainers.isNotEmpty) ...[
|
||||||
@@ -1094,10 +1123,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
); // Fin du ValueListenableBuilder
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1304,9 +1331,19 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Sélecteur de quantité pour consommables
|
// Sélecteur de quantité pour consommables (toujours affiché)
|
||||||
if (isSelected && isConsumable && availableQty != null)
|
if (isConsumable && availableQty != null)
|
||||||
_buildQuantitySelector(equipment.id, selectedItem!, availableQty),
|
_buildQuantitySelector(
|
||||||
|
equipment.id,
|
||||||
|
selectedItem ?? SelectedItem(
|
||||||
|
id: equipment.id,
|
||||||
|
name: equipment.id,
|
||||||
|
type: SelectionType.equipment,
|
||||||
|
quantity: 0, // Quantité 0 si non sélectionné
|
||||||
|
),
|
||||||
|
availableQty,
|
||||||
|
isSelected: isSelected, // Passer l'état de sélection
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1414,40 +1451,62 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widget pour le sélecteur de quantité (sans setState global pour éviter le refresh)
|
/// Widget pour le sélecteur de quantité
|
||||||
Widget _buildQuantitySelector(String equipmentId, SelectedItem selectedItem, int maxQuantity) {
|
/// Si isSelected = false, le premier clic sur + sélectionne l'item avec quantité 1
|
||||||
|
Widget _buildQuantitySelector(
|
||||||
|
String equipmentId,
|
||||||
|
SelectedItem selectedItem,
|
||||||
|
int maxQuantity, {
|
||||||
|
required bool isSelected,
|
||||||
|
}) {
|
||||||
|
final displayQuantity = isSelected ? selectedItem.quantity : 0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.remove_circle_outline),
|
icon: const Icon(Icons.remove_circle_outline),
|
||||||
onPressed: selectedItem.quantity > 1
|
onPressed: isSelected && selectedItem.quantity > 1
|
||||||
? () {
|
? () {
|
||||||
setState(() {
|
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
|
||||||
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity - 1);
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||||
});
|
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
|
color: isSelected && selectedItem.quantity > 1 ? AppColors.rouge : Colors.grey,
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
'${selectedItem.quantity}',
|
'$displayQuantity',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isSelected ? Colors.black : Colors.grey,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add_circle_outline),
|
icon: const Icon(Icons.add_circle_outline),
|
||||||
onPressed: selectedItem.quantity < maxQuantity
|
onPressed: (isSelected && selectedItem.quantity < maxQuantity) || !isSelected
|
||||||
? () {
|
? () {
|
||||||
setState(() {
|
if (!isSelected) {
|
||||||
|
// Premier clic : sélectionner avec quantité 1
|
||||||
|
_toggleSelection(
|
||||||
|
equipmentId,
|
||||||
|
selectedItem.name,
|
||||||
|
SelectionType.equipment,
|
||||||
|
maxQuantity: maxQuantity,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Item déjà sélectionné : incrémenter
|
||||||
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
|
_selectedItems[equipmentId] = selectedItem.copyWith(quantity: selectedItem.quantity + 1);
|
||||||
});
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
iconSize: 20,
|
iconSize: 20,
|
||||||
|
color: AppColors.rouge,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1635,13 +1694,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
color: AppColors.rouge,
|
color: AppColors.rouge,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
if (isExpanded) {
|
||||||
if (isExpanded) {
|
_expandedContainers.remove(container.id);
|
||||||
_expandedContainers.remove(container.id);
|
} else {
|
||||||
} else {
|
_expandedContainers.add(container.id);
|
||||||
_expandedContainers.add(container.id);
|
}
|
||||||
}
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||||
});
|
|
||||||
},
|
},
|
||||||
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
|
tooltip: isExpanded ? 'Replier' : 'Voir le contenu',
|
||||||
),
|
),
|
||||||
@@ -1996,13 +2054,12 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
|||||||
size: 18,
|
size: 18,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
setState(() {
|
if (isExpanded) {
|
||||||
if (isExpanded) {
|
_expandedContainers.remove(id);
|
||||||
_expandedContainers.remove(id);
|
} else {
|
||||||
} else {
|
_expandedContainers.add(id);
|
||||||
_expandedContainers.add(id);
|
}
|
||||||
}
|
_selectionChangeNotifier.value++; // Notifier sans rebuild complet
|
||||||
});
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
65
em2rp/lib/views/widgets/event/optimized_equipment_card.dart
Normal file
65
em2rp/lib/views/widgets/event/optimized_equipment_card.dart
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:em2rp/models/equipment_model.dart';
|
||||||
|
import 'package:em2rp/views/widgets/event/equipment_selection_dialog.dart';
|
||||||
|
|
||||||
|
/// Widget optimisé pour une card d'équipement qui ne rebuild que si nécessaire
|
||||||
|
class OptimizedEquipmentCard extends StatefulWidget {
|
||||||
|
final EquipmentModel equipment;
|
||||||
|
final bool isSelected;
|
||||||
|
final int? selectedQuantity;
|
||||||
|
final bool hasConflict;
|
||||||
|
final String? conflictMessage;
|
||||||
|
final int? availableQuantity;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Function(int)? onQuantityChanged;
|
||||||
|
|
||||||
|
const OptimizedEquipmentCard({
|
||||||
|
super.key,
|
||||||
|
required this.equipment,
|
||||||
|
required this.isSelected,
|
||||||
|
this.selectedQuantity,
|
||||||
|
required this.hasConflict,
|
||||||
|
this.conflictMessage,
|
||||||
|
this.availableQuantity,
|
||||||
|
required this.onTap,
|
||||||
|
this.onQuantityChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OptimizedEquipmentCard> createState() => _OptimizedEquipmentCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptimizedEquipmentCardState extends State<OptimizedEquipmentCard> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Le contenu de la card sera ici
|
||||||
|
// Pour l'instant, retournons juste un placeholder
|
||||||
|
return Card(
|
||||||
|
key: ValueKey('equipment_${widget.equipment.id}'),
|
||||||
|
child: ListTile(
|
||||||
|
title: Text(widget.equipment.id),
|
||||||
|
subtitle: Text('${widget.equipment.brand} - ${widget.equipment.model}'),
|
||||||
|
trailing: widget.isSelected ? Icon(Icons.check_circle, color: Colors.green) : null,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) =>
|
||||||
|
identical(this, other) ||
|
||||||
|
other is OptimizedEquipmentCard &&
|
||||||
|
runtimeType == other.runtimeType &&
|
||||||
|
widget.equipment.id == other.equipment.id &&
|
||||||
|
widget.isSelected == other.isSelected &&
|
||||||
|
widget.selectedQuantity == other.selectedQuantity &&
|
||||||
|
widget.hasConflict == other.hasConflict;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
widget.equipment.id.hashCode ^
|
||||||
|
widget.isSelected.hashCode ^
|
||||||
|
widget.selectedQuantity.hashCode ^
|
||||||
|
widget.hasConflict.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:em2rp/models/event_model.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 {
|
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
|
// Séparer équipements et conteneurs
|
||||||
final newEquipment = <EventEquipment>[];
|
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
|
// Charger les équipements et conteneurs
|
||||||
final containerProvider = context.read<ContainerProvider>();
|
final containerProvider = context.read<ContainerProvider>();
|
||||||
@@ -163,13 +164,13 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
final allContainers = await containerProvider.containersStream.first;
|
final allContainers = await containerProvider.containersStream.first;
|
||||||
final allEquipment = await equipmentProvider.equipmentStream.first;
|
final allEquipment = await equipmentProvider.equipmentStream.first;
|
||||||
|
|
||||||
print('[EventAssignedEquipmentSection] Starting conflict checks...');
|
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
|
||||||
final allConflicts = <String, List<AvailabilityConflict>>{};
|
final allConflicts = <String, List<AvailabilityConflict>>{};
|
||||||
|
|
||||||
// 1. Vérifier les conflits pour les équipements directs
|
// 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) {
|
for (var eq in newEquipment) {
|
||||||
print('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
|
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
|
||||||
|
|
||||||
final equipment = allEquipment.firstWhere(
|
final equipment = allEquipment.firstWhere(
|
||||||
(e) => e.id == eq.equipmentId,
|
(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)
|
// Pour les équipements quantifiables (consommables/câbles)
|
||||||
if (equipment.hasQuantity) {
|
if (equipment.hasQuantity) {
|
||||||
@@ -225,16 +226,16 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (conflicts.isNotEmpty) {
|
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;
|
allConflicts[eq.equipmentId] = conflicts;
|
||||||
} else {
|
} 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
|
// 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) {
|
for (var containerId in newContainers) {
|
||||||
final container = allContainers.firstWhere(
|
final container = allContainers.firstWhere(
|
||||||
(c) => c.id == containerId,
|
(c) => c.id == containerId,
|
||||||
@@ -305,24 +306,24 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (containerConflicts.isNotEmpty) {
|
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;
|
allConflicts[containerId] = containerConflicts;
|
||||||
} else {
|
} 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) {
|
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
|
// Afficher le dialog de conflits
|
||||||
final action = await showDialog<String>(
|
final action = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
|
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
|
||||||
);
|
);
|
||||||
|
|
||||||
print('[EventAssignedEquipmentSection] Conflict dialog result: $action');
|
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
|
||||||
|
|
||||||
if (action == 'cancel') {
|
if (action == 'cancel') {
|
||||||
return; // Annuler l'ajout
|
return; // Annuler l'ajout
|
||||||
@@ -398,7 +399,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
|||||||
|
|
||||||
// Recharger le cache
|
// Recharger le cache
|
||||||
await _loadEquipmentAndContainers();
|
await _loadEquipmentAndContainers();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _removeEquipment(String equipmentId) {
|
void _removeEquipment(String equipmentId) {
|
||||||
final updated = widget.assignedEquipment
|
final updated = widget.assignedEquipment
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/models/event_type_model.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 {
|
class EventBasicInfoSection extends StatelessWidget {
|
||||||
final TextEditingController nameController;
|
final TextEditingController nameController;
|
||||||
@@ -80,29 +81,9 @@ class EventBasicInfoSection extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextFormField(
|
PriceHtTtcFields(
|
||||||
controller: basePriceController,
|
basePriceController: basePriceController,
|
||||||
decoration: const InputDecoration(
|
onPriceChanged: onAnyFieldChanged,
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:em2rp/utils/colors.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/data_service.dart';
|
||||||
import 'package:em2rp/services/api_service.dart';
|
import 'package:em2rp/services/api_service.dart';
|
||||||
|
|
||||||
@@ -62,10 +64,14 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
...enrichedOptions.map((opt) {
|
...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 quantity = (opt['quantity'] ?? 1) as int;
|
||||||
final totalPrice = price * quantity;
|
final totalPriceTTC = priceTTC * quantity;
|
||||||
final isNegative = totalPrice < 0;
|
final isNegative = totalPriceTTC < 0;
|
||||||
|
|
||||||
|
// Calculer le prix HT
|
||||||
|
final priceHT = PriceHelpers.calculateHT(priceTTC.toDouble());
|
||||||
|
final totalPriceHT = priceHT * quantity;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(Icons.tune, color: AppColors.rouge),
|
leading: Icon(Icons.tune, color: AppColors.rouge),
|
||||||
@@ -98,28 +104,64 @@ class EventOptionsDisplayWidget extends StatelessWidget {
|
|||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (quantity > 1 && canViewPrices)
|
if (quantity > 1 && canViewPrices) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4.0),
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${currencyFormat.format(price)} × $quantity',
|
'HT: ${currencyFormat.format(priceHT)} × $quantity',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.grey[700],
|
color: Colors.grey[700],
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
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
|
trailing: canViewPrices
|
||||||
? Text(
|
? Column(
|
||||||
(isNegative ? '- ' : '+ ') +
|
mainAxisSize: MainAxisSize.min,
|
||||||
currencyFormat.format(totalPrice.abs()),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
style: TextStyle(
|
children: [
|
||||||
color: isNegative ? Colors.red : AppColors.noir,
|
Text(
|
||||||
fontWeight: FontWeight.bold,
|
(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,
|
: null,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
@@ -139,31 +181,71 @@ 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) {
|
final optionsTotalTTC = options.fold<num>(0, (sum, opt) {
|
||||||
final price = opt['price'] ?? 0.0;
|
final priceTTC = opt['price'] ?? 0.0;
|
||||||
final quantity = opt['quantity'] ?? 1;
|
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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
padding: const EdgeInsets.only(top: 8.0, bottom: 8.0),
|
||||||
child: Row(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.tune, color: AppColors.rouge),
|
Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
Text(
|
const Icon(Icons.tune, color: AppColors.rouge, size: 20),
|
||||||
'Total options : ',
|
const SizedBox(width: 8),
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
Text(
|
||||||
color: AppColors.noir,
|
'Total options HT : ',
|
||||||
fontWeight: FontWeight.bold,
|
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(
|
Row(
|
||||||
currencyFormat.format(optionsTotal),
|
children: [
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
const Icon(Icons.attach_money, color: AppColors.rouge, size: 20),
|
||||||
color: AppColors.rouge,
|
const SizedBox(width: 8),
|
||||||
fontWeight: FontWeight.bold,
|
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) {
|
} 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
|
// En cas d'erreur, créer une entrée avec les données disponibles
|
||||||
enrichedOptions.add({
|
enrichedOptions.add({
|
||||||
'id': optionData['id'],
|
'id': optionData['id'],
|
||||||
|
|||||||
235
em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart
Normal file
235
em2rp/lib/views/widgets/event_form/price_ht_ttc_fields.dart
Normal 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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:em2rp/utils/debug_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:em2rp/models/option_model.dart';
|
import 'package:em2rp/models/option_model.dart';
|
||||||
import 'package:em2rp/services/data_service.dart';
|
import 'package:em2rp/services/data_service.dart';
|
||||||
@@ -379,37 +380,37 @@ class _OptionPickerDialogState extends State<_OptionPickerDialog> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Debug: Afficher les informations de filtrage
|
// Debug: Afficher les informations de filtrage
|
||||||
print('=== DEBUG OptionPickerDialog ===');
|
DebugLog.info('=== DEBUG OptionPickerDialog ===');
|
||||||
print('widget.eventType: ${widget.eventType}');
|
DebugLog.info('widget.eventType: ${widget.eventType}');
|
||||||
print('_currentOptions.length: ${_currentOptions.length}');
|
DebugLog.info('_currentOptions.length: ${_currentOptions.length}');
|
||||||
|
|
||||||
final filtered = _currentOptions.where((opt) {
|
final filtered = _currentOptions.where((opt) {
|
||||||
print('Option: ${opt.name}');
|
DebugLog.info('Option: ${opt.name}');
|
||||||
print(' opt.eventTypes: ${opt.eventTypes}');
|
DebugLog.info(' opt.eventTypes: ${opt.eventTypes}');
|
||||||
print(' widget.eventType: ${widget.eventType}');
|
DebugLog.info(' widget.eventType: ${widget.eventType}');
|
||||||
|
|
||||||
if (widget.eventType == null) {
|
if (widget.eventType == null) {
|
||||||
print(' -> Filtered out: eventType is null');
|
DebugLog.info(' -> Filtered out: eventType is null');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
final matchesType = opt.eventTypes.contains(widget.eventType);
|
final matchesType = opt.eventTypes.contains(widget.eventType);
|
||||||
print(' -> matchesType: $matchesType');
|
DebugLog.info(' -> matchesType: $matchesType');
|
||||||
|
|
||||||
// Recherche dans le code ET le nom
|
// Recherche dans le code ET le nom
|
||||||
final searchLower = _search.toLowerCase();
|
final searchLower = _search.toLowerCase();
|
||||||
final matchesSearch = opt.name.toLowerCase().contains(searchLower) ||
|
final matchesSearch = opt.name.toLowerCase().contains(searchLower) ||
|
||||||
opt.code.toLowerCase().contains(searchLower);
|
opt.code.toLowerCase().contains(searchLower);
|
||||||
print(' -> matchesSearch: $matchesSearch');
|
DebugLog.info(' -> matchesSearch: $matchesSearch');
|
||||||
|
|
||||||
final result = matchesType && matchesSearch;
|
final result = matchesType && matchesSearch;
|
||||||
print(' -> Final result: $result');
|
DebugLog.info(' -> Final result: $result');
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
print('Filtered options count: ${filtered.length}');
|
DebugLog.info('Filtered options count: ${filtered.length}');
|
||||||
print('===========================');
|
DebugLog.info('===========================');
|
||||||
|
|
||||||
return Dialog(
|
return Dialog(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user