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`).
386 lines
12 KiB
Dart
386 lines
12 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|