diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 66043aa..363bc19 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -1,8 +1,12 @@ import 'package:em2rp/providers/users_provider.dart'; import 'package:em2rp/providers/event_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/maintenance_provider.dart'; +import 'package:em2rp/providers/alert_provider.dart'; import 'package:em2rp/utils/auth_guard_widget.dart'; import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/login_page.dart'; +import 'package:em2rp/views/equipment_management_page.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -43,6 +47,17 @@ void main() async { ChangeNotifierProvider( create: (context) => EventProvider(), ), + + // Providers pour la gestion du matériel + ChangeNotifierProvider( + create: (context) => EquipmentProvider(), + ), + ChangeNotifierProvider( + create: (context) => MaintenanceProvider(), + ), + ChangeNotifierProvider( + create: (context) => AlertProvider(), + ), ], child: const MyApp(), ), @@ -105,6 +120,9 @@ class MyApp extends StatelessWidget { actionCode: args['actionCode'] as String, ); }, + '/equipment_management': (context) => const AuthGuard( + requiredPermission: "view_equipment", + child: EquipmentManagementPage()), }, ); } diff --git a/em2rp/lib/models/alert_model.dart b/em2rp/lib/models/alert_model.dart new file mode 100644 index 0000000..1bd8d5b --- /dev/null +++ b/em2rp/lib/models/alert_model.dart @@ -0,0 +1,89 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +enum AlertType { + lowStock, // Stock faible + maintenanceDue, // Maintenance à venir + conflict // Conflit disponibilité +} + +String alertTypeToString(AlertType type) { + switch (type) { + case AlertType.lowStock: + return 'LOW_STOCK'; + case AlertType.maintenanceDue: + return 'MAINTENANCE_DUE'; + case AlertType.conflict: + return 'CONFLICT'; + } +} + +AlertType alertTypeFromString(String? type) { + switch (type) { + case 'LOW_STOCK': + return AlertType.lowStock; + case 'MAINTENANCE_DUE': + return AlertType.maintenanceDue; + case 'CONFLICT': + return AlertType.conflict; + default: + return AlertType.conflict; + } +} + +class AlertModel { + final String id; // ID généré automatiquement + final AlertType type; // Type d'alerte + final String message; // Message de l'alerte + final String? equipmentId; // ID de l'équipement concerné (optionnel) + final DateTime createdAt; // Date de création + final bool isRead; // Statut lu/non lu + + AlertModel({ + required this.id, + required this.type, + required this.message, + this.equipmentId, + required this.createdAt, + this.isRead = false, + }); + + factory AlertModel.fromMap(Map map, String id) { + return AlertModel( + id: id, + type: alertTypeFromString(map['type']), + message: map['message'] ?? '', + equipmentId: map['equipmentId'], + createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + isRead: map['isRead'] ?? false, + ); + } + + Map toMap() { + return { + 'type': alertTypeToString(type), + 'message': message, + 'equipmentId': equipmentId, + 'createdAt': Timestamp.fromDate(createdAt), + 'isRead': isRead, + }; + } + + AlertModel copyWith({ + String? id, + AlertType? type, + String? message, + String? equipmentId, + DateTime? createdAt, + bool? isRead, + }) { + return AlertModel( + id: id ?? this.id, + type: type ?? this.type, + message: message ?? this.message, + equipmentId: equipmentId ?? this.equipmentId, + createdAt: createdAt ?? this.createdAt, + isRead: isRead ?? this.isRead, + ); + } +} + diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart new file mode 100644 index 0000000..78dea46 --- /dev/null +++ b/em2rp/lib/models/equipment_model.dart @@ -0,0 +1,279 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +enum EquipmentStatus { + available, // Disponible + inUse, // En prestation + rented, // Loué + lost, // Perdu + outOfService, // HS + maintenance, // En maintenance +} + +String equipmentStatusToString(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return 'AVAILABLE'; + case EquipmentStatus.inUse: + return 'IN_USE'; + case EquipmentStatus.rented: + return 'RENTED'; + case EquipmentStatus.lost: + return 'LOST'; + case EquipmentStatus.outOfService: + return 'OUT_OF_SERVICE'; + case EquipmentStatus.maintenance: + return 'MAINTENANCE'; + } +} + +EquipmentStatus equipmentStatusFromString(String? status) { + switch (status) { + case 'AVAILABLE': + return EquipmentStatus.available; + case 'IN_USE': + return EquipmentStatus.inUse; + case 'RENTED': + return EquipmentStatus.rented; + case 'LOST': + return EquipmentStatus.lost; + case 'OUT_OF_SERVICE': + return EquipmentStatus.outOfService; + case 'MAINTENANCE': + return EquipmentStatus.maintenance; + default: + return EquipmentStatus.available; + } +} + +enum EquipmentCategory { + lighting, // Lumière + sound, // Son + video, // Vidéo + effect, // Effets spéciaux + structure, // Structure + consumable, // Consommable + cable, // Câble + other // Autre +} + +String equipmentCategoryToString(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'LIGHTING'; + case EquipmentCategory.sound: + return 'SOUND'; + case EquipmentCategory.video: + return 'VIDEO'; + case EquipmentCategory.structure: + return 'STRUCTURE'; + case EquipmentCategory.consumable: + return 'CONSUMABLE'; + case EquipmentCategory.cable: + return 'CABLE'; + case EquipmentCategory.other: + return 'OTHER'; + case EquipmentCategory.effect: + return 'EFFECT'; + } +} + +EquipmentCategory equipmentCategoryFromString(String? category) { + switch (category) { + case 'LIGHTING': + return EquipmentCategory.lighting; + case 'SOUND': + return EquipmentCategory.sound; + case 'VIDEO': + return EquipmentCategory.video; + case 'STRUCTURE': + return EquipmentCategory.structure; + case 'CONSUMABLE': + return EquipmentCategory.consumable; + case 'CABLE': + return EquipmentCategory.cable; + case 'EFFECT': + return EquipmentCategory.effect; + case 'OTHER': + default: + return EquipmentCategory.other; + } +} + +class EquipmentModel { + final String id; // Identifiant unique (clé) + final String name; // Nom de l'équipement + final String? brand; // Marque (indexé) + final String? model; // Modèle (indexé) + final EquipmentCategory category; // Catégorie + final EquipmentStatus status; // Statut actuel + + // Prix (visible uniquement avec manage_equipment) + final double? purchasePrice; // Prix d'achat + final double? rentalPrice; // Prix de location + + // Quantité (pour consommables/câbles) + final int? totalQuantity; // Quantité totale + final int? availableQuantity; // Quantité disponible + final int? criticalThreshold; // Seuil critique pour alerte + + // Boîtes parentes (plusieurs possibles) + final List parentBoxIds; // IDs des boîtes contenant cet équipement + + // Dates & maintenance + final DateTime? purchaseDate; // Date d'achat + final DateTime? lastMaintenanceDate; // Dernière maintenance + final DateTime? nextMaintenanceDate; // Prochaine maintenance prévue + + // Maintenances (références) + final List maintenanceIds; // IDs des opérations de maintenance + + // Image + final String? imageUrl; // URL de l'image (Storage /materiel) + + // Métadonnées + final String? notes; // Notes additionnelles + final DateTime createdAt; // Date de création + final DateTime updatedAt; // Date de mise à jour + + EquipmentModel({ + required this.id, + required this.name, + this.brand, + this.model, + required this.category, + this.status = EquipmentStatus.available, + this.purchasePrice, + this.rentalPrice, + this.totalQuantity, + this.availableQuantity, + this.criticalThreshold, + this.parentBoxIds = const [], + this.purchaseDate, + this.lastMaintenanceDate, + this.nextMaintenanceDate, + this.maintenanceIds = const [], + this.imageUrl, + this.notes, + required this.createdAt, + required this.updatedAt, + }); + + factory EquipmentModel.fromMap(Map map, String id) { + // Gestion des listes + final List parentBoxIdsRaw = map['parentBoxIds'] ?? []; + final List parentBoxIds = parentBoxIdsRaw.map((e) => e.toString()).toList(); + + final List maintenanceIdsRaw = map['maintenanceIds'] ?? []; + final List maintenanceIds = maintenanceIdsRaw.map((e) => e.toString()).toList(); + + return EquipmentModel( + id: id, + name: map['name'] ?? '', + brand: map['brand'], + model: map['model'], + category: equipmentCategoryFromString(map['category']), + status: equipmentStatusFromString(map['status']), + purchasePrice: map['purchasePrice']?.toDouble(), + rentalPrice: map['rentalPrice']?.toDouble(), + totalQuantity: map['totalQuantity']?.toInt(), + availableQuantity: map['availableQuantity']?.toInt(), + criticalThreshold: map['criticalThreshold']?.toInt(), + parentBoxIds: parentBoxIds, + purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(), + nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(), + maintenanceIds: maintenanceIds, + imageUrl: map['imageUrl'], + notes: map['notes'], + createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'name': name, + 'brand': brand, + 'model': model, + 'category': equipmentCategoryToString(category), + 'status': equipmentStatusToString(status), + 'purchasePrice': purchasePrice, + 'rentalPrice': rentalPrice, + 'totalQuantity': totalQuantity, + 'availableQuantity': availableQuantity, + 'criticalThreshold': criticalThreshold, + 'parentBoxIds': parentBoxIds, + 'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null, + 'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null, + 'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null, + 'maintenanceIds': maintenanceIds, + 'imageUrl': imageUrl, + 'notes': notes, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + }; + } + + EquipmentModel copyWith({ + String? id, + String? brand, + String? name, + String? model, + EquipmentCategory? category, + EquipmentStatus? status, + double? purchasePrice, + double? rentalPrice, + int? totalQuantity, + int? availableQuantity, + int? criticalThreshold, + List? parentBoxIds, + DateTime? purchaseDate, + DateTime? lastMaintenanceDate, + DateTime? nextMaintenanceDate, + List? maintenanceIds, + String? imageUrl, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return EquipmentModel( + id: id ?? this.id, + brand: brand ?? this.brand, + name: name ?? this.name, + model: model ?? this.model, + category: category ?? this.category, + status: status ?? this.status, + purchasePrice: purchasePrice ?? this.purchasePrice, + rentalPrice: rentalPrice ?? this.rentalPrice, + totalQuantity: totalQuantity ?? this.totalQuantity, + availableQuantity: availableQuantity ?? this.availableQuantity, + criticalThreshold: criticalThreshold ?? this.criticalThreshold, + parentBoxIds: parentBoxIds ?? this.parentBoxIds, + lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate, + purchaseDate: purchaseDate ?? this.purchaseDate, + nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate, + maintenanceIds: maintenanceIds ?? this.maintenanceIds, + imageUrl: imageUrl ?? this.imageUrl, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + // Helper pour vérifier si c'est un consommable/câble avec quantité + bool get hasQuantity => category == EquipmentCategory.consumable || category == EquipmentCategory.cable; + + // Helper pour vérifier si le stock est critique + bool get isCriticalStock { + if (!hasQuantity || criticalThreshold == null || availableQuantity == null) { + return false; + } + return availableQuantity! <= criticalThreshold!; + } + + // Helper pour vérifier si la maintenance est à venir + bool get isMaintenanceDue { + if (nextMaintenanceDate == null) return false; + return nextMaintenanceDate!.isBefore(DateTime.now().add(const Duration(days: 7))); + } +} + diff --git a/em2rp/lib/models/event_model.dart b/em2rp/lib/models/event_model.dart index 76f8b8b..7f9fb52 100644 --- a/em2rp/lib/models/event_model.dart +++ b/em2rp/lib/models/event_model.dart @@ -30,6 +30,128 @@ EventStatus eventStatusFromString(String? status) { } } +enum PreparationStatus { + notStarted, + inProgress, + completed, + completedWithMissing +} + +String preparationStatusToString(PreparationStatus status) { + switch (status) { + case PreparationStatus.notStarted: + return 'NOT_STARTED'; + case PreparationStatus.inProgress: + return 'IN_PROGRESS'; + case PreparationStatus.completed: + return 'COMPLETED'; + case PreparationStatus.completedWithMissing: + return 'COMPLETED_WITH_MISSING'; + } +} + +PreparationStatus preparationStatusFromString(String? status) { + switch (status) { + case 'NOT_STARTED': + return PreparationStatus.notStarted; + case 'IN_PROGRESS': + return PreparationStatus.inProgress; + case 'COMPLETED': + return PreparationStatus.completed; + case 'COMPLETED_WITH_MISSING': + return PreparationStatus.completedWithMissing; + default: + return PreparationStatus.notStarted; + } +} + +enum ReturnStatus { + notStarted, + inProgress, + completed, + completedWithMissing +} + +String returnStatusToString(ReturnStatus status) { + switch (status) { + case ReturnStatus.notStarted: + return 'NOT_STARTED'; + case ReturnStatus.inProgress: + return 'IN_PROGRESS'; + case ReturnStatus.completed: + return 'COMPLETED'; + case ReturnStatus.completedWithMissing: + return 'COMPLETED_WITH_MISSING'; + } +} + +ReturnStatus returnStatusFromString(String? status) { + switch (status) { + case 'NOT_STARTED': + return ReturnStatus.notStarted; + case 'IN_PROGRESS': + return ReturnStatus.inProgress; + case 'COMPLETED': + return ReturnStatus.completed; + case 'COMPLETED_WITH_MISSING': + return ReturnStatus.completedWithMissing; + default: + return ReturnStatus.notStarted; + } +} + +class EventEquipment { + final String equipmentId; // ID de l'équipement + final int quantity; // Quantité (pour consommables) + final bool isPrepared; // Validé en préparation + final bool isReturned; // Validé au retour + final int? returnedQuantity; // Quantité retournée (pour consommables) + + EventEquipment({ + required this.equipmentId, + this.quantity = 1, + this.isPrepared = false, + this.isReturned = false, + this.returnedQuantity, + }); + + factory EventEquipment.fromMap(Map map) { + return EventEquipment( + equipmentId: map['equipmentId'] ?? '', + quantity: map['quantity'] ?? 1, + isPrepared: map['isPrepared'] ?? false, + isReturned: map['isReturned'] ?? false, + returnedQuantity: map['returnedQuantity'], + ); + } + + Map toMap() { + return { + 'equipmentId': equipmentId, + 'quantity': quantity, + 'isPrepared': isPrepared, + 'isReturned': isReturned, + 'returnedQuantity': returnedQuantity, + }; + } + + EventEquipment copyWith({ + String? equipmentId, + int? quantity, + bool? isPrepared, + bool? isReturned, + int? returnedQuantity, + }) { + return EventEquipment( + equipmentId: equipmentId ?? this.equipmentId, + quantity: quantity ?? this.quantity, + isPrepared: isPrepared ?? this.isPrepared, + isReturned: isReturned ?? this.isReturned, + returnedQuantity: returnedQuantity ?? this.returnedQuantity, + ); + } +} + class EventModel { final String id; final String name; @@ -50,6 +172,11 @@ class EventModel { final List> options; final EventStatus status; + // Nouveaux champs pour la gestion du matériel + final List assignedEquipment; + final PreparationStatus? preparationStatus; + final ReturnStatus? returnStatus; + EventModel({ required this.id, required this.name, @@ -69,6 +196,9 @@ class EventModel { required this.documents, this.options = const [], this.status = EventStatus.waitingForApproval, + this.assignedEquipment = const [], + this.preparationStatus, + this.returnStatus, }); factory EventModel.fromMap(Map map, String id) { @@ -149,6 +279,22 @@ class EventModel { customerId = map['customer'] as String; } + // Gestion des équipements assignés + final assignedEquipmentRaw = map['assignedEquipment'] ?? []; + final List assignedEquipment = []; + + if (assignedEquipmentRaw is List) { + for (var e in assignedEquipmentRaw) { + try { + if (e is Map) { + assignedEquipment.add(EventEquipment.fromMap(Map.from(e))); + } + } catch (equipmentError) { + print('Warning: Failed to parse equipment in event $id: $equipmentError'); + } + } + } + return EventModel( id: id, name: (map['Name'] ?? '').toString().trim(), @@ -168,6 +314,9 @@ class EventModel { documents: docs, options: options, status: eventStatusFromString(map['status'] as String?), + assignedEquipment: assignedEquipment, + preparationStatus: preparationStatusFromString(map['preparationStatus'] as String?), + returnStatus: returnStatusFromString(map['returnStatus'] as String?), ); } catch (e) { print('Error parsing event $id: $e'); @@ -220,6 +369,9 @@ class EventModel { 'documents': documents, 'options': options, 'status': eventStatusToString(status), + 'assignedEquipment': assignedEquipment.map((e) => e.toMap()).toList(), + 'preparationStatus': preparationStatus != null ? preparationStatusToString(preparationStatus!) : null, + 'returnStatus': returnStatus != null ? returnStatusToString(returnStatus!) : null, }; } } diff --git a/em2rp/lib/models/maintenance_model.dart b/em2rp/lib/models/maintenance_model.dart new file mode 100644 index 0000000..76431ae --- /dev/null +++ b/em2rp/lib/models/maintenance_model.dart @@ -0,0 +1,138 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + +enum MaintenanceType { + preventive, // Préventive + corrective, // Corrective + inspection // Inspection +} + +String maintenanceTypeToString(MaintenanceType type) { + switch (type) { + case MaintenanceType.preventive: + return 'PREVENTIVE'; + case MaintenanceType.corrective: + return 'CORRECTIVE'; + case MaintenanceType.inspection: + return 'INSPECTION'; + } +} + +MaintenanceType maintenanceTypeFromString(String? type) { + switch (type) { + case 'PREVENTIVE': + return MaintenanceType.preventive; + case 'CORRECTIVE': + return MaintenanceType.corrective; + case 'INSPECTION': + return MaintenanceType.inspection; + default: + return MaintenanceType.preventive; + } +} + +class MaintenanceModel { + final String id; // ID aléatoire + final List equipmentIds; // IDs des équipements concernés (peut être multiple) + final MaintenanceType type; // Type de maintenance + final DateTime scheduledDate; // Date planifiée + final DateTime? completedDate; // Date de réalisation (null si pas encore effectuée) + final String name; // Nom de l'opération + final String description; // Description détaillée + final String? performedBy; // ID de l'utilisateur qui a effectué la maintenance + final double? cost; // Coût de la maintenance + final String? notes; // Notes additionnelles + final DateTime createdAt; // Date de création + final DateTime updatedAt; // Date de mise à jour + + MaintenanceModel({ + required this.id, + required this.equipmentIds, + required this.type, + required this.scheduledDate, + this.completedDate, + required this.name, + required this.description, + this.performedBy, + this.cost, + this.notes, + required this.createdAt, + required this.updatedAt, + }); + + factory MaintenanceModel.fromMap(Map map, String id) { + // Gestion de la liste des équipements + final List equipmentIdsRaw = map['equipmentIds'] ?? []; + final List equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); + + return MaintenanceModel( + id: id, + equipmentIds: equipmentIds, + type: maintenanceTypeFromString(map['type']), + scheduledDate: (map['scheduledDate'] as Timestamp?)?.toDate() ?? DateTime.now(), + completedDate: (map['completedDate'] as Timestamp?)?.toDate(), + name: map['name'] ?? '', + description: map['description'] ?? '', + performedBy: map['performedBy'], + cost: map['cost']?.toDouble(), + notes: map['notes'], + createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + ); + } + + Map toMap() { + return { + 'equipmentIds': equipmentIds, + 'type': maintenanceTypeToString(type), + 'scheduledDate': Timestamp.fromDate(scheduledDate), + 'completedDate': completedDate != null ? Timestamp.fromDate(completedDate!) : null, + 'name': name, + 'description': description, + 'performedBy': performedBy, + 'cost': cost, + 'notes': notes, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + }; + } + + MaintenanceModel copyWith({ + String? id, + List? equipmentIds, + MaintenanceType? type, + DateTime? scheduledDate, + DateTime? completedDate, + String? name, + String? description, + String? performedBy, + double? cost, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return MaintenanceModel( + id: id ?? this.id, + equipmentIds: equipmentIds ?? this.equipmentIds, + type: type ?? this.type, + scheduledDate: scheduledDate ?? this.scheduledDate, + completedDate: completedDate ?? this.completedDate, + name: name ?? this.name, + description: description ?? this.description, + performedBy: performedBy ?? this.performedBy, + cost: cost ?? this.cost, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + // Helper pour vérifier si la maintenance est complétée + bool get isCompleted => completedDate != null; + + // Helper pour vérifier si la maintenance est en retard + bool get isOverdue { + if (isCompleted) return false; + return scheduledDate.isBefore(DateTime.now()); + } +} + diff --git a/em2rp/lib/providers/alert_provider.dart b/em2rp/lib/providers/alert_provider.dart new file mode 100644 index 0000000..1bf7d29 --- /dev/null +++ b/em2rp/lib/providers/alert_provider.dart @@ -0,0 +1,128 @@ +import 'package:flutter/foundation.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/alert_model.dart'; + +class AlertProvider extends ChangeNotifier { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + List _alerts = []; + + // Getters + List get alerts => _alerts; + + /// Nombre d'alertes non lues + int get unreadCount => _alerts.where((alert) => !alert.isRead).length; + + /// Alertes non lues uniquement + List get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList(); + + /// Alertes de stock critique + List get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList(); + + /// Alertes de maintenance + List get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList(); + + /// Alertes de conflit + List get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList(); + + /// Stream des alertes + Stream> get alertsStream { + return _firestore + .collection('alerts') + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) { + _alerts = snapshot.docs + .map((doc) => AlertModel.fromMap(doc.data(), doc.id)) + .toList(); + return _alerts; + }); + } + + /// Marquer une alerte comme lue + Future markAsRead(String alertId) async { + try { + await _firestore.collection('alerts').doc(alertId).update({ + 'isRead': true, + }); + notifyListeners(); + } catch (e) { + print('Error marking alert as read: $e'); + rethrow; + } + } + + /// Marquer toutes les alertes comme lues + Future markAllAsRead() async { + try { + final batch = _firestore.batch(); + + for (var alert in _alerts.where((a) => !a.isRead)) { + batch.update( + _firestore.collection('alerts').doc(alert.id), + {'isRead': true}, + ); + } + + await batch.commit(); + notifyListeners(); + } catch (e) { + print('Error marking all alerts as read: $e'); + rethrow; + } + } + + /// Supprimer une alerte + Future deleteAlert(String alertId) async { + try { + await _firestore.collection('alerts').doc(alertId).delete(); + notifyListeners(); + } catch (e) { + print('Error deleting alert: $e'); + rethrow; + } + } + + /// Supprimer toutes les alertes lues + Future deleteReadAlerts() async { + try { + final batch = _firestore.batch(); + + for (var alert in _alerts.where((a) => a.isRead)) { + batch.delete(_firestore.collection('alerts').doc(alert.id)); + } + + await batch.commit(); + notifyListeners(); + } catch (e) { + print('Error deleting read alerts: $e'); + rethrow; + } + } + + /// Créer une alerte (utilisé principalement par les services) + Future createAlert(AlertModel alert) async { + try { + await _firestore.collection('alerts').doc(alert.id).set(alert.toMap()); + notifyListeners(); + } catch (e) { + print('Error creating alert: $e'); + rethrow; + } + } + + /// Récupérer les alertes pour un équipement spécifique + Stream> getAlertsForEquipment(String equipmentId) { + return _firestore + .collection('alerts') + .where('equipmentId', isEqualTo: equipmentId) + .orderBy('createdAt', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => AlertModel.fromMap(doc.data(), doc.id)) + .toList(); + }); + } +} + diff --git a/em2rp/lib/providers/equipment_provider.dart b/em2rp/lib/providers/equipment_provider.dart new file mode 100644 index 0000000..3a8009b --- /dev/null +++ b/em2rp/lib/providers/equipment_provider.dart @@ -0,0 +1,217 @@ +import 'package:flutter/foundation.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +class EquipmentProvider extends ChangeNotifier { + final EquipmentService _service = EquipmentService(); + + List _equipment = []; + List _models = []; + List _brands = []; + + EquipmentCategory? _selectedCategory; + EquipmentStatus? _selectedStatus; + String? _selectedModel; + String _searchQuery = ''; + + // Getters + List get equipment => _equipment; + List get models => _models; + List get brands => _brands; + EquipmentCategory? get selectedCategory => _selectedCategory; + EquipmentStatus? get selectedStatus => _selectedStatus; + String? get selectedModel => _selectedModel; + String get searchQuery => _searchQuery; + + /// Stream des équipements avec filtres appliqués + Stream> get equipmentStream { + return _service.getEquipment( + category: _selectedCategory, + status: _selectedStatus, + model: _selectedModel, + searchQuery: _searchQuery, + ); + } + + /// Charger tous les modèles uniques + Future loadModels() async { + try { + _models = await _service.getAllModels(); + notifyListeners(); + } catch (e) { + print('Error loading models: $e'); + rethrow; + } + } + + /// Charger toutes les marques uniques + Future loadBrands() async { + try { + _brands = await _service.getAllBrands(); + notifyListeners(); + } catch (e) { + print('Error loading brands: $e'); + rethrow; + } + } + + /// Charger les modèles filtrés par marque + Future> loadModelsByBrand(String brand) async { + try { + return await _service.getModelsByBrand(brand); + } catch (e) { + print('Error loading models by brand: $e'); + rethrow; + } + } + + + /// Ajouter un équipement + Future addEquipment(EquipmentModel equipment) async { + try { + await _service.createEquipment(equipment); + + // Recharger les modèles si un nouveau modèle a été ajouté + if (equipment.model != null && !_models.contains(equipment.model)) { + await loadModels(); + } + } catch (e) { + print('Error adding equipment: $e'); + rethrow; + } + } + + /// Mettre à jour un équipement + Future updateEquipment(String id, Map data) async { + try { + await _service.updateEquipment(id, data); + + // Recharger les modèles si le modèle a changé + if (data.containsKey('model')) { + await loadModels(); + } + } catch (e) { + print('Error updating equipment: $e'); + rethrow; + } + } + + /// Supprimer un équipement + Future deleteEquipment(String id) async { + try { + await _service.deleteEquipment(id); + } catch (e) { + print('Error deleting equipment: $e'); + rethrow; + } + } + + /// Récupérer un équipement par ID + Future getEquipmentById(String id) async { + try { + return await _service.getEquipmentById(id); + } catch (e) { + print('Error getting equipment: $e'); + rethrow; + } + } + + /// Trouver des alternatives disponibles + Future> findAlternatives( + String model, + DateTime startDate, + DateTime endDate, + ) async { + try { + return await _service.findAlternatives(model, startDate, endDate); + } catch (e) { + print('Error finding alternatives: $e'); + rethrow; + } + } + + /// Vérifier la disponibilité d'un équipement + Future> checkAvailability( + String equipmentId, + DateTime startDate, + DateTime endDate, + ) async { + try { + return await _service.checkAvailability(equipmentId, startDate, endDate); + } catch (e) { + print('Error checking availability: $e'); + rethrow; + } + } + + /// Mettre à jour le stock d'un consommable + Future updateStock(String id, int quantityChange) async { + try { + await _service.updateStock(id, quantityChange); + } catch (e) { + print('Error updating stock: $e'); + rethrow; + } + } + + /// Vérifier les stocks critiques + Future checkCriticalStock() async { + try { + await _service.checkCriticalStock(); + } catch (e) { + print('Error checking critical stock: $e'); + rethrow; + } + } + + /// Générer les données du QR code + String generateQRCodeData(String equipmentId) { + return _service.generateQRCodeData(equipmentId); + } + + /// Vérifier si un ID est unique + Future isIdUnique(String id) async { + try { + return await _service.isIdUnique(id); + } catch (e) { + print('Error checking ID uniqueness: $e'); + rethrow; + } + } + + // === FILTRES === + + /// Définir la catégorie sélectionnée + void setSelectedCategory(EquipmentCategory? category) { + _selectedCategory = category; + notifyListeners(); + } + + /// Définir le statut sélectionné + void setSelectedStatus(EquipmentStatus? status) { + _selectedStatus = status; + notifyListeners(); + } + + /// Définir le modèle sélectionné + void setSelectedModel(String? model) { + _selectedModel = model; + notifyListeners(); + } + + /// Définir la recherche + void setSearchQuery(String query) { + _searchQuery = query; + notifyListeners(); + } + + /// Réinitialiser tous les filtres + void resetFilters() { + _selectedCategory = null; + _selectedStatus = null; + _selectedModel = null; + _searchQuery = ''; + notifyListeners(); + } +} + diff --git a/em2rp/lib/providers/event_provider.dart b/em2rp/lib/providers/event_provider.dart index 7bc4df6..23190d8 100644 --- a/em2rp/lib/providers/event_provider.dart +++ b/em2rp/lib/providers/event_provider.dart @@ -30,9 +30,7 @@ class EventProvider with ChangeNotifier { for (var doc in eventsSnapshot.docs) { try { final data = doc.data() as Map; - print('Processing event ${doc.id}: ${data['Name'] ?? 'Unknown'}'); - - final event = EventModel.fromMap(data, doc.id); + final event = EventModel.fromMap(data, doc.id); allEvents.add(event); } catch (e) { print('Failed to parse event ${doc.id}: $e'); @@ -41,8 +39,6 @@ class EventProvider with ChangeNotifier { } } - print('Successfully parsed ${allEvents.length} events, failed: $failedCount'); - // Filtrage amélioré pour les utilisateurs non-admin if (canViewAllEvents) { _events = allEvents; @@ -63,8 +59,6 @@ class EventProvider with ChangeNotifier { return isInWorkforce; }).toList(); - - print('Non-admin user: showing ${_events.length} events out of ${allEvents.length}'); } _isLoading = false; diff --git a/em2rp/lib/providers/maintenance_provider.dart b/em2rp/lib/providers/maintenance_provider.dart new file mode 100644 index 0000000..9ef0912 --- /dev/null +++ b/em2rp/lib/providers/maintenance_provider.dart @@ -0,0 +1,106 @@ +import 'package:flutter/foundation.dart'; +import 'package:em2rp/models/maintenance_model.dart'; +import 'package:em2rp/services/maintenance_service.dart'; + +class MaintenanceProvider extends ChangeNotifier { + final MaintenanceService _service = MaintenanceService(); + + List _maintenances = []; + + // Getters + List get maintenances => _maintenances; + + /// Stream des maintenances pour un équipement spécifique + Stream> getMaintenancesStream(String equipmentId) { + return _service.getMaintenances(equipmentId); + } + + /// Stream de toutes les maintenances + Stream> get allMaintenancesStream { + return _service.getAllMaintenances(); + } + + /// Créer une nouvelle maintenance + Future createMaintenance(MaintenanceModel maintenance) async { + try { + await _service.createMaintenance(maintenance); + notifyListeners(); + } catch (e) { + print('Error creating maintenance: $e'); + rethrow; + } + } + + /// Mettre à jour une maintenance + Future updateMaintenance(String id, Map data) async { + try { + await _service.updateMaintenance(id, data); + notifyListeners(); + } catch (e) { + print('Error updating maintenance: $e'); + rethrow; + } + } + + /// Supprimer une maintenance + Future deleteMaintenance(String id) async { + try { + await _service.deleteMaintenance(id); + notifyListeners(); + } catch (e) { + print('Error deleting maintenance: $e'); + rethrow; + } + } + + /// Récupérer une maintenance par ID + Future getMaintenanceById(String id) async { + try { + return await _service.getMaintenanceById(id); + } catch (e) { + print('Error getting maintenance: $e'); + rethrow; + } + } + + /// Marquer une maintenance comme complétée + Future completeMaintenance( + String id, { + String? performedBy, + double? cost, + }) async { + try { + await _service.completeMaintenance(id, performedBy: performedBy, cost: cost); + notifyListeners(); + } catch (e) { + print('Error completing maintenance: $e'); + rethrow; + } + } + + /// Vérifier les maintenances à venir + Future checkUpcomingMaintenances() async { + try { + await _service.checkUpcomingMaintenances(); + } catch (e) { + print('Error checking upcoming maintenances: $e'); + rethrow; + } + } + + /// Récupérer les maintenances en retard + List get overdueMaintances { + return _maintenances.where((m) => m.isOverdue).toList(); + } + + /// Récupérer les maintenances complétées + List get completedMaintenances { + return _maintenances.where((m) => m.isCompleted).toList(); + } + + /// Récupérer les maintenances à venir + List get upcomingMaintenances { + return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList(); + } +} + diff --git a/em2rp/lib/services/equipment_service.dart b/em2rp/lib/services/equipment_service.dart new file mode 100644 index 0000000..c387ad5 --- /dev/null +++ b/em2rp/lib/services/equipment_service.dart @@ -0,0 +1,373 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/models/alert_model.dart'; + +class EquipmentService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + // Collection references + CollectionReference get _equipmentCollection => _firestore.collection('equipments'); + CollectionReference get _alertsCollection => _firestore.collection('alerts'); + CollectionReference get _eventsCollection => _firestore.collection('events'); + + // CRUD Operations + + /// Créer un nouvel équipement + Future createEquipment(EquipmentModel equipment) async { + try { + await _equipmentCollection.doc(equipment.id).set(equipment.toMap()); + } catch (e) { + print('Error creating equipment: $e'); + rethrow; + } + } + + /// Mettre à jour un équipement + Future updateEquipment(String id, Map data) async { + try { + data['updatedAt'] = Timestamp.fromDate(DateTime.now()); + await _equipmentCollection.doc(id).update(data); + } catch (e) { + print('Error updating equipment: $e'); + rethrow; + } + } + + /// Supprimer un équipement + Future deleteEquipment(String id) async { + try { + await _equipmentCollection.doc(id).delete(); + } catch (e) { + print('Error deleting equipment: $e'); + rethrow; + } + } + + /// Récupérer un équipement par ID + Future getEquipmentById(String id) async { + try { + final doc = await _equipmentCollection.doc(id).get(); + if (doc.exists) { + return EquipmentModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting equipment: $e'); + rethrow; + } + } + + /// Récupérer les équipements avec filtres + Stream> getEquipment({ + EquipmentCategory? category, + EquipmentStatus? status, + String? model, + String? searchQuery, + }) { + try { + Query query = _equipmentCollection; + + // Filtre par catégorie + if (category != null) { + query = query.where('category', isEqualTo: equipmentCategoryToString(category)); + } + + // Filtre par statut + if (status != null) { + query = query.where('status', isEqualTo: equipmentStatusToString(status)); + } + + // Filtre par modèle + if (model != null && model.isNotEmpty) { + query = query.where('model', isEqualTo: model); + } + + return query.snapshots().map((snapshot) { + List equipmentList = snapshot.docs + .map((doc) => EquipmentModel.fromMap(doc.data() as Map, doc.id)) + .toList(); + + // Filtre par recherche texte (côté client car Firestore ne supporte pas les recherches texte complexes) + if (searchQuery != null && searchQuery.isNotEmpty) { + final lowerSearch = searchQuery.toLowerCase(); + equipmentList = equipmentList.where((equipment) { + return equipment.name.toLowerCase().contains(lowerSearch) || + (equipment.model?.toLowerCase().contains(lowerSearch) ?? false) || + equipment.id.toLowerCase().contains(lowerSearch); + }).toList(); + } + + return equipmentList; + }); + } catch (e) { + print('Error streaming equipment: $e'); + rethrow; + } + } + + /// Vérifier la disponibilité d'un équipement pour une période donnée + Future> checkAvailability( + String equipmentId, + DateTime startDate, + DateTime endDate, + ) async { + try { + final conflicts = []; + + // Récupérer tous les événements qui chevauchent la période + final eventsQuery = await _eventsCollection + .where('StartDateTime', isLessThanOrEqualTo: Timestamp.fromDate(endDate)) + .where('EndDateTime', isGreaterThanOrEqualTo: Timestamp.fromDate(startDate)) + .get(); + + for (var eventDoc in eventsQuery.docs) { + final eventData = eventDoc.data() as Map; + final assignedEquipmentRaw = eventData['assignedEquipment'] ?? []; + + if (assignedEquipmentRaw is List) { + for (var eq in assignedEquipmentRaw) { + if (eq is Map && eq['equipmentId'] == equipmentId) { + conflicts.add(eventDoc.id); + break; + } + } + } + } + + return conflicts; + } catch (e) { + print('Error checking availability: $e'); + rethrow; + } + } + + /// Trouver des alternatives (même modèle) disponibles + Future> findAlternatives( + String model, + DateTime startDate, + DateTime endDate, + ) async { + try { + // Récupérer tous les équipements du même modèle + final equipmentQuery = await _equipmentCollection + .where('model', isEqualTo: model) + .get(); + + final alternatives = []; + + for (var doc in equipmentQuery.docs) { + final equipment = EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ); + + // Vérifier la disponibilité + final conflicts = await checkAvailability(equipment.id, startDate, endDate); + + if (conflicts.isEmpty && equipment.status == EquipmentStatus.available) { + alternatives.add(equipment); + } + } + + return alternatives; + } catch (e) { + print('Error finding alternatives: $e'); + rethrow; + } + } + + /// Mettre à jour le stock d'un consommable/câble + Future updateStock(String id, int quantityChange) async { + try { + final equipment = await getEquipmentById(id); + if (equipment == null) { + throw Exception('Equipment not found'); + } + + if (!equipment.hasQuantity) { + throw Exception('Equipment does not have quantity tracking'); + } + + final newAvailableQuantity = (equipment.availableQuantity ?? 0) + quantityChange; + + await updateEquipment(id, { + 'availableQuantity': newAvailableQuantity, + }); + + // Vérifier si le seuil critique est atteint + if (equipment.criticalThreshold != null && + newAvailableQuantity <= equipment.criticalThreshold!) { + await _createLowStockAlert(equipment); + } + } catch (e) { + print('Error updating stock: $e'); + rethrow; + } + } + + /// Vérifier les stocks critiques et créer des alertes + Future checkCriticalStock() async { + try { + final equipmentQuery = await _equipmentCollection + .where('category', whereIn: [ + equipmentCategoryToString(EquipmentCategory.consumable), + equipmentCategoryToString(EquipmentCategory.cable), + ]) + .get(); + + for (var doc in equipmentQuery.docs) { + final equipment = EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ); + + if (equipment.isCriticalStock) { + await _createLowStockAlert(equipment); + } + } + } catch (e) { + print('Error checking critical stock: $e'); + rethrow; + } + } + + /// Créer une alerte de stock faible + Future _createLowStockAlert(EquipmentModel equipment) async { + try { + // Vérifier si une alerte existe déjà pour cet équipement + final existingAlerts = await _alertsCollection + .where('equipmentId', isEqualTo: equipment.id) + .where('type', isEqualTo: alertTypeToString(AlertType.lowStock)) + .where('isRead', isEqualTo: false) + .get(); + + if (existingAlerts.docs.isEmpty) { + final alert = AlertModel( + id: _alertsCollection.doc().id, + type: AlertType.lowStock, + message: 'Stock critique pour ${equipment.name} (${equipment.model ?? ""}): ${equipment.availableQuantity}/${equipment.criticalThreshold}', + equipmentId: equipment.id, + createdAt: DateTime.now(), + ); + + await _alertsCollection.doc(alert.id).set(alert.toMap()); + } + } catch (e) { + print('Error creating low stock alert: $e'); + rethrow; + } + } + + /// Générer les données du QR code (ID de l'équipement) + String generateQRCodeData(String equipmentId) { + // Pour l'instant, on retourne simplement l'ID + // On pourrait aussi générer une URL complète : https://app.em2events.fr/equipment/$equipmentId + return equipmentId; + } + + /// Récupérer tous les modèles uniques (pour l'indexation/autocomplete) + Future> getAllModels() async { + try { + final equipmentQuery = await _equipmentCollection.get(); + final models = {}; + + for (var doc in equipmentQuery.docs) { + final data = doc.data() as Map; + final model = data['model'] as String?; + if (model != null && model.isNotEmpty) { + models.add(model); + } + } + + return models.toList()..sort(); + } catch (e) { + print('Error getting all models: $e'); + rethrow; + } + } + + /// Récupérer toutes les marques uniques (pour l'indexation/autocomplete) + Future> getAllBrands() async { + try { + final equipmentQuery = await _equipmentCollection.get(); + final brands = {}; + + for (var doc in equipmentQuery.docs) { + final data = doc.data() as Map; + final brand = data['brand'] as String?; + if (brand != null && brand.isNotEmpty) { + brands.add(brand); + } + } + + return brands.toList()..sort(); + } catch (e) { + print('Error getting all brands: $e'); + rethrow; + } + } + + /// Récupérer les modèles filtrés par marque + Future> getModelsByBrand(String brand) async { + try { + final equipmentQuery = await _equipmentCollection + .where('brand', isEqualTo: brand) + .get(); + final models = {}; + + for (var doc in equipmentQuery.docs) { + final data = doc.data() as Map; + final model = data['model'] as String?; + if (model != null && model.isNotEmpty) { + models.add(model); + } + } + + return models.toList()..sort(); + } catch (e) { + print('Error getting models by brand: $e'); + rethrow; + } + } + + /// Vérifier si un ID existe déjà + Future isIdUnique(String id) async { + try { + final doc = await _equipmentCollection.doc(id).get(); + return !doc.exists; + } catch (e) { + print('Error checking ID uniqueness: $e'); + rethrow; + } + } + + /// Récupérer toutes les boîtes (équipements qui peuvent contenir d'autres équipements) + Future> getBoxes() async { + try { + // Les boîtes sont généralement des équipements de catégorie "structure" ou "other" + // On pourrait aussi ajouter un champ spécifique "isBox" dans le modèle + final equipmentQuery = await _equipmentCollection + .where('category', whereIn: [ + equipmentCategoryToString(EquipmentCategory.structure), + equipmentCategoryToString(EquipmentCategory.other), + ]) + .get(); + + final boxes = []; + for (var doc in equipmentQuery.docs) { + final equipment = EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ); + // On pourrait ajouter un filtre supplémentaire ici si besoin + boxes.add(equipment); + } + + return boxes; + } catch (e) { + print('Error getting boxes: $e'); + rethrow; + } + } +} + diff --git a/em2rp/lib/services/event_preparation_service.dart b/em2rp/lib/services/event_preparation_service.dart new file mode 100644 index 0000000..011ee30 --- /dev/null +++ b/em2rp/lib/services/event_preparation_service.dart @@ -0,0 +1,363 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +class EventPreparationService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final EquipmentService _equipmentService = EquipmentService(); + + // Collection references + CollectionReference get _eventsCollection => _firestore.collection('events'); + CollectionReference get _equipmentCollection => _firestore.collection('equipment'); + + // === PRÉPARATION === + + /// Valider un équipement individuel en préparation + Future validateEquipmentPreparation(String eventId, String equipmentId) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Mettre à jour le statut de l'équipement dans la liste + final updatedEquipment = event.assignedEquipment.map((eq) { + if (eq.equipmentId == equipmentId) { + return eq.copyWith(isPrepared: true); + } + return eq; + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }); + } catch (e) { + print('Error validating equipment preparation: $e'); + rethrow; + } + } + + /// Valider tous les équipements en préparation + Future validateAllPreparation(String eventId) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Marquer tous les équipements comme préparés + final updatedEquipment = event.assignedEquipment.map((eq) { + return eq.copyWith(isPrepared: true); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'preparationStatus': preparationStatusToString(PreparationStatus.completed), + }); + + // Mettre à jour le statut des équipements à "inUse" + for (var equipment in event.assignedEquipment) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + } + } catch (e) { + print('Error validating all preparation: $e'); + rethrow; + } + } + + /// Finaliser la préparation avec des équipements manquants + Future completePreparationWithMissing( + String eventId, + List missingEquipmentIds, + ) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Marquer comme complété avec manquants + await _eventsCollection.doc(eventId).update({ + 'preparationStatus': preparationStatusToString(PreparationStatus.completedWithMissing), + }); + + // Mettre à jour le statut des équipements préparés à "inUse" + for (var equipment in event.assignedEquipment) { + if (equipment.isPrepared && !missingEquipmentIds.contains(equipment.equipmentId)) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.inUse); + } + } + } catch (e) { + print('Error completing preparation with missing: $e'); + rethrow; + } + } + + // === RETOUR === + + + /// Valider le retour d'un équipement individuel + Future validateEquipmentReturn( + String eventId, + String equipmentId, { + int? returnedQuantity, + }) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Mettre à jour le statut de l'équipement dans la liste + final updatedEquipment = event.assignedEquipment.map((eq) { + if (eq.equipmentId == equipmentId) { + return eq.copyWith( + isReturned: true, + returnedQuantity: returnedQuantity, + ); + } + return eq; + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }); + + // Mettre à jour le stock si c'est un consommable + if (returnedQuantity != null) { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipment.hasQuantity) { + final currentAvailable = equipment.availableQuantity ?? 0; + await _equipmentCollection.doc(equipmentId).update({ + 'availableQuantity': currentAvailable + returnedQuantity, + }); + } + } + } + } catch (e) { + print('Error validating equipment return: $e'); + rethrow; + } + } + + /// Valider tous les retours + Future validateAllReturn(String eventId) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Marquer tous les équipements comme retournés + final updatedEquipment = event.assignedEquipment.map((eq) { + return eq.copyWith( + isReturned: true, + returnedQuantity: eq.returnedQuantity ?? eq.quantity, + ); + }).toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + 'returnStatus': returnStatusToString(ReturnStatus.completed), + }); + + // Mettre à jour le statut des équipements à "available" et gérer les stocks + for (var equipment in updatedEquipment) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + + // Restaurer le stock pour les consommables + final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (equipmentDoc.exists) { + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipment.equipmentId).update({ + 'availableQuantity': currentAvailable + equipment.returnedQuantity!, + }); + } + } + } + } catch (e) { + print('Error validating all return: $e'); + rethrow; + } + } + + /// Finaliser le retour avec des équipements manquants + Future completeReturnWithMissing( + String eventId, + List missingEquipmentIds, + ) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Marquer comme complété avec manquants + await _eventsCollection.doc(eventId).update({ + 'returnStatus': returnStatusToString(ReturnStatus.completedWithMissing), + }); + + // Mettre à jour le statut des équipements retournés à "available" + for (var equipment in event.assignedEquipment) { + if (equipment.isReturned && !missingEquipmentIds.contains(equipment.equipmentId)) { + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.available); + + // Restaurer le stock pour les consommables + final equipmentDoc = await _equipmentCollection.doc(equipment.equipmentId).get(); + if (equipmentDoc.exists) { + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipmentData.hasQuantity && equipment.returnedQuantity != null) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipment.equipmentId).update({ + 'availableQuantity': currentAvailable + equipment.returnedQuantity!, + }); + } + } + } else if (missingEquipmentIds.contains(equipment.equipmentId)) { + // Marquer comme perdu + await updateEquipmentStatus(equipment.equipmentId, EquipmentStatus.lost); + } + } + } catch (e) { + print('Error completing return with missing: $e'); + rethrow; + } + } + + // === HELPERS === + + /// Mettre à jour le statut d'un équipement + Future updateEquipmentStatus(String equipmentId, EquipmentStatus status) async { + try { + await _equipmentCollection.doc(equipmentId).update({ + 'status': equipmentStatusToString(status), + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } catch (e) { + print('Error updating equipment status: $e'); + rethrow; + } + } + + /// Récupérer un événement + Future _getEvent(String eventId) async { + try { + final doc = await _eventsCollection.doc(eventId).get(); + if (doc.exists) { + return EventModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting event: $e'); + rethrow; + } + } + + /// Ajouter un équipement à un événement + Future addEquipmentToEvent( + String eventId, + String equipmentId, { + int quantity = 1, + }) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + // Vérifier que l'équipement n'est pas déjà ajouté + final alreadyAdded = event.assignedEquipment.any((eq) => eq.equipmentId == equipmentId); + if (alreadyAdded) { + throw Exception('Equipment already added to event'); + } + + final newEquipment = EventEquipment( + equipmentId: equipmentId, + quantity: quantity, + ); + + final updatedEquipment = [...event.assignedEquipment, newEquipment]; + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }); + + // Décrémenter le stock pour les consommables + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipmentData.hasQuantity) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipmentId).update({ + 'availableQuantity': currentAvailable - quantity, + }); + } + } + } catch (e) { + print('Error adding equipment to event: $e'); + rethrow; + } + } + + /// Retirer un équipement d'un événement + Future removeEquipmentFromEvent(String eventId, String equipmentId) async { + try { + final event = await _getEvent(eventId); + if (event == null) { + throw Exception('Event not found'); + } + + final equipmentToRemove = event.assignedEquipment.firstWhere( + (eq) => eq.equipmentId == equipmentId, + ); + + final updatedEquipment = event.assignedEquipment + .where((eq) => eq.equipmentId != equipmentId) + .toList(); + + await _eventsCollection.doc(eventId).update({ + 'assignedEquipment': updatedEquipment.map((e) => e.toMap()).toList(), + }); + + // Restaurer le stock pour les consommables + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipmentData = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipmentData.hasQuantity) { + final currentAvailable = equipmentData.availableQuantity ?? 0; + await _equipmentCollection.doc(equipmentId).update({ + 'availableQuantity': currentAvailable + equipmentToRemove.quantity, + }); + } + } + } catch (e) { + print('Error removing equipment from event: $e'); + rethrow; + } + } +} diff --git a/em2rp/lib/services/maintenance_service.dart b/em2rp/lib/services/maintenance_service.dart new file mode 100644 index 0000000..a0f9954 --- /dev/null +++ b/em2rp/lib/services/maintenance_service.dart @@ -0,0 +1,281 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/maintenance_model.dart'; +import 'package:em2rp/models/alert_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +class MaintenanceService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + final EquipmentService _equipmentService = EquipmentService(); + + // Collection references + CollectionReference get _maintenancesCollection => _firestore.collection('maintenances'); + CollectionReference get _equipmentCollection => _firestore.collection('equipment'); + CollectionReference get _alertsCollection => _firestore.collection('alerts'); + + // CRUD Operations + + /// Créer une nouvelle maintenance + Future createMaintenance(MaintenanceModel maintenance) async { + try { + await _maintenancesCollection.doc(maintenance.id).set(maintenance.toMap()); + + // Mettre à jour les équipements concernés + for (String equipmentId in maintenance.equipmentIds) { + await _updateEquipmentMaintenanceList(equipmentId, maintenance.id); + + // Si la maintenance est planifiée dans les 7 prochains jours, créer une alerte + if (maintenance.scheduledDate.isBefore(DateTime.now().add(const Duration(days: 7)))) { + await _createMaintenanceAlert(equipmentId, maintenance); + } + } + } catch (e) { + print('Error creating maintenance: $e'); + rethrow; + } + } + + /// Mettre à jour une maintenance + Future updateMaintenance(String id, Map data) async { + try { + data['updatedAt'] = Timestamp.fromDate(DateTime.now()); + await _maintenancesCollection.doc(id).update(data); + } catch (e) { + print('Error updating maintenance: $e'); + rethrow; + } + } + + /// Supprimer une maintenance + Future deleteMaintenance(String id) async { + try { + // Récupérer la maintenance pour connaître les équipements + final doc = await _maintenancesCollection.doc(id).get(); + if (doc.exists) { + final maintenance = MaintenanceModel.fromMap( + doc.data() as Map, + doc.id, + ); + + // Retirer la maintenance des équipements + for (String equipmentId in maintenance.equipmentIds) { + await _removeMaintenanceFromEquipment(equipmentId, id); + } + } + + await _maintenancesCollection.doc(id).delete(); + } catch (e) { + print('Error deleting maintenance: $e'); + rethrow; + } + } + + /// Récupérer une maintenance par ID + Future getMaintenanceById(String id) async { + try { + final doc = await _maintenancesCollection.doc(id).get(); + if (doc.exists) { + return MaintenanceModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting maintenance: $e'); + rethrow; + } + } + + /// Récupérer l'historique des maintenances pour un équipement + Stream> getMaintenances(String equipmentId) { + try { + return _maintenancesCollection + .where('equipmentIds', arrayContains: equipmentId) + .orderBy('scheduledDate', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => MaintenanceModel.fromMap( + doc.data() as Map, + doc.id, + )) + .toList(); + }); + } catch (e) { + print('Error streaming maintenances: $e'); + rethrow; + } + } + + /// Récupérer toutes les maintenances + Stream> getAllMaintenances() { + try { + return _maintenancesCollection + .orderBy('scheduledDate', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => MaintenanceModel.fromMap( + doc.data() as Map, + doc.id, + )) + .toList(); + }); + } catch (e) { + print('Error streaming all maintenances: $e'); + rethrow; + } + } + + /// Marquer une maintenance comme complétée + Future completeMaintenance(String id, {String? performedBy, double? cost}) async { + try { + final updateData = { + 'completedDate': Timestamp.fromDate(DateTime.now()), + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }; + + if (performedBy != null) { + updateData['performedBy'] = performedBy; + } + + if (cost != null) { + updateData['cost'] = cost; + } + + await updateMaintenance(id, updateData); + + // Mettre à jour la date de dernière maintenance des équipements + final maintenance = await getMaintenanceById(id); + if (maintenance != null) { + for (String equipmentId in maintenance.equipmentIds) { + await _equipmentCollection.doc(equipmentId).update({ + 'lastMaintenanceDate': Timestamp.fromDate(DateTime.now()), + }); + } + } + } catch (e) { + print('Error completing maintenance: $e'); + rethrow; + } + } + + /// Vérifier les maintenances à venir et créer des alertes + Future checkUpcomingMaintenances() async { + try { + final sevenDaysFromNow = DateTime.now().add(const Duration(days: 7)); + + // Récupérer les maintenances planifiées dans les 7 prochains jours + final maintenancesQuery = await _maintenancesCollection + .where('scheduledDate', isLessThanOrEqualTo: Timestamp.fromDate(sevenDaysFromNow)) + .where('completedDate', isNull: true) + .get(); + + for (var doc in maintenancesQuery.docs) { + final maintenance = MaintenanceModel.fromMap( + doc.data() as Map, + doc.id, + ); + + for (String equipmentId in maintenance.equipmentIds) { + await _createMaintenanceAlert(equipmentId, maintenance); + } + } + } catch (e) { + print('Error checking upcoming maintenances: $e'); + rethrow; + } + } + + /// Créer une alerte de maintenance à venir + Future _createMaintenanceAlert(String equipmentId, MaintenanceModel maintenance) async { + try { + // Vérifier si une alerte existe déjà + final existingAlerts = await _alertsCollection + .where('equipmentId', isEqualTo: equipmentId) + .where('type', isEqualTo: alertTypeToString(AlertType.maintenanceDue)) + .where('isRead', isEqualTo: false) + .get(); + + // Vérifier si l'alerte concerne la même maintenance + bool alertExists = false; + for (var alertDoc in existingAlerts.docs) { + final alertData = alertDoc.data() as Map; + if (alertData['message']?.contains(maintenance.name) ?? false) { + alertExists = true; + break; + } + } + + if (!alertExists) { + // Récupérer l'équipement pour le nom + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + String equipmentName = equipmentId; + if (equipmentDoc.exists) { + final equipmentData = equipmentDoc.data() as Map; + equipmentName = equipmentData['name'] ?? equipmentId; + } + + final daysUntil = maintenance.scheduledDate.difference(DateTime.now()).inDays; + final alert = AlertModel( + id: _alertsCollection.doc().id, + type: AlertType.maintenanceDue, + message: 'Maintenance "${maintenance.name}" prévue dans $daysUntil jour(s) pour $equipmentName', + equipmentId: equipmentId, + createdAt: DateTime.now(), + ); + + await _alertsCollection.doc(alert.id).set(alert.toMap()); + } + } catch (e) { + print('Error creating maintenance alert: $e'); + rethrow; + } + } + + /// Mettre à jour la liste des maintenances d'un équipement + Future _updateEquipmentMaintenanceList(String equipmentId, String maintenanceId) async { + try { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + final updatedMaintenanceIds = List.from(equipment.maintenanceIds); + if (!updatedMaintenanceIds.contains(maintenanceId)) { + updatedMaintenanceIds.add(maintenanceId); + + await _equipmentCollection.doc(equipmentId).update({ + 'maintenanceIds': updatedMaintenanceIds, + }); + } + } + } catch (e) { + print('Error updating equipment maintenance list: $e'); + rethrow; + } + } + + /// Retirer une maintenance de la liste d'un équipement + Future _removeMaintenanceFromEquipment(String equipmentId, String maintenanceId) async { + try { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + final updatedMaintenanceIds = List.from(equipment.maintenanceIds); + updatedMaintenanceIds.remove(maintenanceId); + + await _equipmentCollection.doc(equipmentId).update({ + 'maintenanceIds': updatedMaintenanceIds, + }); + } + } catch (e) { + print('Error removing maintenance from equipment: $e'); + rethrow; + } + } +} diff --git a/em2rp/lib/views/equipment_form/brand_model_selector.dart b/em2rp/lib/views/equipment_form/brand_model_selector.dart new file mode 100644 index 0000000..f36311d --- /dev/null +++ b/em2rp/lib/views/equipment_form/brand_model_selector.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; + +class BrandModelSelector extends StatefulWidget { + final TextEditingController brandController; + final TextEditingController modelController; + final ValueChanged? onBrandChanged; + final List filteredModels; + final String? selectedBrand; + final Function(List) onModelsChanged; + + const BrandModelSelector({ + super.key, + required this.brandController, + required this.modelController, + this.onBrandChanged, + required this.filteredModels, + required this.selectedBrand, + required this.onModelsChanged, + }); + + @override + State createState() => _BrandModelSelectorState(); +} + +class _BrandModelSelectorState extends State { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Consumer( + builder: (context, provider, child) { + return Autocomplete( + initialValue: TextEditingValue(text: widget.brandController.text), + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return provider.brands; + } + return provider.brands.where((String brand) { + return brand.toLowerCase().contains( + textEditingValue.text.toLowerCase(), + ); + }); + }, + onSelected: (String selection) async { + widget.brandController.text = selection; + widget.onBrandChanged?.call(selection); + final equipmentProvider = Provider.of(context, listen: false); + final models = await equipmentProvider.loadModelsByBrand(selection); + widget.onModelsChanged(models); + }, + fieldViewBuilder: (context, controller, focusNode, onEditingComplete) { + if (controller.text != widget.brandController.text) { + controller.text = widget.brandController.text; + } + return TextFormField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration( + labelText: 'Marque *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.business), + helperText: 'Champ obligatoire', + ), + onChanged: (value) async { + widget.brandController.text = value; + widget.modelController.clear(); + widget.onBrandChanged?.call(value.isNotEmpty ? value : null); + if (value.isNotEmpty) { + final equipmentProvider = Provider.of(context, listen: false); + final models = await equipmentProvider.loadModelsByBrand(value); + widget.onModelsChanged(models); + } else { + widget.onModelsChanged([]); + } + }, + ); + }, + ); + }, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Autocomplete( + initialValue: TextEditingValue(text: widget.modelController.text), + optionsBuilder: (TextEditingValue textEditingValue) { + if (widget.selectedBrand == null || widget.selectedBrand!.isEmpty) { + return const Iterable.empty(); + } + if (textEditingValue.text.isEmpty) { + return widget.filteredModels; + } + return widget.filteredModels.where((String model) { + return model.toLowerCase().contains( + textEditingValue.text.toLowerCase(), + ); + }); + }, + onSelected: (String selection) { + widget.modelController.text = selection; + }, + fieldViewBuilder: (context, controller, focusNode, onEditingComplete) { + if (controller.text != widget.modelController.text) { + controller.text = widget.modelController.text; + } + return TextFormField( + controller: controller, + focusNode: focusNode, + enabled: widget.selectedBrand != null && widget.selectedBrand!.isNotEmpty, + decoration: InputDecoration( + labelText: 'Modèle *', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.inventory_2), + hintText: widget.selectedBrand == null || widget.selectedBrand!.isEmpty + ? 'Marque requise' + : 'Saisissez le modèle', + helperText: 'Champ obligatoire', + ), + onChanged: (value) { + widget.modelController.text = value; + }, + ); + }, + ), + ), + ], + ); + } +} + diff --git a/em2rp/lib/views/equipment_form/id_generator.dart b/em2rp/lib/views/equipment_form/id_generator.dart new file mode 100644 index 0000000..7fb22dd --- /dev/null +++ b/em2rp/lib/views/equipment_form/id_generator.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +class EquipmentIdGenerator { + static String generate({required String brand, required String model, int? number}) { + final brandTrim = brand.trim().replaceAll(' ', '_'); + final modelTrim = model.trim().replaceAll(' ', '_'); + if (brandTrim.isEmpty && modelTrim.isEmpty) { + return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}'; + } + final brandPrefix = brandTrim.length >= 4 ? brandTrim.substring(0, 4) : brandTrim; + String baseId = modelTrim.isNotEmpty ? '${brandPrefix}_$modelTrim' : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ'); + if (number != null) { + baseId += '_#$number'; + } + return baseId; + } + + static Future ensureUniqueId(String baseId, EquipmentService service) async { + if (await service.isIdUnique(baseId)) { + return baseId; + } + return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } +} + diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart new file mode 100644 index 0000000..bc88fac --- /dev/null +++ b/em2rp/lib/views/equipment_form_page.dart @@ -0,0 +1,691 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; +import 'package:em2rp/services/equipment_service.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:intl/intl.dart'; +import 'package:em2rp/views/equipment_form/brand_model_selector.dart'; +import 'package:em2rp/views/equipment_form/id_generator.dart'; + +class EquipmentFormPage extends StatefulWidget { + final EquipmentModel? equipment; + + const EquipmentFormPage({super.key, this.equipment}); + + @override + State createState() => _EquipmentFormPageState(); +} + +class _EquipmentFormPageState extends State { + final _formKey = GlobalKey(); + final EquipmentService _equipmentService = EquipmentService(); + + // Controllers + final TextEditingController _identifierController = TextEditingController(); + final TextEditingController _brandController = TextEditingController(); + final TextEditingController _modelController = TextEditingController(); + final TextEditingController _purchasePriceController = TextEditingController(); + final TextEditingController _rentalPriceController = TextEditingController(); + final TextEditingController _totalQuantityController = TextEditingController(); + final TextEditingController _criticalThresholdController = TextEditingController(); + final TextEditingController _notesController = TextEditingController(); + final TextEditingController _quantityToAddController = TextEditingController(text: '1'); + + // State variables + EquipmentCategory _selectedCategory = EquipmentCategory.other; + EquipmentStatus _selectedStatus = EquipmentStatus.available; + DateTime? _purchaseDate; + DateTime? _lastMaintenanceDate; + DateTime? _nextMaintenanceDate; + List _selectedParentBoxIds = []; + List _availableBoxes = []; + bool _isLoading = false; + bool _isLoadingBoxes = true; + bool _addMultiple = false; + String? _selectedBrand; + List _filteredModels = []; + + @override + void initState() { + super.initState(); + _loadAvailableBoxes(); + WidgetsBinding.instance.addPostFrameCallback((_) { + final provider = Provider.of(context, listen: false); + provider.loadBrands(); + provider.loadModels(); + }); + if (widget.equipment != null) { + _populateFields(); + } + } + + void _populateFields() { + final equipment = widget.equipment!; + _identifierController.text = equipment.id; + _brandController.text = equipment.brand ?? ''; + _selectedBrand = equipment.brand; + _modelController.text = equipment.model ?? ''; + _selectedCategory = equipment.category; + _selectedStatus = equipment.status; + _purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? ''; + _rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? ''; + _totalQuantityController.text = equipment.totalQuantity?.toString() ?? ''; + _criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? ''; + _purchaseDate = equipment.purchaseDate; + _lastMaintenanceDate = equipment.lastMaintenanceDate; + _nextMaintenanceDate = equipment.nextMaintenanceDate; + _selectedParentBoxIds = List.from(equipment.parentBoxIds); + _notesController.text = equipment.notes ?? ''; + + if (_selectedBrand != null && _selectedBrand!.isNotEmpty) { + _loadFilteredModels(_selectedBrand!); + } + } + + Future _loadAvailableBoxes() async { + try { + final boxes = await _equipmentService.getBoxes(); + setState(() { + _availableBoxes = boxes; + _isLoadingBoxes = false; + }); + } catch (e) { + setState(() { + _isLoadingBoxes = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')), + ); + } + } + } + + Future _loadFilteredModels(String brand) async { + try { + final equipmentProvider = Provider.of(context, listen: false); + final models = await equipmentProvider.loadModelsByBrand(brand); + setState(() { + _filteredModels = models; + }); + } catch (e) { + setState(() { + _filteredModels = []; + }); + } + } + + @override + void dispose() { + _identifierController.dispose(); + _brandController.dispose(); + _modelController.dispose(); + _purchasePriceController.dispose(); + _rentalPriceController.dispose(); + _totalQuantityController.dispose(); + _criticalThresholdController.dispose(); + _notesController.dispose(); + _quantityToAddController.dispose(); + super.dispose(); + } + + bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable; + + @override + Widget build(BuildContext context) { + final localUserProvider = Provider.of(context); + final hasManagePermission = localUserProvider.hasPermission('manage_equipment'); + final isEditing = widget.equipment != null; + + return Scaffold( + appBar: CustomAppBar( + title: isEditing ? 'Modifier l\'équipement' : 'Nouvel équipement', + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Identifiant (généré ou saisi) + TextFormField( + controller: _identifierController, + decoration: InputDecoration( + labelText: 'Identifiant *', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.tag), + hintText: isEditing ? null : 'Laissez vide pour générer automatiquement', + helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}', + ), + enabled: !isEditing, + ), + const SizedBox(height: 16), + + // Case à cocher "Ajouter plusieurs" (uniquement en mode création) + if (!isEditing) ...[ + Row( + children: [ + Expanded( + flex: 2, + child: CheckboxListTile( + title: const Text('Ajouter plusieurs équipements'), + subtitle: const Text('Créer plusieurs équipements numérotés'), + value: _addMultiple, + contentPadding: EdgeInsets.zero, + onChanged: (bool? value) { + setState(() { + _addMultiple = value ?? false; + }); + }, + ), + ), + if (_addMultiple) ...[ + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _quantityToAddController, + decoration: const InputDecoration( + labelText: 'Quantité ou range', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.plus_one), + hintText: '5 ou 6-18', + helperText: 'Ex: 5 ou 6-18', + ), + keyboardType: TextInputType.text, + validator: (value) { + if (_addMultiple) { + if (value == null || value.isEmpty) return 'Requis'; + // Vérifier si c'est un nombre simple ou une range + if (value.contains('-')) { + final parts = value.split('-'); + if (parts.length != 2) return 'Format invalide'; + final start = int.tryParse(parts[0].trim()); + final end = int.tryParse(parts[1].trim()); + if (start == null || end == null) return 'Nombres invalides'; + if (start >= end) return 'Le début doit être < fin'; + if (end - start > 100) return 'Max 100 équipements'; + } else { + final num = int.tryParse(value); + if (num == null || num < 1 || num > 100) return '1-100'; + } + } + return null; + }, + ), + ), + ], + ], + ), + const SizedBox(height: 16), + ], + + // Sélecteur Marque/Modèle + BrandModelSelector( + brandController: _brandController, + modelController: _modelController, + selectedBrand: _selectedBrand, + filteredModels: _filteredModels, + onBrandChanged: (brand) { + setState(() { + _selectedBrand = brand; + }); + if (brand != null && brand.isNotEmpty) { + _loadFilteredModels(brand); + } else { + setState(() { + _filteredModels = []; + }); + } + }, + onModelsChanged: (models) { + setState(() { + _filteredModels = models; + }); + }, + ), + const SizedBox(height: 16), + + // Catégorie et Statut + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _selectedCategory, + decoration: const InputDecoration( + labelText: 'Catégorie *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: EquipmentCategory.values.map((category) { + return DropdownMenuItem( + value: category, + child: Text(_getCategoryLabel(category)), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedCategory = value; + }); + } + }, + ), + ), + // Afficher le statut uniquement si ce n'est pas un consommable ou câble + if (!_isConsumable) ...[ + const SizedBox(width: 16), + Expanded( + child: DropdownButtonFormField( + value: _selectedStatus, + decoration: const InputDecoration( + labelText: 'Statut *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info), + ), + items: EquipmentStatus.values.map((status) { + return DropdownMenuItem( + value: status, + child: Text(_getStatusLabel(status)), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedStatus = value; + }); + } + }, + ), + ), + ], + ], + ), + const SizedBox(height: 16), + + // Prix + if (hasManagePermission) ...[ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _purchasePriceController, + decoration: const InputDecoration( + labelText: 'Prix d\'achat (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.euro), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _rentalPriceController, + decoration: const InputDecoration( + labelText: 'Prix de location (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.attach_money), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))], + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Quantités pour consommables + if (_isConsumable) ...[ + const Divider(), + const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _totalQuantityController, + decoration: const InputDecoration( + labelText: 'Quantité totale', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.inventory), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _criticalThresholdController, + decoration: const InputDecoration( + labelText: 'Seuil critique', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.warning), + ), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Boîtes parentes + const Divider(), + const Text('Boîtes parentes', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _isLoadingBoxes + ? const Center(child: CircularProgressIndicator()) + : _buildParentBoxesSelector(), + const SizedBox(height: 16), + + // Dates + const Divider(), + const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + _buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')), + const SizedBox(height: 16), + _buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')), + const SizedBox(height: 16), + _buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')), + const SizedBox(height: 16), + + // Notes + const Divider(), + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.notes), + ), + maxLines: 3, + ), + const SizedBox(height: 24), + + // Boutons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: _saveEquipment, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16), + ), + child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildParentBoxesSelector() { + if (_availableBoxes.isEmpty) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text('Aucune boîte disponible'), + ), + ); + } + + return Card( + child: Column( + children: _availableBoxes.map((box) { + final isSelected = _selectedParentBoxIds.contains(box.id); + return CheckboxListTile( + title: Text(box.name), + subtitle: box.model != null ? Text('Modèle: {box.model}') : null, + value: isSelected, + onChanged: (bool? value) { + setState(() { + if (value == true) { + _selectedParentBoxIds.add(box.id); + } else { + _selectedParentBoxIds.remove(box.id); + } + }); + }, + ); + }).toList(), + ), + ); + } + + Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) { + return InkWell( + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + prefixIcon: Icon(icon), + suffixIcon: value != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + if (label.contains('achat')) { + _purchaseDate = null; + } else if (label.contains('Dernière')) { + _lastMaintenanceDate = null; + } else if (label.contains('Prochaine')) { + _nextMaintenanceDate = null; + } + }); + }, + ) + : null, + ), + child: Text( + value != null ? DateFormat('dd/MM/yyyy').format(value) : 'Sélectionner une date', + style: TextStyle(color: value != null ? Colors.black : Colors.grey), + ), + ), + ); + } + + Future _selectDate(BuildContext context, String field) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2000), + lastDate: DateTime(2100), + ); + + if (picked != null) { + setState(() { + switch (field) { + case 'purchase': + _purchaseDate = picked; + break; + case 'lastMaintenance': + _lastMaintenanceDate = picked; + break; + case 'nextMaintenance': + _nextMaintenanceDate = picked; + break; + } + }); + } + } + + Future _saveEquipment() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final equipmentProvider = Provider.of(context, listen: false); + final isEditing = widget.equipment != null; + + int? availableQuantity; + if (_isConsumable && _totalQuantityController.text.isNotEmpty) { + final totalQuantity = int.parse(_totalQuantityController.text); + if (isEditing && widget.equipment!.availableQuantity != null) { + availableQuantity = widget.equipment!.availableQuantity; + } else { + availableQuantity = totalQuantity; + } + } + + // Validation marque/modèle obligatoires + String brand = _brandController.text.trim(); + String model = _modelController.text.trim(); + + if (brand.isEmpty || model.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('La marque et le modèle sont obligatoires')), + ); + return; + } + + // Génération d'identifiant si vide + List ids = []; + List numbers = []; + + if (!isEditing && _identifierController.text.isEmpty) { + // Gérer la range ou nombre simple + final quantityText = _quantityToAddController.text.trim(); + if (_addMultiple && quantityText.contains('-')) { + // Range: ex "6-18" + final parts = quantityText.split('-'); + final start = int.parse(parts[0].trim()); + final end = int.parse(parts[1].trim()); + for (int i = start; i <= end; i++) { + numbers.add(i); + } + } else if (_addMultiple) { + // Nombre simple + final nbToAdd = int.tryParse(quantityText) ?? 1; + for (int i = 1; i <= nbToAdd; i++) { + numbers.add(i); + } + } + + // Générer les IDs + if (numbers.isEmpty) { + String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: null); + String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + ids.add(uniqueId); + } else { + for (final num in numbers) { + String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: num); + String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + ids.add(uniqueId); + } + } + } else { + ids.add(_identifierController.text.trim()); + } + + // Création des équipements + for (final id in ids) { + final now = DateTime.now(); + final equipment = EquipmentModel( + id: id, + name: id, // Utilisation de l'identifiant comme nom + brand: brand, + model: model, + category: _selectedCategory, + status: _selectedStatus, + purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null, + rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null, + totalQuantity: _isConsumable ? int.tryParse(_totalQuantityController.text) : null, + criticalThreshold: _isConsumable ? int.tryParse(_criticalThresholdController.text) : null, + purchaseDate: _purchaseDate, + lastMaintenanceDate: _lastMaintenanceDate, + nextMaintenanceDate: _nextMaintenanceDate, + parentBoxIds: _selectedParentBoxIds, + notes: _notesController.text, + createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now, + updatedAt: now, + availableQuantity: availableQuantity, + ); + if (isEditing) { + await equipmentProvider.updateEquipment( + equipment.id, + equipment.toMap(), + ); + } else { + await equipmentProvider.addEquipment(equipment); + } + } + + if (mounted) { + Navigator.pop(context, true); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')), + ); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + // Correction des enums dans _getCategoryLabel + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effet'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + default: + return 'Autre'; + } + } + + // Correction des enums dans _getStatusLabel + String _getStatusLabel(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return 'Disponible'; + case EquipmentStatus.inUse: + return 'En prestation'; + case EquipmentStatus.rented: + return 'Loué'; + case EquipmentStatus.lost: + return 'Perdu'; + case EquipmentStatus.outOfService: + return 'HS'; + case EquipmentStatus.maintenance: + return 'Maintenance'; + default: + return 'Autre'; + } + } +} diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart new file mode 100644 index 0000000..430c4c4 --- /dev/null +++ b/em2rp/lib/views/equipment_management_page.dart @@ -0,0 +1,1474 @@ +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/permission_gate.dart'; +import 'package:em2rp/views/widgets/nav/main_drawer.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/views/equipment_form_page.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +class EquipmentManagementPage extends StatefulWidget { + const EquipmentManagementPage({super.key}); + + @override + State createState() => _EquipmentManagementPageState(); +} + +enum QRLabelFormat { small, medium, large } + +class _EquipmentManagementPageState extends State { + final TextEditingController _searchController = TextEditingController(); + EquipmentCategory? _selectedCategory; + bool _isSelectionMode = false; + final Set _selectedEquipmentIds = {}; + + void _toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedEquipmentIds.clear(); + } + }); + } + + void _toggleEquipmentSelection(String id) { + setState(() { + if (_selectedEquipmentIds.contains(id)) { + _selectedEquipmentIds.remove(id); + } else { + _selectedEquipmentIds.add(id); + } + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 800; + + return PermissionGate( + requiredPermissions: const ['view_equipment'], + fallback: Scaffold( + appBar: const CustomAppBar(title: 'Accès refusé'), + drawer: const MainDrawer(currentPage: '/equipment_management'), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion du matériel.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + child: Scaffold( + appBar: _isSelectionMode + ? AppBar( + backgroundColor: AppColors.rouge, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: _toggleSelectionMode, + ), + title: Text( + '${_selectedEquipmentIds.length} sélectionné(s)', + style: const TextStyle(color: Colors.white), + ), + actions: [ + if (_selectedEquipmentIds.isNotEmpty) ...[ + IconButton( + icon: const Icon(Icons.qr_code, color: Colors.white), + tooltip: 'Générer QR Codes', + onPressed: _generateQRCodesForSelected, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + tooltip: 'Supprimer', + onPressed: _deleteSelectedEquipment, + ), + ], + ], + ) + : CustomAppBar( + title: 'Gestion du matériel', + actions: [ + IconButton( + icon: const Icon(Icons.checklist), + tooltip: 'Mode sélection', + onPressed: _toggleSelectionMode, + ), + ], + ), + drawer: const MainDrawer(currentPage: '/equipment_management'), + body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), + floatingActionButton: _isSelectionMode ? null : _buildFAB(), + ), + ); + } + + Widget _buildFAB() { + return PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: FloatingActionButton.extended( + onPressed: _createNewEquipment, + backgroundColor: AppColors.rouge, + icon: const Icon(Icons.add), + label: const Text('Ajouter un équipement'), + ), + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + // Barre de recherche + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, modèle ou ID...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), + ), + // Menu horizontal de filtres par catégorie + SizedBox( + height: 60, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ChoiceChip( + label: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.all_inclusive, size: 16, color: Colors.white), + SizedBox(width: 8), + Text('Tout'), + ], + ), + selected: _selectedCategory == null, + onSelected: (selected) { + if (selected) { + setState(() => _selectedCategory = null); + context.read().setSelectedCategory(null); + } + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _selectedCategory == null ? Colors.white : AppColors.rouge, + fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + ), + ), + ), + ..._buildCategoryChips(), + ], + ), + ), + const Divider(), + // Liste des équipements + Expanded(child: _buildEquipmentList()), + ], + ); + } + + Widget _buildDesktopLayout() { + return Row( + children: [ + // Sidebar gauche avec filtres + Container( + width: 280, + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border( + right: BorderSide(color: Colors.grey, width: 1), + ), + ), + child: Column( + children: [ + // En-tête + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.rouge.withOpacity(0.1), + ), + child: Row( + children: [ + Icon(Icons.inventory, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Filtres', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.rouge, + ), + ), + ), + ], + ), + ), + // Barre de recherche + Padding( + padding: const EdgeInsets.all(16.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher...', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), + ), + const Divider(), + // Filtres par catégorie + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Catégories', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + ), + ), + Expanded( + child: ListView( + children: [ + ListTile( + leading: Icon( + Icons.all_inclusive, + color: _selectedCategory == null ? AppColors.rouge : Colors.grey[600], + ), + title: Text( + 'Tout', + style: TextStyle( + color: _selectedCategory == null ? AppColors.rouge : Colors.black87, + fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + ), + ), + selected: _selectedCategory == null, + selectedTileColor: AppColors.rouge.withOpacity(0.1), + onTap: () { + setState(() => _selectedCategory = null); + context.read().setSelectedCategory(null); + }, + ), + ..._buildCategoryListTiles(), + ], + ), + ), + ], + ), + ), + // Contenu principal + Expanded(child: _buildEquipmentList()), + ], + ); + } + + List _buildCategoryChips() { + final categories = [ + (EquipmentCategory.lighting, Icons.light_mode, 'Lumière'), + (EquipmentCategory.sound, Icons.volume_up, 'Son'), + (EquipmentCategory.video, Icons.videocam, 'Vidéo'), + (EquipmentCategory.effect, Icons.auto_awesome, 'Effets'), + (EquipmentCategory.structure, Icons.construction, 'Structure'), + (EquipmentCategory.consumable, Icons.inventory_2, 'Consommable'), + (EquipmentCategory.cable, Icons.cable, 'Câble'), + (EquipmentCategory.other, Icons.more_horiz, 'Autre'), + ]; + + return categories.map((cat) { + final isSelected = _selectedCategory == cat.$1; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4.0), + child: ChoiceChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + cat.$2, + size: 16, + color: isSelected ? Colors.white : AppColors.rouge, + ), + const SizedBox(width: 8), + Text(cat.$3), + ], + ), + selected: isSelected, + onSelected: (selected) { + if (selected) { + setState(() => _selectedCategory = cat.$1); + context.read().setSelectedCategory(cat.$1); + } + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: isSelected ? Colors.white : AppColors.rouge, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + ); + }).toList(); + } + + List _buildCategoryListTiles() { + final categories = [ + (EquipmentCategory.lighting, Icons.light_mode, 'Lumière'), + (EquipmentCategory.sound, Icons.volume_up, 'Son'), + (EquipmentCategory.video, Icons.videocam, 'Vidéo'), + (EquipmentCategory.effect, Icons.auto_awesome, 'Effets'), + (EquipmentCategory.structure, Icons.construction, 'Structure'), + (EquipmentCategory.consumable, Icons.inventory_2, 'Consommable'), + (EquipmentCategory.cable, Icons.cable, 'Câble'), + (EquipmentCategory.other, Icons.more_horiz, 'Autre'), + ]; + + return categories.map((cat) { + final isSelected = _selectedCategory == cat.$1; + return ListTile( + leading: Icon( + cat.$2, + color: isSelected ? AppColors.rouge : Colors.grey[600], + ), + title: Text( + cat.$3, + style: TextStyle( + color: isSelected ? AppColors.rouge : Colors.black87, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ), + selected: isSelected, + selectedTileColor: AppColors.rouge.withOpacity(0.1), + onTap: () { + setState(() => _selectedCategory = cat.$1); + context.read().setSelectedCategory(cat.$1); + }, + ); + }).toList(); + } + + Widget _buildEquipmentList() { + return Consumer( + builder: (context, provider, child) { + return StreamBuilder>( + stream: provider.equipmentStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Erreur: ${snapshot.error}'), + ); + } + + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[400]), + const SizedBox(height: 16), + Text( + 'Aucun équipement trouvé', + style: TextStyle(fontSize: 18, color: Colors.grey[600]), + ), + const SizedBox(height: 8), + Text( + 'Ajoutez votre premier équipement', + style: TextStyle(fontSize: 14, color: Colors.grey[500]), + ), + ], + ), + ); + } + + // Tri par nom + final equipment = snapshot.data!; + equipment.sort((a, b) => a.name.compareTo(b.name)); + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: equipment.length, + itemBuilder: (context, index) { + return _buildEquipmentCard(equipment[index]); + }, + ); + }, + ); + }, + ); + } + + Widget _buildEquipmentCard(EquipmentModel equipment) { + final isSelected = _selectedEquipmentIds.contains(equipment.id); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + color: _isSelectionMode && isSelected ? AppColors.rouge.withOpacity(0.1) : null, + child: ListTile( + leading: _isSelectionMode + ? Checkbox( + value: isSelected, + onChanged: (value) => _toggleEquipmentSelection(equipment.id), + activeColor: AppColors.rouge, + ) + : CircleAvatar( + backgroundColor: _getStatusColor(equipment.status).withOpacity(0.2), + child: Icon( + _getCategoryIcon(equipment.category), + color: _getStatusColor(equipment.status), + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + // Afficher le statut uniquement si ce n'est pas un consommable ou câble + if (equipment.category != EquipmentCategory.consumable && + equipment.category != EquipmentCategory.cable) + _buildStatusBadge(equipment.status), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim().isNotEmpty + ? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim() + : 'Marque/Modèle non défini', + style: TextStyle(color: Colors.grey[600], fontSize: 14), + ), + // Afficher la quantité disponible pour les consommables/câbles + if (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable) ...[ + const SizedBox(height: 4), + _buildQuantityDisplay(equipment), + ], + ], + ), + trailing: _isSelectionMode + ? null + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton Restock (uniquement pour consommables/câbles avec permission) + if (equipment.category == EquipmentCategory.consumable || + equipment.category == EquipmentCategory.cable) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.add_shopping_cart, color: AppColors.rouge), + tooltip: 'Restock', + onPressed: () => _showRestockDialog(equipment), + ), + ), + // Bouton QR Code + IconButton( + icon: const Icon(Icons.qr_code, color: AppColors.rouge), + tooltip: 'QR Code', + onPressed: () => _showSingleQRCode(equipment), + ), + // Bouton Modifier (permission required) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.edit, color: AppColors.rouge), + tooltip: 'Modifier', + onPressed: () => _editEquipment(equipment), + ), + ), + // Bouton Supprimer (permission required) + PermissionGate( + requiredPermissions: const ['manage_equipment'], + child: IconButton( + icon: const Icon(Icons.delete, color: Colors.red), + tooltip: 'Supprimer', + onPressed: () => _deleteEquipment(equipment), + ), + ), + ], + ), + onTap: _isSelectionMode + ? () => _toggleEquipmentSelection(equipment.id) + : () => _viewEquipmentDetails(equipment), + ), + ); + } + + Widget _buildQuantityDisplay(EquipmentModel equipment) { + final availableQty = equipment.availableQuantity ?? 0; + final totalQty = equipment.totalQuantity ?? 0; + final criticalThreshold = equipment.criticalThreshold ?? 0; + final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isCritical ? Colors.red.withOpacity(0.15) : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isCritical ? Colors.red : Colors.grey.shade400, + width: isCritical ? 2 : 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isCritical ? Icons.warning : Icons.inventory, + size: 16, + color: isCritical ? Colors.red : Colors.grey[700], + ), + const SizedBox(width: 6), + Text( + 'Disponible: $availableQty / $totalQty', + style: TextStyle( + fontSize: 13, + fontWeight: isCritical ? FontWeight.bold : FontWeight.normal, + color: isCritical ? Colors.red : Colors.grey[700], + ), + ), + if (isCritical) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(10), + ), + child: const Text( + 'CRITIQUE', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ), + ], + ], + ), + ); + } + + Widget _buildStatusBadge(EquipmentStatus status) { + final statusInfo = _getStatusInfo(status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusInfo.$2.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statusInfo.$2), + ), + child: Text( + statusInfo.$1, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: statusInfo.$2, + ), + ), + ); + } + + (String, Color) _getStatusInfo(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return ('Disponible', Colors.green); + case EquipmentStatus.inUse: + return ('En prestation', Colors.blue); + case EquipmentStatus.rented: + return ('Loué', Colors.orange); + case EquipmentStatus.lost: + return ('Perdu', Colors.red); + case EquipmentStatus.outOfService: + return ('HS', Colors.red[900]!); + case EquipmentStatus.maintenance: + return ('Maintenance', Colors.amber); + } + } + + Color _getStatusColor(EquipmentStatus status) { + return _getStatusInfo(status).$2; + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return Icons.light_mode; + case EquipmentCategory.sound: + return Icons.volume_up; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.consumable: + return Icons.inventory_2; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.other: + return Icons.more_horiz; + } + } + + // Actions + void _createNewEquipment() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const EquipmentFormPage(), + ), + ); + } + + void _editEquipment(EquipmentModel equipment) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentFormPage(equipment: equipment), + ), + ); + } + + void _deleteEquipment(EquipmentModel equipment) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await context.read().deleteEquipment(equipment.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Équipement supprimé avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + void _deleteSelectedEquipment() async { + if (_selectedEquipmentIds.isEmpty) return; + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Voulez-vous vraiment supprimer ${_selectedEquipmentIds.length} équipement(s) ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + final provider = context.read(); + for (final id in _selectedEquipmentIds) { + await provider.deleteEquipment(id); + } + setState(() { + _selectedEquipmentIds.clear(); + _isSelectionMode = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('${_selectedEquipmentIds.length} équipement(s) supprimé(s) avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + void _generateQRCodesForSelected() async { + if (_selectedEquipmentIds.isEmpty) return; + + // Récupérer les équipements sélectionnés + final provider = context.read(); + final List selectedEquipment = []; + + // On doit récupérer les équipements depuis le stream + await for (final equipmentList in provider.equipmentStream.take(1)) { + for (final equipment in equipmentList) { + if (_selectedEquipmentIds.contains(equipment.id)) { + selectedEquipment.add(equipment); + } + } + break; + } + + if (selectedEquipment.isEmpty) return; + + if (selectedEquipment.length == 1) { + _showSingleQRCode(selectedEquipment.first); + } else { + _showMultipleQRCodesDialog(selectedEquipment); + } + } + + void _showSingleQRCode(EquipmentModel equipment) { + showDialog( + context: context, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.qr_code, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'QR Code - ${equipment.id}', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + // QR Code + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: QrImageView( + data: equipment.id, + version: QrVersions.auto, + size: 250, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + // Informations + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + equipment.id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + style: TextStyle(color: Colors.grey[700]), + ), + ], + ), + ), + const SizedBox(height: 24), + // Bouton télécharger + ElevatedButton.icon( + onPressed: () => _downloadSingleQRCodeImage(equipment), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + minimumSize: const Size(double.infinity, 48), + ), + icon: const Icon(Icons.download, color: Colors.white), + label: const Text( + 'Télécharger l\'image', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + ), + ); + } + + void _showMultipleQRCodesDialog(List equipmentList) { + showDialog( + context: context, + builder: (context) => Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Générer ${equipmentList.length} QR Codes', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + const Text( + 'Choisissez un format d\'étiquette :', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + // Liste des équipements + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: equipmentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = equipmentList[index]; + return ListTile( + dense: true, + leading: const Icon(Icons.qr_code, size: 20), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + style: const TextStyle(fontSize: 12), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + // Boutons de format + _buildFormatButton( + context, + icon: Icons.qr_code, + title: 'Petits QR Codes', + subtitle: 'QR codes compacts (2x2 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(equipmentList, QRLabelFormat.small); + }, + ), + const SizedBox(height: 12), + _buildFormatButton( + context, + icon: Icons.qr_code_2, + title: 'QR Moyens', + subtitle: 'QR codes taille moyenne (4x4 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(equipmentList, QRLabelFormat.medium); + }, + ), + const SizedBox(height: 12), + _buildFormatButton( + context, + icon: Icons.label, + title: 'Grandes étiquettes', + subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(equipmentList, QRLabelFormat.large); + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildFormatButton( + BuildContext context, { + required IconData icon, + required String title, + required String subtitle, + required VoidCallback onPressed, + }) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.rouge, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + ), + ], + ), + ), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ); + } + + Future _downloadSingleQRCodeImage(EquipmentModel equipment) async { + try { + // Générer l'image QR code en haute résolution + final qrImage = await _generateQRImage(equipment.id, size: 1024); + + // Utiliser la bibliothèque printing pour sauvegarder l'image + await Printing.sharePdf( + bytes: qrImage, + filename: 'QRCode_${equipment.id}.png', + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Image QR Code téléchargée avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors du téléchargement de l\'image: $e')), + ); + } + } + } + + Future _generatePDF(List equipmentList, QRLabelFormat format) async { + try { + final pdf = pw.Document(); + + switch (format) { + case QRLabelFormat.small: + await _generateSmallQRCodesPDF(pdf, equipmentList); + break; + case QRLabelFormat.medium: + await _generateMediumQRCodesPDF(pdf, equipmentList); + break; + case QRLabelFormat.large: + await _generateLargeQRCodesPDF(pdf, equipmentList); + break; + } + + await Printing.layoutPdf( + onLayout: (format) async => pdf.save(), + name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('PDF généré avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), + ); + } + } + } + + Future _generateSmallQRCodesPDF(pw.Document pdf, List equipmentList) async { + // Petits QR codes : 2x2 cm, 9 par page (3x3) + const qrSize = 56.69; // 2cm en points + const itemsPerRow = 4; + const itemsPerPage = 20; + + for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { + final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); + final qrImages = await Future.wait( + pageEquipment.map((eq) => _generateQRImage(eq.id)), + ); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageEquipment.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize, + child: pw.Image(pw.MemoryImage(qrImages[index])), + ); + }), + ); + }, + ), + ); + } + } + + Future _generateMediumQRCodesPDF(pw.Document pdf, List equipmentList) async { + // QR moyens : 4x4 cm, 6 par page (2x3) + const qrSize = 113.39; // 4cm en points + const itemsPerRow = 2; + const itemsPerPage = 6; + + for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { + final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); + final qrImages = await Future.wait( + pageEquipment.map((eq) => _generateQRImage(eq.id)), + ); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 20, + runSpacing: 20, + children: List.generate(pageEquipment.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize, + child: pw.Image(pw.MemoryImage(qrImages[index])), + ); + }), + ); + }, + ), + ); + } + } + + Future _generateLargeQRCodesPDF(pw.Document pdf, List equipmentList) async { + // Grandes étiquettes : 10x5 cm, 4 par page + const labelWidth = 283.46; // 10cm en points + const labelHeight = 141.73; // 5cm en points + const qrSize = 113.39; // 4cm en points + const itemsPerPage = 4; + + for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { + final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); + final qrImages = await Future.wait( + pageEquipment.map((eq) => _generateQRImage(eq.id)), + ); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageEquipment.length, (index) { + final equipment = pageEquipment[index]; + return pw.Container( + width: labelWidth, + height: labelHeight, + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey300), + borderRadius: const pw.BorderRadius.all(pw.Radius.circular(5)), + ), + child: pw.Row( + children: [ + pw.Image(pw.MemoryImage(qrImages[index]), width: qrSize, height: qrSize), + pw.SizedBox(width: 15), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.Text( + equipment.id, + style: pw.TextStyle( + fontSize: 14, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 8), + pw.Text( + 'Marque: ${equipment.brand ?? 'N/A'}', + style: const pw.TextStyle(fontSize: 10), + ), + pw.SizedBox(height: 4), + pw.Text( + 'Modèle: ${equipment.model ?? 'N/A'}', + style: const pw.TextStyle(fontSize: 10), + ), + ], + ), + ), + ], + ), + ); + }), + ); + }, + ), + ); + } + } + + Future _generateQRImage(String data, {double size = 512}) async { + final qrValidationResult = QrValidator.validate( + data: data, + version: QrVersions.auto, + errorCorrectionLevel: QrErrorCorrectLevel.L, + ); + + if (qrValidationResult.status != QrValidationStatus.valid) { + throw Exception('QR code validation failed'); + } + + final qrCode = qrValidationResult.qrCode!; + final painter = QrPainter.withQr( + qr: qrCode, + color: const Color(0xFF000000), + emptyColor: const Color(0xFFFFFFFF), + gapless: true, + ); + + final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); + return picData!.buffer.asUint8List(); + } + + void _showRestockDialog(EquipmentModel equipment) { + final TextEditingController quantityController = TextEditingController(); + bool addToTotal = false; + + showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setState) { + return AlertDialog( + title: Row( + children: [ + const Icon(Icons.add_shopping_cart, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text('Restock - ${equipment.name}'), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Quantités actuelles', + style: TextStyle( + fontWeight: FontWeight.bold, + color: Colors.grey[700], + ), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Disponible:'), + Text( + '${equipment.availableQuantity ?? 0}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Total:'), + Text( + '${equipment.totalQuantity ?? 0}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 20), + TextField( + controller: quantityController, + decoration: const InputDecoration( + labelText: 'Quantité à ajouter/retirer', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.inventory), + hintText: 'Ex: 10 ou -5', + helperText: 'Nombre positif pour ajouter, négatif pour retirer', + ), + keyboardType: const TextInputType.numberWithOptions(signed: true), + autofocus: true, + ), + const SizedBox(height: 16), + CheckboxListTile( + title: const Text('Ajouter à la quantité totale'), + subtitle: const Text('Mettre à jour aussi la quantité totale'), + value: addToTotal, + contentPadding: EdgeInsets.zero, + onChanged: (bool? value) { + setState(() { + addToTotal = value ?? false; + }); + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () async { + final quantityText = quantityController.text.trim(); + if (quantityText.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Veuillez entrer une quantité')), + ); + return; + } + + final quantity = int.tryParse(quantityText); + if (quantity == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Quantité invalide')), + ); + return; + } + + Navigator.pop(context); + + try { + final currentAvailable = equipment.availableQuantity ?? 0; + final currentTotal = equipment.totalQuantity ?? 0; + + final newAvailable = currentAvailable + quantity; + final newTotal = addToTotal ? currentTotal + quantity : currentTotal; + + if (newAvailable < 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('La quantité disponible ne peut pas être négative')), + ); + return; + } + + if (newTotal < 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('La quantité totale ne peut pas être négative')), + ); + return; + } + + final updatedData = { + 'availableQuantity': newAvailable, + 'totalQuantity': newTotal, + 'updatedAt': DateTime.now().toIso8601String(), + }; + + await context.read().updateEquipment( + equipment.id, + updatedData, + ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + quantity > 0 + ? 'Ajout de $quantity unité(s) effectué' + : 'Retrait de ${quantity.abs()} unité(s) effectué', + ), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + ), + child: const Text('Valider', style: TextStyle(color: Colors.white)), + ), + ], + ); + }, + ), + ); + } + + void _showQRCode(EquipmentModel equipment) { + // TODO: Afficher le QR code + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('QR Code pour ${equipment.name} - À implémenter')), + ); + } + + void _viewEquipmentDetails(EquipmentModel equipment) { + // TODO: Naviguer vers la page de détails + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Détails de ${equipment.name} - À implémenter')), + ); + } +} diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 8475ae7..a7212b1 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -4,6 +4,7 @@ import 'package:em2rp/views/calendar_page.dart'; import 'package:em2rp/views/my_account_page.dart'; import 'package:em2rp/views/user_management_page.dart'; import 'package:em2rp/views/data_management_page.dart'; +import 'package:em2rp/views/equipment_management_page.dart'; import 'package:flutter/material.dart'; import 'package:em2rp/views/widgets/image/profile_picture.dart'; import 'package:provider/provider.dart'; @@ -151,6 +152,24 @@ class MainDrawer extends StatelessWidget { }, ), ), + PermissionGate( + requiredPermissions: const ['view_equipment'], + child: ListTile( + leading: const Icon(Icons.inventory), + title: const Text('Gestion du Matériel'), + selected: currentPage == '/equipment_management', + selectedColor: AppColors.rouge, + onTap: () { + Navigator.pop(context); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + const EquipmentManagementPage()), + ); + }, + ), + ), ], ), ), diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index 838e29d..c44cfad 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -33,6 +33,7 @@ dependencies: path_provider: ^2.1.2 pdf: ^3.10.7 printing: ^5.11.1 + qr_flutter: ^4.1.0 flutter_local_notifications: ^19.2.1 timezone: ^0.10.1 flutter_secure_storage: ^9.0.0