feat: Refactor event equipment management with advanced selection and conflict detection
This commit introduces a complete overhaul of how equipment is assigned to events, focusing on an enhanced user experience, advanced selection capabilities, and robust conflict detection.
**Key Features & Enhancements:**
- **Advanced Equipment Selection UI (`EquipmentSelectionDialog`):**
- New full-screen dialog to select equipment and containers ("boîtes") for an event.
- Hierarchical view showing containers and a flat list of all individual equipment.
- Real-time search and filtering by equipment category.
- Side panel summarizing the current selection and providing recommendations for containers based on selected equipment.
- Supports quantity selection for consumables and cables.
- **Conflict Detection & Management (`EventAvailabilityService`):**
- A new service (`EventAvailabilityService`) checks for equipment availability against other events based on the selected date range.
- The selection dialog visually highlights equipment and containers with scheduling conflicts (e.g., already used, partially unavailable).
- A dedicated conflict resolution dialog (`EquipmentConflictDialog`) appears if conflicting items are selected, allowing the user to either remove them or force the assignment.
- **Integrated Event Form (`EventAssignedEquipmentSection`):**
- The event creation/editing form now includes a new section for managing assigned equipment.
- It clearly displays assigned containers and standalone equipment, showing the composition of each container.
- Integrates the new selection dialog, ensuring all assignments are checked for conflicts before being saved.
- **Event Preparation & Return Workflow (`EventPreparationPage`):**
- New page (`EventPreparationPage`) for managing the check-out (preparation) and check-in (return) of equipment for an event.
- Provides a checklist of all assigned equipment.
- Users can validate each item, with options to "validate all" or finalize with missing items.
- Includes a dialog (`MissingEquipmentDialog`) to handle discrepancies.
- Supports tracking returned quantities for consumables.
**Data Model and Other Changes:**
- The `EventModel` now includes `assignedContainers` to explicitly link containers to an event.
- `EquipmentAssociatedEventsSection` on the equipment detail page is now functional, displaying current, upcoming, and past events for that item.
- Added deployment and versioning scripts (`scripts/deploy.js`, `scripts/increment_version.js`, `scripts/toggle_env.js`) to automate the release process.
- Introduced an application version display in the main drawer (`AppVersion`).
This commit is contained in:
385
em2rp/lib/views/event_preparation_page.dart
Normal file
385
em2rp/lib/views/event_preparation_page.dart
Normal file
@@ -0,0 +1,385 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/providers/event_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/services/event_preparation_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/widgets/event/equipment_checklist_item.dart';
|
||||
import 'package:em2rp/views/widgets/event/missing_equipment_dialog.dart';
|
||||
import 'package:em2rp/views/widgets/event/preparation_success_dialog.dart';
|
||||
|
||||
class EventPreparationPage extends StatefulWidget {
|
||||
final String eventId;
|
||||
|
||||
const EventPreparationPage({
|
||||
super.key,
|
||||
required this.eventId,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EventPreparationPage> createState() => _EventPreparationPageState();
|
||||
}
|
||||
|
||||
class _EventPreparationPageState extends State<EventPreparationPage> {
|
||||
final EventPreparationService _preparationService = EventPreparationService();
|
||||
EventModel? _event;
|
||||
Map<String, EquipmentModel> _equipmentMap = {};
|
||||
Map<String, int> _returnedQuantities = {}; // Pour les quantités retournées (consommables)
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
|
||||
// Mode déterminé automatiquement
|
||||
bool get _isReturnMode {
|
||||
if (_event == null) return false;
|
||||
// Mode retour si préparation complétée et retour pas encore complété
|
||||
return _event!.preparationStatus == PreparationStatus.completed ||
|
||||
_event!.preparationStatus == PreparationStatus.completedWithMissing;
|
||||
}
|
||||
|
||||
String get _pageTitle => _isReturnMode ? 'Retour matériel' : 'Préparation matériel';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEventAndEquipment();
|
||||
}
|
||||
|
||||
Future<void> _loadEventAndEquipment() async {
|
||||
try {
|
||||
// Charger l'événement
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
final event = await eventProvider.getEvent(widget.eventId);
|
||||
|
||||
if (event == null) {
|
||||
throw Exception('Événement non trouvé');
|
||||
}
|
||||
|
||||
// Charger tous les équipements assignés
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
final Map<String, EquipmentModel> equipmentMap = {};
|
||||
|
||||
for (var assignedEq in event.assignedEquipment) {
|
||||
final equipment = await equipmentProvider.getEquipmentById(assignedEq.equipmentId);
|
||||
if (equipment != null) {
|
||||
equipmentMap[assignedEq.equipmentId] = equipment;
|
||||
|
||||
// Initialiser les quantités retournées pour les consommables
|
||||
if (_isReturnMode &&
|
||||
(equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable)) {
|
||||
_returnedQuantities[assignedEq.equipmentId] = assignedEq.returnedQuantity ?? assignedEq.quantity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_event = event;
|
||||
_equipmentMap = equipmentMap;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleEquipmentValidation(String equipmentId, bool isValidated) async {
|
||||
try {
|
||||
if (_isReturnMode) {
|
||||
if (isValidated) {
|
||||
final returnedQty = _returnedQuantities[equipmentId];
|
||||
await _preparationService.validateEquipmentReturn(
|
||||
widget.eventId,
|
||||
equipmentId,
|
||||
returnedQuantity: returnedQty,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await _preparationService.validateEquipmentPreparation(widget.eventId, equipmentId);
|
||||
}
|
||||
|
||||
// Recharger l'événement
|
||||
await _loadEventAndEquipment();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _validateAllQuickly() async {
|
||||
setState(() => _isSaving = true);
|
||||
|
||||
try {
|
||||
if (_isReturnMode) {
|
||||
await _preparationService.validateAllReturn(widget.eventId, _returnedQuantities);
|
||||
} else {
|
||||
await _preparationService.validateAllPreparation(widget.eventId);
|
||||
}
|
||||
|
||||
// Afficher le dialog de succès avec animation
|
||||
if (mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode),
|
||||
);
|
||||
|
||||
// Retour à la page précédente
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _validatePreparation() async {
|
||||
if (_event == null) return;
|
||||
|
||||
// Vérifier quels équipements ne sont pas validés
|
||||
final missingEquipment = <EquipmentModel>[];
|
||||
final missingIds = <String>[];
|
||||
|
||||
for (var assignedEq in _event!.assignedEquipment) {
|
||||
final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared;
|
||||
|
||||
if (!isValidated) {
|
||||
final equipment = _equipmentMap[assignedEq.equipmentId];
|
||||
if (equipment != null) {
|
||||
missingEquipment.add(equipment);
|
||||
missingIds.add(assignedEq.equipmentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Si tout est validé, on finalise directement
|
||||
if (missingEquipment.isEmpty) {
|
||||
await _validateAllQuickly();
|
||||
return;
|
||||
}
|
||||
|
||||
// Sinon, afficher le dialog des manquants
|
||||
if (mounted) {
|
||||
final result = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => MissingEquipmentDialog(
|
||||
missingEquipments: missingEquipment,
|
||||
eventId: widget.eventId,
|
||||
isReturnMode: _isReturnMode,
|
||||
),
|
||||
);
|
||||
|
||||
if (result == 'confirm_with_missing') {
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
if (_isReturnMode) {
|
||||
await _preparationService.completeReturnWithMissing(widget.eventId, missingIds);
|
||||
} else {
|
||||
await _preparationService.completePreparationWithMissing(widget.eventId, missingIds);
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
await showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => PreparationSuccessDialog(isReturnMode: _isReturnMode),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
} else if (result == 'validate_missing') {
|
||||
// Valider tous les manquants
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
for (var equipmentId in missingIds) {
|
||||
await _toggleEquipmentValidation(equipmentId, true);
|
||||
}
|
||||
await _validateAllQuickly();
|
||||
} finally {
|
||||
setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final userProvider = context.watch<LocalUserProvider>();
|
||||
final userId = userProvider.uid;
|
||||
final hasManagePermission = userProvider.hasPermission('manage_events');
|
||||
|
||||
// Vérifier si l'utilisateur fait partie de l'équipe
|
||||
final isInWorkforce = _event?.workforce.any((ref) => ref.id == userId) ?? false;
|
||||
final hasPermission = hasManagePermission || isInWorkforce;
|
||||
|
||||
if (!hasPermission) {
|
||||
return Scaffold(
|
||||
appBar: const CustomAppBar(title: 'Accès refusé'),
|
||||
body: const Center(
|
||||
child: Text('Vous n\'avez pas les permissions pour accéder à cette page.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: CustomAppBar(title: _pageTitle),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _event == null
|
||||
? const Center(child: Text('Événement introuvable'))
|
||||
: Column(
|
||||
children: [
|
||||
// En-tête avec info de l'événement
|
||||
_buildEventHeader(),
|
||||
|
||||
// Bouton "Tout confirmer"
|
||||
_buildQuickValidateButton(),
|
||||
|
||||
// Liste des équipements
|
||||
Expanded(child: _buildEquipmentList()),
|
||||
|
||||
// Bouton de validation final
|
||||
_buildValidateButton(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventHeader() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge.withOpacity(0.1),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_event!.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${_event!.assignedEquipment.length} équipement(s) assigné(s)',
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickValidateButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isSaving ? null : _validateAllQuickly,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.green,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
icon: const Icon(Icons.check_circle, color: Colors.white),
|
||||
label: Text(
|
||||
_isReturnMode ? 'Tout confirmer comme retourné' : 'Tout confirmer comme préparé',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEquipmentList() {
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _event!.assignedEquipment.length,
|
||||
itemBuilder: (context, index) {
|
||||
final assignedEq = _event!.assignedEquipment[index];
|
||||
final equipment = _equipmentMap[assignedEq.equipmentId];
|
||||
|
||||
if (equipment == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isValidated = _isReturnMode ? assignedEq.isReturned : assignedEq.isPrepared;
|
||||
|
||||
return EquipmentChecklistItem(
|
||||
equipment: equipment,
|
||||
isValidated: isValidated,
|
||||
onValidate: (value) => _toggleEquipmentValidation(assignedEq.equipmentId, value),
|
||||
isReturnMode: _isReturnMode,
|
||||
quantity: assignedEq.quantity,
|
||||
returnedQuantity: _returnedQuantities[assignedEq.equipmentId],
|
||||
onReturnedQuantityChanged: _isReturnMode
|
||||
? (value) {
|
||||
setState(() {
|
||||
_returnedQuantities[assignedEq.equipmentId] = value;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValidateButton() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.3),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 5,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: _isSaving ? null : _validatePreparation,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
child: _isSaving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(
|
||||
_isReturnMode ? 'Finaliser le retour' : 'Finaliser la préparation',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user