Add equipment management features (and qr generation support)

This commit is contained in:
ElPoyo
2025-10-21 16:32:18 +02:00
parent ef638d8c8c
commit ae3a1b7227
18 changed files with 4489 additions and 7 deletions

View File

@@ -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<EventProvider>(
create: (context) => EventProvider(),
),
// Providers pour la gestion du matériel
ChangeNotifierProvider<EquipmentProvider>(
create: (context) => EquipmentProvider(),
),
ChangeNotifierProvider<MaintenanceProvider>(
create: (context) => MaintenanceProvider(),
),
ChangeNotifierProvider<AlertProvider>(
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()),
},
);
}

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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<String> 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<String> 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<String, dynamic> map, String id) {
// Gestion des listes
final List<dynamic> parentBoxIdsRaw = map['parentBoxIds'] ?? [];
final List<String> parentBoxIds = parentBoxIdsRaw.map((e) => e.toString()).toList();
final List<dynamic> maintenanceIdsRaw = map['maintenanceIds'] ?? [];
final List<String> 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<String, dynamic> 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<String>? parentBoxIds,
DateTime? purchaseDate,
DateTime? lastMaintenanceDate,
DateTime? nextMaintenanceDate,
List<String>? 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)));
}
}

View File

@@ -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<String, dynamic> map) {
return EventEquipment(
equipmentId: map['equipmentId'] ?? '',
quantity: map['quantity'] ?? 1,
isPrepared: map['isPrepared'] ?? false,
isReturned: map['isReturned'] ?? false,
returnedQuantity: map['returnedQuantity'],
);
}
Map<String, dynamic> 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<Map<String, dynamic>> options;
final EventStatus status;
// Nouveaux champs pour la gestion du matériel
final List<EventEquipment> 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<String, dynamic> 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<EventEquipment> assignedEquipment = [];
if (assignedEquipmentRaw is List) {
for (var e in assignedEquipmentRaw) {
try {
if (e is Map) {
assignedEquipment.add(EventEquipment.fromMap(Map<String, dynamic>.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,
};
}
}

View File

@@ -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<String> 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<String, dynamic> map, String id) {
// Gestion de la liste des équipements
final List<dynamic> equipmentIdsRaw = map['equipmentIds'] ?? [];
final List<String> 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<String, dynamic> 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<String>? 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());
}
}

View File

@@ -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<AlertModel> _alerts = [];
// Getters
List<AlertModel> get alerts => _alerts;
/// Nombre d'alertes non lues
int get unreadCount => _alerts.where((alert) => !alert.isRead).length;
/// Alertes non lues uniquement
List<AlertModel> get unreadAlerts => _alerts.where((alert) => !alert.isRead).toList();
/// Alertes de stock critique
List<AlertModel> get lowStockAlerts => _alerts.where((alert) => alert.type == AlertType.lowStock).toList();
/// Alertes de maintenance
List<AlertModel> get maintenanceAlerts => _alerts.where((alert) => alert.type == AlertType.maintenanceDue).toList();
/// Alertes de conflit
List<AlertModel> get conflictAlerts => _alerts.where((alert) => alert.type == AlertType.conflict).toList();
/// Stream des alertes
Stream<List<AlertModel>> 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<void> 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<void> 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<void> 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<void> 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<void> 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<List<AlertModel>> 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();
});
}
}

View File

@@ -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<EquipmentModel> _equipment = [];
List<String> _models = [];
List<String> _brands = [];
EquipmentCategory? _selectedCategory;
EquipmentStatus? _selectedStatus;
String? _selectedModel;
String _searchQuery = '';
// Getters
List<EquipmentModel> get equipment => _equipment;
List<String> get models => _models;
List<String> 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<List<EquipmentModel>> get equipmentStream {
return _service.getEquipment(
category: _selectedCategory,
status: _selectedStatus,
model: _selectedModel,
searchQuery: _searchQuery,
);
}
/// Charger tous les modèles uniques
Future<void> loadModels() async {
try {
_models = await _service.getAllModels();
notifyListeners();
} catch (e) {
print('Error loading models: $e');
rethrow;
}
}
/// Charger toutes les marques uniques
Future<void> 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<List<String>> loadModelsByBrand(String brand) async {
try {
return await _service.getModelsByBrand(brand);
} catch (e) {
print('Error loading models by brand: $e');
rethrow;
}
}
/// Ajouter un équipement
Future<void> 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<void> updateEquipment(String id, Map<String, dynamic> 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<void> 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<EquipmentModel?> getEquipmentById(String id) async {
try {
return await _service.getEquipmentById(id);
} catch (e) {
print('Error getting equipment: $e');
rethrow;
}
}
/// Trouver des alternatives disponibles
Future<List<EquipmentModel>> 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<List<String>> 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<void> 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<void> 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<bool> 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();
}
}

