Migration complète du backend pour utiliser des Cloud Functions comme couche API sécurisée, en remplacement des appels directs à Firestore depuis le client.
**Backend (Cloud Functions):**
- **Centralisation CORS :** Ajout d'un middleware `withCors` et d'une configuration `httpOptions` pour gérer uniformément les en-têtes CORS et les requêtes `OPTIONS` sur toutes les fonctions.
- **Nouvelles Fonctions de Lecture (GET) :**
- `getEquipments`, `getContainers`, `getEvents`, `getUsers`, `getOptions`, `getEventTypes`, `getRoles`, `getMaintenances`, `getAlerts`.
- Ces fonctions gèrent les permissions côté serveur, masquant les données sensibles (ex: prix des équipements) pour les utilisateurs non-autorisés.
- `getEvents` retourne également une map des utilisateurs (`usersMap`) pour optimiser le chargement des données de la main d'œuvre.
- **Nouvelle Fonction de Recherche :**
- `getContainersByEquipment` : Endpoint dédié pour trouver efficacement tous les containers qui contiennent un équipement spécifique.
- **Nouvelles Fonctions d'Écriture (CRUD) :**
- Fonctions CRUD complètes pour `eventTypes` (`create`, `update`, `delete`), incluant la validation (unicité du nom, vérification des événements futurs avant suppression).
- **Mise à jour de Fonctions Existantes :**
- Toutes les fonctions CRUD existantes (`create/update/deleteEquipment`, `create/update/deleteContainer`, etc.) sont wrappées avec le nouveau gestionnaire CORS.
**Frontend (Flutter):**
- **Introduction du `DataService` :** Nouveau service centralisant tous les appels aux Cloud Functions, servant d'intermédiaire entre l'UI/Providers et l'API.
- **Refactorisation des Providers :**
- `EquipmentProvider`, `ContainerProvider`, `EventProvider`, `UsersProvider`, `MaintenanceProvider` et `AlertProvider` ont été refactorisés pour utiliser le `DataService` au lieu d'accéder directement à Firestore.
- Les `Stream` Firestore sont remplacés par des chargements de données via des méthodes `Future` (`loadEquipments`, `loadEvents`, etc.).
- **Gestion des Relations Équipement-Container :**
- Le modèle `EquipmentModel` ne stocke plus `parentBoxIds`.
- La relation est maintenant gérée par le `ContainerModel` qui contient `equipmentIds`.
- Le `ContainerEquipmentService` est introduit pour utiliser la nouvelle fonction `getContainersByEquipment`.
- L'affichage des boîtes parentes (`EquipmentParentContainers`) et le formulaire d'équipement (`EquipmentFormPage`) ont été mis à jour pour refléter ce nouveau modèle de données, synchronisant les ajouts/suppressions d'équipements dans les containers.
- **Amélioration de l'UI :**
- Nouveau widget `ParentBoxesSelector` pour une sélection améliorée et visuelle des boîtes parentes dans le formulaire d'équipement.
- Refonte visuelle de `EquipmentParentContainers` pour une meilleure présentation.
333 lines
13 KiB
Dart
333 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:em2rp/models/event_model.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/providers/local_user_provider.dart';
|
|
import 'package:em2rp/providers/event_provider.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_navigation.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_header.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_status_button.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_preparation_buttons.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_info.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_description.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_documents.dart';
|
|
import 'package:em2rp/views/widgets/calendar_widgets/event_details_components/event_details_equipe.dart';
|
|
|
|
class EventDetails extends StatelessWidget {
|
|
final EventModel event;
|
|
final DateTime? selectedDate;
|
|
final List<EventModel> events;
|
|
final void Function(EventModel, DateTime) onSelectEvent;
|
|
|
|
const EventDetails({
|
|
super.key,
|
|
required this.event,
|
|
required this.selectedDate,
|
|
required this.events,
|
|
required this.onSelectEvent,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Trie les événements par date de début
|
|
final sortedEvents = List<EventModel>.from(events)
|
|
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
|
final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id);
|
|
final localUserProvider = Provider.of<LocalUserProvider>(context);
|
|
final canViewPrices = localUserProvider.hasPermission('view_event_prices');
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.all(16),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
EventDetailsNavigation(
|
|
sortedEvents: sortedEvents,
|
|
currentIndex: currentIndex,
|
|
selectedDate: selectedDate,
|
|
onSelectEvent: onSelectEvent,
|
|
),
|
|
const SizedBox(height: 16),
|
|
EventDetailsHeader(event: event),
|
|
if (Provider.of<LocalUserProvider>(context, listen: false)
|
|
.hasPermission('change_event_status'))
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
|
child: EventStatusButton(
|
|
event: event,
|
|
selectedDate: selectedDate,
|
|
onSelectEvent: onSelectEvent,
|
|
),
|
|
),
|
|
// Boutons de préparation et retour
|
|
EventPreparationButtons(event: event),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
EventDetailsInfo(
|
|
event: event,
|
|
canViewPrices: canViewPrices,
|
|
),
|
|
const SizedBox(height: 16),
|
|
EventDetailsDescription(event: event),
|
|
EventDetailsDocuments(documents: event.documents),
|
|
const SizedBox(height: 16),
|
|
EventDetailsEquipe(event: event),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// La classe EventAddDialog reste inchangée car elle n'est pas liée aux détails d'événement
|
|
class EventAddDialog extends StatefulWidget {
|
|
const EventAddDialog({super.key});
|
|
|
|
@override
|
|
State<EventAddDialog> createState() => _EventAddDialogState();
|
|
}
|
|
|
|
class _EventAddDialogState extends State<EventAddDialog> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final TextEditingController _nameController = TextEditingController();
|
|
final TextEditingController _descriptionController = TextEditingController();
|
|
final TextEditingController _priceController = TextEditingController();
|
|
final TextEditingController _installationController = TextEditingController();
|
|
final TextEditingController _disassemblyController = TextEditingController();
|
|
final TextEditingController _latitudeController = TextEditingController();
|
|
final TextEditingController _longitudeController = TextEditingController();
|
|
final TextEditingController _addressController = TextEditingController();
|
|
DateTime? _startDateTime;
|
|
DateTime? _endDateTime;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _success;
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_descriptionController.dispose();
|
|
_priceController.dispose();
|
|
_installationController.dispose();
|
|
_disassemblyController.dispose();
|
|
_latitudeController.dispose();
|
|
_longitudeController.dispose();
|
|
_addressController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate() ||
|
|
_startDateTime == null ||
|
|
_endDateTime == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
_success = null;
|
|
});
|
|
try {
|
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
final newEvent = EventModel(
|
|
id: '',
|
|
name: _nameController.text.trim(),
|
|
description: _descriptionController.text.trim(),
|
|
startDateTime: _startDateTime!,
|
|
endDateTime: _endDateTime!,
|
|
basePrice: double.tryParse(_priceController.text) ?? 0.0,
|
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
|
eventTypeId: '', // à adapter si tu veux gérer les types
|
|
customerId: '', // à adapter si tu veux gérer les clients
|
|
address: _addressController.text.trim(),
|
|
latitude: double.tryParse(_latitudeController.text) ?? 0.0,
|
|
longitude: double.tryParse(_longitudeController.text) ?? 0.0,
|
|
workforce: [],
|
|
documents: [],
|
|
);
|
|
await eventProvider.addEvent(newEvent);
|
|
setState(() {
|
|
_success = "Événement créé avec succès !";
|
|
});
|
|
Navigator.of(context).pop();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = "Erreur lors de la création : $e";
|
|
});
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AlertDialog(
|
|
title: const Text('Créer un événement'),
|
|
content: SingleChildScrollView(
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(labelText: 'Nom'),
|
|
validator: (v) =>
|
|
v == null || v.isEmpty ? 'Champ requis' : null,
|
|
),
|
|
TextFormField(
|
|
controller: _descriptionController,
|
|
decoration: const InputDecoration(labelText: 'Description'),
|
|
maxLines: 2,
|
|
),
|
|
TextFormField(
|
|
controller: _priceController,
|
|
decoration: const InputDecoration(labelText: 'Prix (€)'),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
TextFormField(
|
|
controller: _installationController,
|
|
decoration:
|
|
const InputDecoration(labelText: 'Installation (h)'),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
TextFormField(
|
|
controller: _disassemblyController,
|
|
decoration: const InputDecoration(labelText: 'Démontage (h)'),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
TextFormField(
|
|
controller: _latitudeController,
|
|
decoration: const InputDecoration(labelText: 'Latitude'),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
TextFormField(
|
|
controller: _longitudeController,
|
|
decoration: const InputDecoration(labelText: 'Longitude'),
|
|
keyboardType: TextInputType.number,
|
|
),
|
|
TextFormField(
|
|
controller: _addressController,
|
|
decoration: const InputDecoration(labelText: 'Adresse'),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.now(),
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2030),
|
|
);
|
|
if (picked != null) {
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
);
|
|
if (time != null) {
|
|
setState(() {
|
|
_startDateTime = DateTime(
|
|
picked.year,
|
|
picked.month,
|
|
picked.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
child: Text(_startDateTime == null
|
|
? 'Début'
|
|
: DateFormat('dd/MM/yyyy HH:mm')
|
|
.format(_startDateTime!)),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _startDateTime ?? DateTime.now(),
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2030),
|
|
);
|
|
if (picked != null) {
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
);
|
|
if (time != null) {
|
|
setState(() {
|
|
_endDateTime = DateTime(
|
|
picked.year,
|
|
picked.month,
|
|
picked.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
child: Text(_endDateTime == null
|
|
? 'Fin'
|
|
: DateFormat('dd/MM/yyyy HH:mm')
|
|
.format(_endDateTime!)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_error != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child:
|
|
Text(_error!, style: const TextStyle(color: Colors.red)),
|
|
),
|
|
if (_success != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(_success!,
|
|
style: const TextStyle(color: Colors.green)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: _isLoading ? null : () => Navigator.of(context).pop(),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: _isLoading ? null : _submit,
|
|
child: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Créer'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|