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:
ElPoyo
2025-11-30 20:33:03 +01:00
parent e59e3e6316
commit 08f046c89c
31 changed files with 4955 additions and 46 deletions

View File

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