View File

@@ -30,9 +30,7 @@ class EventProvider with ChangeNotifier {
for (var doc in eventsSnapshot.docs) {
try {
final data = doc.data() as Map<String, dynamic>;
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;

View File

@@ -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<MaintenanceModel> _maintenances = [];
// Getters
List<MaintenanceModel> get maintenances => _maintenances;
/// Stream des maintenances pour un équipement spécifique
Stream<List<MaintenanceModel>> getMaintenancesStream(String equipmentId) {
return _service.getMaintenances(equipmentId);
}
/// Stream de toutes les maintenances
Stream<List<MaintenanceModel>> get allMaintenancesStream {
return _service.getAllMaintenances();
}
/// Créer une nouvelle maintenance
Future<void> createMaintenance(MaintenanceModel maintenance) async {
try {
await _service.createMaintenance(maintenance);
notifyListeners();
} catch (e) {
print('Error creating maintenance: $e');
rethrow;
}
}
/// Mettre à jour une maintenance
Future<void> updateMaintenance(String id, Map<String, dynamic> data) async {
try {
await _service.updateMaintenance(id, data);
notifyListeners();
} catch (e) {
print('Error updating maintenance: $e');
rethrow;
}
}
/// Supprimer une maintenance
Future<void> 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<MaintenanceModel?> 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<void> 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<void> checkUpcomingMaintenances() async {
try {
await _service.checkUpcomingMaintenances();
} catch (e) {
print('Error checking upcoming maintenances: $e');
rethrow;
}
}
/// Récupérer les maintenances en retard
List<MaintenanceModel> get overdueMaintances {
return _maintenances.where((m) => m.isOverdue).toList();
}
/// Récupérer les maintenances complétées
List<MaintenanceModel> get completedMaintenances {
return _maintenances.where((m) => m.isCompleted).toList();
}
/// Récupérer les maintenances à venir
List<MaintenanceModel> get upcomingMaintenances {
return _maintenances.where((m) => !m.isCompleted && !m.isOverdue).toList();
}
}

View File

@@ -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<void> 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<void> updateEquipment(String id, Map<String, dynamic> 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<void> 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<EquipmentModel?> getEquipmentById(String id) async {
try {
final doc = await _equipmentCollection.doc(id).get();
if (doc.exists) {
return EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting equipment: $e');
rethrow;
}
}
/// Récupérer les équipements avec filtres
Stream<List<EquipmentModel>> 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<EquipmentModel> equipmentList = snapshot.docs
.map((doc) => EquipmentModel.fromMap(doc.data() as Map<String, dynamic>, 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<List<String>> checkAvailability(
String equipmentId,
DateTime startDate,
DateTime endDate,
) async {
try {
final conflicts = <String>[];
// 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<String, dynamic>;
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<List<EquipmentModel>> 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 = <EquipmentModel>[];
for (var doc in equipmentQuery.docs) {
final equipment = EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
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<void> 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<void> 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<String, dynamic>,
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<void> _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<List<String>> getAllModels() async {
try {
final equipmentQuery = await _equipmentCollection.get();
final models = <String>{};
for (var doc in equipmentQuery.docs) {
final data = doc.data() as Map<String, dynamic>;
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<List<String>> getAllBrands() async {
try {
final equipmentQuery = await _equipmentCollection.get();
final brands = <String>{};
for (var doc in equipmentQuery.docs) {
final data = doc.data() as Map<String, dynamic>;
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<List<String>> getModelsByBrand(String brand) async {
try {
final equipmentQuery = await _equipmentCollection
.where('brand', isEqualTo: brand)
.get();
final models = <String>{};
for (var doc in equipmentQuery.docs) {
final data = doc.data() as Map<String, dynamic>;
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<bool> 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<List<EquipmentModel>> 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 = <EquipmentModel>[];
for (var doc in equipmentQuery.docs) {
final equipment = EquipmentModel.fromMap(
doc.data() as Map<String, dynamic>,
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;
}
}
}

View File

@@ -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<void> 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<void> 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<void> completePreparationWithMissing(
String eventId,
List<String> 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<void> 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<String, dynamic>,
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<void> 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<String, dynamic>,
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<void> completeReturnWithMissing(
String eventId,
List<String> 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<String, dynamic>,
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<void> 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<EventModel?> _getEvent(String eventId) async {
try {
final doc = await _eventsCollection.doc(eventId).get();
if (doc.exists) {
return EventModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting event: $e');
rethrow;
}
}
/// Ajouter un équipement à un événement
Future<void> 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<String, dynamic>,
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<void> 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<String, dynamic>,
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;
}
}
}

View File

@@ -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<void> 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<void> updateMaintenance(String id, Map<String, dynamic> 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<void> 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<String, dynamic>,
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<MaintenanceModel?> getMaintenanceById(String id) async {
try {
final doc = await _maintenancesCollection.doc(id).get();
if (doc.exists) {
return MaintenanceModel.fromMap(doc.data() as Map<String, dynamic>, doc.id);
}
return null;
} catch (e) {
print('Error getting maintenance: $e');
rethrow;
}
}
/// Récupérer l'historique des maintenances pour un équipement
Stream<List<MaintenanceModel>> 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<String, dynamic>,
doc.id,
))
.toList();
});
} catch (e) {
print('Error streaming maintenances: $e');
rethrow;
}
}
/// Récupérer toutes les maintenances
Stream<List<MaintenanceModel>> getAllMaintenances() {
try {
return _maintenancesCollection
.orderBy('scheduledDate', descending: true)
.snapshots()
.map((snapshot) {
return snapshot.docs
.map((doc) => MaintenanceModel.fromMap(
doc.data() as Map<String, dynamic>,
doc.id,
))
.toList();
});
} catch (e) {
print('Error streaming all maintenances: $e');
rethrow;
}
}
/// Marquer une maintenance comme complétée
Future<void> completeMaintenance(String id, {String? performedBy, double? cost}) async {
try {
final updateData = <String, dynamic>{
'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<void> 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<String, dynamic>,
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<void> _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<String, dynamic>;
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<String, dynamic>;
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<void> _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<String, dynamic>,
equipmentDoc.id,
);
final updatedMaintenanceIds = List<String>.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<void> _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<String, dynamic>,
equipmentDoc.id,
);
final updatedMaintenanceIds = List<String>.from(equipment.maintenanceIds);
updatedMaintenanceIds.remove(maintenanceId);
await _equipmentCollection.doc(equipmentId).update({
'maintenanceIds': updatedMaintenanceIds,
});
}
} catch (e) {
print('Error removing maintenance from equipment: $e');
rethrow;
}
}
}

View File

@@ -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<String?>? onBrandChanged;
final List<String> filteredModels;
final String? selectedBrand;
final Function(List<String>) onModelsChanged;
const BrandModelSelector({
super.key,
required this.brandController,
required this.modelController,
this.onBrandChanged,
required this.filteredModels,
required this.selectedBrand,
required this.onModelsChanged,
});
@override
State<BrandModelSelector> createState() => _BrandModelSelectorState();
}
class _BrandModelSelectorState extends State<BrandModelSelector> {
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: Consumer<EquipmentProvider>(
builder: (context, provider, child) {
return Autocomplete<String>(
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<EquipmentProvider>(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<EquipmentProvider>(context, listen: false);
final models = await equipmentProvider.loadModelsByBrand(value);
widget.onModelsChanged(models);
} else {
widget.onModelsChanged([]);
}
},
);
},
);
},
),
),
const SizedBox(width: 16),
Expanded(
child: Autocomplete<String>(
initialValue: TextEditingValue(text: widget.modelController.text),
optionsBuilder: (TextEditingValue textEditingValue) {
if (widget.selectedBrand == null || widget.selectedBrand!.isEmpty) {
return const Iterable<String>.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;
},
);
},
),
),
],
);
}
}

View File

@@ -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<String> ensureUniqueId(String baseId, EquipmentService service) async {
if (await service.isIdUnique(baseId)) {
return baseId;
}
return '${baseId}_${DateTime.now().millisecondsSinceEpoch}';
}
}

View File

@@ -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<EquipmentFormPage> createState() => _EquipmentFormPageState();
}
class _EquipmentFormPageState extends State<EquipmentFormPage> {
final _formKey = GlobalKey<FormState>();
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<String> _selectedParentBoxIds = [];
List<EquipmentModel> _availableBoxes = [];
bool _isLoading = false;
bool _isLoadingBoxes = true;
bool _addMultiple = false;
String? _selectedBrand;
List<String> _filteredModels = [];
@override
void initState() {
super.initState();
_loadAvailableBoxes();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(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<void> _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<void> _loadFilteredModels(String brand) async {
try {
final equipmentProvider = Provider.of<EquipmentProvider>(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<LocalUserProvider>(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<EquipmentCategory>(
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<EquipmentStatus>(
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<void> _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<void> _saveEquipment() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final equipmentProvider = Provider.of<EquipmentProvider>(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<String> ids = [];
List<int> 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';
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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()),
);
},
),
),
],
),
),

View File

@@ -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