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:
@@ -1,9 +1,122 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// Widget pour afficher les événements associés
|
||||
class EquipmentAssociatedEventsSection extends StatelessWidget {
|
||||
const EquipmentAssociatedEventsSection({super.key});
|
||||
enum EventFilter {
|
||||
current, // Événements en cours (préparés mais pas encore retournés)
|
||||
upcoming, // Événements à venir
|
||||
past, // Événements passés
|
||||
}
|
||||
|
||||
/// Widget pour afficher les événements associés à un équipement
|
||||
class EquipmentAssociatedEventsSection extends StatefulWidget {
|
||||
final EquipmentModel equipment;
|
||||
|
||||
const EquipmentAssociatedEventsSection({
|
||||
super.key,
|
||||
required this.equipment,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EquipmentAssociatedEventsSection> createState() =>
|
||||
_EquipmentAssociatedEventsSectionState();
|
||||
}
|
||||
|
||||
class _EquipmentAssociatedEventsSectionState
|
||||
extends State<EquipmentAssociatedEventsSection> {
|
||||
EventFilter _selectedFilter = EventFilter.current;
|
||||
List<EventModel> _events = [];
|
||||
bool _isLoading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadEvents();
|
||||
}
|
||||
|
||||
Future<void> _loadEvents() async {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final eventsSnapshot = await FirebaseFirestore.instance
|
||||
.collection('events')
|
||||
.where('assignedEquipment',
|
||||
arrayContains: {'equipmentId': widget.equipment.id})
|
||||
.get();
|
||||
|
||||
final events = eventsSnapshot.docs
|
||||
.map((doc) => EventModel.fromMap(doc.data(), doc.id))
|
||||
.toList();
|
||||
|
||||
// Filtrer selon le statut
|
||||
final now = DateTime.now();
|
||||
final filteredEvents = events.where((event) {
|
||||
switch (_selectedFilter) {
|
||||
case EventFilter.current:
|
||||
// Événement en cours = préparation complétée ET retour pas encore complété
|
||||
return (event.preparationStatus == PreparationStatus.completed ||
|
||||
event.preparationStatus ==
|
||||
PreparationStatus.completedWithMissing) &&
|
||||
(event.returnStatus == null ||
|
||||
event.returnStatus == ReturnStatus.notStarted ||
|
||||
event.returnStatus == ReturnStatus.inProgress);
|
||||
|
||||
case EventFilter.upcoming:
|
||||
// Événements à venir = date de début dans le futur OU préparation pas encore faite
|
||||
return event.startDateTime.isAfter(now) ||
|
||||
event.preparationStatus == PreparationStatus.notStarted;
|
||||
|
||||
case EventFilter.past:
|
||||
// Événements passés = retour complété
|
||||
return event.returnStatus == ReturnStatus.completed ||
|
||||
event.returnStatus == ReturnStatus.completedWithMissing;
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Trier par date
|
||||
filteredEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
|
||||
setState(() {
|
||||
_events = filteredEvents;
|
||||
_isLoading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _isLoading = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Erreur lors du chargement: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _getFilterLabel(EventFilter filter) {
|
||||
switch (filter) {
|
||||
case EventFilter.current:
|
||||
return 'En cours';
|
||||
case EventFilter.upcoming:
|
||||
return 'À venir';
|
||||
case EventFilter.past:
|
||||
return 'Passés';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getFilterIcon(EventFilter filter) {
|
||||
switch (filter) {
|
||||
case EventFilter.current:
|
||||
return Icons.play_circle;
|
||||
case EventFilter.upcoming:
|
||||
return Icons.upcoming;
|
||||
case EventFilter.past:
|
||||
return Icons.history;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -14,32 +127,302 @@ class EquipmentAssociatedEventsSection extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec filtre
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.event, color: AppColors.rouge),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Événements associés',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Événements associés',
|
||||
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Menu déroulant pour filtrer
|
||||
PopupMenuButton<EventFilter>(
|
||||
icon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
_getFilterIcon(_selectedFilter),
|
||||
size: 20,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_getFilterLabel(_selectedFilter),
|
||||
style: const TextStyle(
|
||||
color: AppColors.rouge,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: AppColors.rouge),
|
||||
],
|
||||
),
|
||||
onSelected: (filter) {
|
||||
setState(() => _selectedFilter = filter);
|
||||
_loadEvents();
|
||||
},
|
||||
itemBuilder: (context) => EventFilter.values.map((filter) {
|
||||
return PopupMenuItem(
|
||||
value: filter,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_getFilterIcon(filter), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getFilterLabel(filter)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Divider(height: 24),
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Fonctionnalité à implémenter',
|
||||
style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic),
|
||||
|
||||
// Liste des événements
|
||||
if (_isLoading)
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
)
|
||||
else if (_events.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_busy,
|
||||
size: 48,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Aucun événement ${_getFilterLabel(_selectedFilter).toLowerCase()}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: _events.map((event) => _buildEventCard(event)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEventCard(EventModel event) {
|
||||
final dateFormat = DateFormat('dd/MM/yyyy HH:mm');
|
||||
final isInProgress = (event.preparationStatus == PreparationStatus.completed ||
|
||||
event.preparationStatus == PreparationStatus.completedWithMissing) &&
|
||||
(event.returnStatus == null ||
|
||||
event.returnStatus == ReturnStatus.notStarted ||
|
||||
event.returnStatus == ReturnStatus.inProgress);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: isInProgress
|
||||
? const BorderSide(color: AppColors.rouge, width: 2)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// Navigation vers les détails de l'événement si nécessaire
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Titre de l'événement
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
event.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isInProgress)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'EN COURS',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Dates
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.calendar_today, size: 14, color: Colors.grey),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${dateFormat.format(event.startDateTime)} → ${dateFormat.format(event.endDateTime)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Statuts de préparation et retour
|
||||
Row(
|
||||
children: [
|
||||
_buildStatusChip(
|
||||
'Préparation',
|
||||
event.preparationStatus ?? PreparationStatus.notStarted,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusChip(
|
||||
'Retour',
|
||||
event.returnStatus ?? ReturnStatus.notStarted,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Boutons d'action
|
||||
if (isInProgress && _selectedFilter == EventFilter.current) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
'/event_preparation',
|
||||
arguments: event.id,
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.logout, size: 16),
|
||||
label: const Text('Check-out'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.rouge,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusChip(String label, dynamic status) {
|
||||
Color color;
|
||||
String text;
|
||||
|
||||
if (status is PreparationStatus) {
|
||||
switch (status) {
|
||||
case PreparationStatus.notStarted:
|
||||
color = Colors.grey;
|
||||
text = 'Non démarrée';
|
||||
break;
|
||||
case PreparationStatus.inProgress:
|
||||
color = Colors.orange;
|
||||
text = 'En cours';
|
||||
break;
|
||||
case PreparationStatus.completed:
|
||||
color = Colors.green;
|
||||
text = 'Complétée';
|
||||
break;
|
||||
case PreparationStatus.completedWithMissing:
|
||||
color = Colors.red;
|
||||
text = 'Manquants';
|
||||
break;
|
||||
}
|
||||
} else if (status is ReturnStatus) {
|
||||
switch (status) {
|
||||
case ReturnStatus.notStarted:
|
||||
color = Colors.grey;
|
||||
text = 'Non démarré';
|
||||
break;
|
||||
case ReturnStatus.inProgress:
|
||||
color = Colors.orange;
|
||||
text = 'En cours';
|
||||
break;
|
||||
case ReturnStatus.completed:
|
||||
color = Colors.green;
|
||||
text = 'Complété';
|
||||
break;
|
||||
case ReturnStatus.completedWithMissing:
|
||||
color = Colors.red;
|
||||
text = 'Manquants';
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
color = Colors.grey;
|
||||
text = 'Inconnu';
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$label: ',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user