diff --git a/.cursor/rules/rules1.mdc b/.cursor/rules/rules1.mdc new file mode 100644 index 0000000..249e3d2 --- /dev/null +++ b/.cursor/rules/rules1.mdc @@ -0,0 +1,9 @@ +--- +description: To remember for every prompts +globs: +alwaysApply: false +--- +Le plus important : Repondre en français. Toujours appliquer les modification au code sauf si le message commence par "QUESTION :" + + +Projet d'ERP pour une entreprise d'événemetiel. Flutter, Dart \ No newline at end of file diff --git a/em2rp/lib/main.dart b/em2rp/lib/main.dart index 5a367b3..826c480 100644 --- a/em2rp/lib/main.dart +++ b/em2rp/lib/main.dart @@ -15,6 +15,7 @@ import 'providers/local_user_provider.dart'; import 'services/user_service.dart'; import 'pages/auth/reset_password_page.dart'; import 'config/env.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -81,6 +82,15 @@ class MyApp extends StatelessWidget { ), ), ), + locale: const Locale('fr', 'FR'), + supportedLocales: const [ + Locale('fr', 'FR'), + ], + localizationsDelegates: const [ + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], home: const AutoLoginWrapper(), routes: { '/login': (context) => const LoginPage(), diff --git a/em2rp/lib/models/role_model.dart b/em2rp/lib/models/role_model.dart index 3fddfc5..e82be67 100644 --- a/em2rp/lib/models/role_model.dart +++ b/em2rp/lib/models/role_model.dart @@ -1,100 +1,28 @@ -enum Permission { - // Permissions liées aux prestations - viewAllEvents, // Voir toutes les prestations - viewAssignedEvents, // Voir uniquement les prestations assignées - editEvents, // Modifier les prestations - deleteEvents, // Supprimer les prestations - assignCrew, // Assigner des membres d'équipe aux prestations +import 'package:cloud_firestore/cloud_firestore.dart'; - // Permissions liées aux finances - viewPrices, // Voir les prix - editPrices, // Modifier les prix - viewQuotes, // Voir les devis - createQuotes, // Créer des devis - editQuotes, // Modifier les devis - viewInvoices, // Voir les factures - createInvoices, // Créer des factures - editInvoices, // Modifier les factures - - // Permissions liées aux utilisateurs - viewUsers, // Voir les utilisateurs - editUsers, // Modifier les utilisateurs - deleteUsers, // Supprimer les utilisateurs - - // Permissions liées aux clients - viewClients, // Voir les clients - editClients, // Modifier les clients - deleteClients, // Supprimer les clients -} - -class Role { +class RoleModel { + final String id; final String name; - final Set permissions; + final List permissions; - const Role({ + RoleModel({ + required this.id, required this.name, required this.permissions, }); - bool hasPermission(Permission permission) => permissions.contains(permission); - - bool hasAllPermissions(List requiredPermissions) { - return requiredPermissions - .every((permission) => permissions.contains(permission)); + factory RoleModel.fromMap(Map map, String id) { + return RoleModel( + id: id, + name: map['name'] ?? '', + permissions: List.from(map['permissions'] ?? []), + ); } - bool hasAnyPermission(List requiredPermissions) { - return requiredPermissions - .any((permission) => permissions.contains(permission)); + Map toMap() { + return { + 'name': name, + 'permissions': permissions, + }; } } - -class Roles { - static const admin = Role( - name: 'ADMIN', - permissions: { - // Toutes les permissions pour l'administrateur - Permission.viewAllEvents, - Permission.viewAssignedEvents, - Permission.editEvents, - Permission.deleteEvents, - Permission.assignCrew, - Permission.viewPrices, - Permission.editPrices, - Permission.viewQuotes, - Permission.createQuotes, - Permission.editQuotes, - Permission.viewInvoices, - Permission.createInvoices, - Permission.editInvoices, - Permission.viewUsers, - Permission.editUsers, - Permission.deleteUsers, - Permission.viewClients, - Permission.editClients, - Permission.deleteClients, - }, - ); - - static const crew = Role( - name: 'CREW', - permissions: { - // Permissions limitées pour l'équipe - Permission.viewAssignedEvents, - Permission.viewClients, - }, - ); - - static Role fromString(String roleName) { - switch (roleName.toUpperCase()) { - case 'ADMIN': - return admin; - case 'CREW': - return crew; - default: - return crew; // Par défaut, on donne les permissions minimales - } - } - - static List values = [admin, crew]; -} diff --git a/em2rp/lib/models/user_model.dart b/em2rp/lib/models/user_model.dart index ff08baf..14ee3fc 100644 --- a/em2rp/lib/models/user_model.dart +++ b/em2rp/lib/models/user_model.dart @@ -1,3 +1,5 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; + class UserModel { final String uid; final String firstName; @@ -19,11 +21,20 @@ class UserModel { // Convertit une Map (Firestore) en UserModel factory UserModel.fromMap(Map data, String uid) { + String roleString; + final roleField = data['role']; + if (roleField is String) { + roleString = roleField; + } else if (roleField is DocumentReference) { + roleString = roleField.id; + } else { + roleString = 'USER'; + } return UserModel( uid: uid, firstName: data['firstName'] ?? '', lastName: data['lastName'] ?? '', - role: data['role'] ?? 'USER', + role: roleString, profilePhotoUrl: data['profilePhotoUrl'] ?? '', email: data['email'] ?? '', phoneNumber: data['phoneNumber'] ?? '', diff --git a/em2rp/lib/providers/event_provider.dart b/em2rp/lib/providers/event_provider.dart index 8c0986a..4efa230 100644 --- a/em2rp/lib/providers/event_provider.dart +++ b/em2rp/lib/providers/event_provider.dart @@ -11,24 +11,29 @@ class EventProvider with ChangeNotifier { bool get isLoading => _isLoading; // Récupérer les événements pour un utilisateur spécifique - Future loadUserEvents(String userId) async { + Future loadUserEvents(String userId, + {bool canViewAllEvents = false}) async { _isLoading = true; notifyListeners(); try { - print('Loading events for user: $userId'); - - // Récupérer uniquement les événements où l'utilisateur est dans la workforce - final eventsSnapshot = await _firestore - .collection('events') - .where('workforce', arrayContains: userId) - .get(); + print( + 'Loading events for user: $userId (canViewAllEvents: $canViewAllEvents)'); + QuerySnapshot eventsSnapshot; + if (canViewAllEvents) { + eventsSnapshot = await _firestore.collection('events').get(); + } else { + eventsSnapshot = await _firestore + .collection('events') + .where('workforce', arrayContains: userId) + .get(); + } print('Found ${eventsSnapshot.docs.length} events for user'); _events = eventsSnapshot.docs.map((doc) { print('Event data: ${doc.data()}'); - return EventModel.fromMap(doc.data(), doc.id); + return EventModel.fromMap(doc.data() as Map, doc.id); }).toList(); print('Parsed ${_events.length} events'); diff --git a/em2rp/lib/providers/local_user_provider.dart b/em2rp/lib/providers/local_user_provider.dart index 1e61f1d..bc154f9 100644 --- a/em2rp/lib/providers/local_user_provider.dart +++ b/em2rp/lib/providers/local_user_provider.dart @@ -3,10 +3,12 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../models/user_model.dart'; +import '../models/role_model.dart'; import '../utils/firebase_storage_manager.dart'; class LocalUserProvider with ChangeNotifier { UserModel? _currentUser; + RoleModel? _currentRole; final FirebaseAuth _auth = FirebaseAuth.instance; final FirebaseFirestore _firestore = FirebaseFirestore.instance; final FirebaseStorageManager _storageManager = FirebaseStorageManager(); @@ -19,6 +21,8 @@ class LocalUserProvider with ChangeNotifier { String? get profilePhotoUrl => _currentUser?.profilePhotoUrl; String? get email => _currentUser?.email; String? get phoneNumber => _currentUser?.phoneNumber; + RoleModel? get currentRole => _currentRole; + List get permissions => _currentRole?.permissions ?? []; /// Charge les données de l'utilisateur actuel Future loadUserData() async { @@ -47,6 +51,7 @@ class LocalUserProvider with ChangeNotifier { setUser(UserModel.fromMap(userData, userDoc.id)); print('User data loaded successfully'); + await loadRole(); } else { print('No user document found in Firestore'); // Créer un document utilisateur par défaut @@ -73,6 +78,7 @@ class LocalUserProvider with ChangeNotifier { setUser(defaultUser); print('Default user document created'); + await loadRole(); } } catch (e) { print('Error loading user data: $e'); @@ -154,4 +160,24 @@ class LocalUserProvider with ChangeNotifier { await _auth.signOut(); clearUser(); } + + Future loadRole() async { + if (_currentUser == null) return; + final roleId = _currentUser!.role; + if (roleId.isEmpty) return; + try { + final doc = await _firestore.collection('roles').doc(roleId).get(); + if (doc.exists) { + _currentRole = + RoleModel.fromMap(doc.data() as Map, doc.id); + notifyListeners(); + } + } catch (e) { + print('Error loading role: $e'); + } + } + + bool hasPermission(String permission) { + return _currentRole?.permissions.contains(permission) ?? false; + } } diff --git a/em2rp/lib/providers/users_provider.dart b/em2rp/lib/providers/users_provider.dart index 201dfc1..2d31661 100644 --- a/em2rp/lib/providers/users_provider.dart +++ b/em2rp/lib/providers/users_provider.dart @@ -4,6 +4,8 @@ import '../services/user_service.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/providers/local_user_provider.dart'; class UsersProvider with ChangeNotifier { final UserService _userService; @@ -75,7 +77,8 @@ class UsersProvider with ChangeNotifier { await _userService.resetPassword(email); } - Future createUserWithEmailInvite(UserModel user) async { + Future createUserWithEmailInvite( + BuildContext context, UserModel user) async { String? authUid; try { @@ -87,14 +90,12 @@ class UsersProvider with ChangeNotifier { throw Exception('Aucun utilisateur connecté'); } - // Vérifier le rôle de l'utilisateur actuel - final currentUserDoc = - await _firestore.collection('users').doc(currentUser.uid).get(); - print('Current user role: ${currentUserDoc.data()?['role']}'); - - if (currentUserDoc.data()?['role'] != 'ADMIN') { + // Vérifier la permission via le provider + final localUserProvider = + Provider.of(context, listen: false); + if (!localUserProvider.hasPermission('add_user')) { throw Exception( - 'Seuls les administrateurs peuvent créer des utilisateurs'); + 'Vous n\'avez pas la permission de créer des utilisateurs'); } try { diff --git a/em2rp/lib/utils/permission_gate.dart b/em2rp/lib/utils/permission_gate.dart index 970a181..b7f398c 100644 --- a/em2rp/lib/utils/permission_gate.dart +++ b/em2rp/lib/utils/permission_gate.dart @@ -5,7 +5,7 @@ import 'package:em2rp/providers/local_user_provider.dart'; class PermissionGate extends StatelessWidget { final Widget child; - final List requiredPermissions; + final List requiredPermissions; final bool requireAll; final Widget? fallback; @@ -26,10 +26,9 @@ class PermissionGate extends StatelessWidget { return fallback ?? const SizedBox.shrink(); } - final userRole = Roles.fromString(currentUser.role); final hasPermission = requireAll - ? userRole.hasAllPermissions(requiredPermissions) - : userRole.hasAnyPermission(requiredPermissions); + ? hasAllPermissions(localUserProvider, requiredPermissions) + : hasAnyPermission(localUserProvider, requiredPermissions); if (hasPermission) { return child; @@ -39,4 +38,18 @@ class PermissionGate extends StatelessWidget { }, ); } + + bool hasAllPermissions(LocalUserProvider provider, List permissions) { + for (final perm in permissions) { + if (!provider.hasPermission(perm)) return false; + } + return true; + } + + bool hasAnyPermission(LocalUserProvider provider, List permissions) { + for (final perm in permissions) { + if (provider.hasPermission(perm)) return true; + } + return false; + } } diff --git a/em2rp/lib/view_model/login_view_model.dart b/em2rp/lib/view_model/login_view_model.dart index 0bf842f..bf1020c 100644 --- a/em2rp/lib/view_model/login_view_model.dart +++ b/em2rp/lib/view_model/login_view_model.dart @@ -27,32 +27,36 @@ class LoginViewModel extends ChangeNotifier { notifyListeners(); try { - print('User signed in'); - - // Attendre que les données utilisateur soient chargées - await localAuthProvider.loadUserData(); - + // --- Étape 1: Connecter l'utilisateur dans Firebase Auth --- + // Appelle la méthode du provider qui gère la connexion Auth ET le chargement des données utilisateur + await localAuthProvider.signInWithEmailAndPassword( + emailController.text, + passwordController.text, + ); // Vérifier si le contexte est toujours valide if (context.mounted) { - // Vérifier si l'utilisateur a bien été chargé + // Vérifier si l'utilisateur a bien été chargé dans le provider if (localAuthProvider.currentUser != null) { // Utiliser pushReplacementNamed pour une transition propre Navigator.of(context, rootNavigator: true) .pushReplacementNamed('/calendar'); } else { - errorMessage = 'Erreur lors du chargement des données utilisateur'; + errorMessage = + 'Erreur inattendue après connexion: Données utilisateur non chargées.'; isLoading = false; notifyListeners(); } } } on FirebaseAuthException catch (e) { + // Gestion spécifique des erreurs d'authentification (email/mot de passe incorrects, etc.) isLoading = false; errorMessage = - e.message ?? 'Une erreur est survenue lors de la connexion'; + e.message ?? 'Une erreur est survenue lors de la connexion Firebase.'; notifyListeners(); } catch (e) { + // Gestion des autres erreurs potentielles (ex: erreur lors de loadUserData) isLoading = false; - errorMessage = 'Une erreur inattendue est survenue'; + errorMessage = 'Une erreur inattendue est survenue: ${e.toString()}'; notifyListeners(); } } diff --git a/em2rp/lib/views/calendar_page.dart b/em2rp/lib/views/calendar_page.dart index b83da6a..bbbec35 100644 --- a/em2rp/lib/views/calendar_page.dart +++ b/em2rp/lib/views/calendar_page.dart @@ -1,15 +1,16 @@ import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/event_provider.dart'; import 'package:flutter/material.dart'; -import 'package:em2rp/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/custom_app_bar.dart'; import 'package:em2rp/views/widgets/nav/main_drawer.dart'; import 'package:provider/provider.dart'; import 'package:table_calendar/table_calendar.dart'; import 'package:em2rp/models/event_model.dart'; -import 'package:em2rp/widgets/event_details.dart'; +import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart'; import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart'; +import 'package:em2rp/views/pages/event_add_page.dart'; class CalendarPage extends StatefulWidget { const CalendarPage({super.key}); @@ -36,9 +37,13 @@ class _CalendarPageState extends State { Provider.of(context, listen: false); final eventProvider = Provider.of(context, listen: false); final userId = localAuthProvider.uid; + print('Permissions utilisateur: ${localAuthProvider.permissions}'); + final canViewAllEvents = localAuthProvider.hasPermission('view_all_events'); + print('canViewAllEvents: $canViewAllEvents'); if (userId != null) { - await eventProvider.loadUserEvents(userId); + await eventProvider.loadUserEvents(userId, + canViewAllEvents: canViewAllEvents); } } @@ -51,6 +56,8 @@ class _CalendarPageState extends State { @override Widget build(BuildContext context) { final eventProvider = Provider.of(context); + final localUserProvider = Provider.of(context); + final isAdmin = localUserProvider.role == 'ADMIN'; final isMobile = MediaQuery.of(context).size.width < 600; if (eventProvider.isLoading) { @@ -67,6 +74,20 @@ class _CalendarPageState extends State { ), drawer: const MainDrawer(currentPage: '/calendar'), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), + floatingActionButton: isAdmin + ? FloatingActionButton( + backgroundColor: Colors.white, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const EventAddPage(), + ), + ); + }, + child: const Icon(Icons.add, color: Colors.red), + tooltip: 'Ajouter un événement', + ) + : null, ); } diff --git a/em2rp/lib/views/my_account_page.dart b/em2rp/lib/views/my_account_page.dart index 225d715..c9d4e2c 100644 --- a/em2rp/lib/views/my_account_page.dart +++ b/em2rp/lib/views/my_account_page.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/views/widgets/inputs/styled_text_field.dart'; import 'package:em2rp/views/widgets/image/profile_picture_selector.dart'; -import 'package:em2rp/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/custom_app_bar.dart'; class MyAccountPage extends StatelessWidget { const MyAccountPage({super.key}); diff --git a/em2rp/lib/views/pages/event_add_page.dart b/em2rp/lib/views/pages/event_add_page.dart new file mode 100644 index 0000000..11ae205 --- /dev/null +++ b/em2rp/lib/views/pages/event_add_page.dart @@ -0,0 +1,410 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/providers/event_provider.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:intl/intl.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart'; + +class EventAddPage extends StatefulWidget { + const EventAddPage({super.key}); + + @override + State createState() => _EventAddPageState(); +} + +class _EventAddPageState extends State { + final _formKey = GlobalKey(); + 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(); + DateTime? _startDateTime; + DateTime? _endDateTime; + bool _isLoading = false; + String? _error; + String? _success; + String? _selectedEventType; + final List _eventTypes = ['Bal', 'Mariage', 'Anniversaire']; + int _descriptionMaxLines = 3; + + @override + void initState() { + super.initState(); + _descriptionController.addListener(_handleDescriptionChange); + } + + void _handleDescriptionChange() { + final lines = '\n'.allMatches(_descriptionController.text).length + 1; + setState(() { + _descriptionMaxLines = lines.clamp(3, 6); + }); + } + + @override + void dispose() { + _nameController.dispose(); + _descriptionController.dispose(); + _priceController.dispose(); + _installationController.dispose(); + _disassemblyController.dispose(); + _latitudeController.dispose(); + _longitudeController.dispose(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate() || + _startDateTime == null || + _endDateTime == null || + _selectedEventType == null) return; + setState(() { + _isLoading = true; + _error = null; + _success = null; + }); + try { + final eventProvider = Provider.of(context, listen: false); + final newEvent = EventModel( + id: '', + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + startDateTime: _startDateTime!, + endDateTime: _endDateTime!, + price: double.tryParse(_priceController.text) ?? 0.0, + installationTime: int.tryParse(_installationController.text) ?? 0, + disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0, + eventTypeId: _selectedEventType!, + customerId: '', // à adapter si tu veux gérer les clients + address: LatLng( + double.tryParse(_latitudeController.text) ?? 0.0, + double.tryParse(_longitudeController.text) ?? 0.0, + ), + workforce: [], + ); + await eventProvider.addEvent(newEvent); + setState(() { + _success = "Événement créé avec succès !"; + }); + if (context.mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _error = "Erreur lors de la création : $e"; + }); + } finally { + setState(() { + _isLoading = false; + }); + } + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(top: 16.0, bottom: 8.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('Créer un événement'), + ), + body: Center( + child: SingleChildScrollView( + child: Card( + elevation: 6, + margin: const EdgeInsets.all(24), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.only(top: 0.0, bottom: 4.0), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + 'Informations principales', + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold), + ), + ), + ), + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom de l\'événement', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.event), + ), + validator: (v) => + v == null || v.isEmpty ? 'Champ requis' : null, + ), + const SizedBox(height: 16), + DropdownButtonFormField( + value: _selectedEventType, + items: _eventTypes + .map((type) => DropdownMenuItem( + value: type, + child: Text(type), + )) + .toList(), + onChanged: (val) => + setState(() => _selectedEventType = val), + decoration: const InputDecoration( + labelText: 'Type d\'événement', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + validator: (v) => + v == null ? 'Sélectionnez un type' : null, + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2099), + ); + 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: AbsorbPointer( + child: TextFormField( + readOnly: true, + decoration: InputDecoration( + labelText: 'Début', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.calendar_today), + suffixIcon: const Icon(Icons.edit_calendar), + ), + controller: TextEditingController( + text: _startDateTime == null + ? '' + : DateFormat('dd/MM/yyyy HH:mm') + .format(_startDateTime!), + ), + validator: (v) => _startDateTime == null + ? 'Champ requis' + : null, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: GestureDetector( + onTap: () async { + final picked = await showDatePicker( + context: context, + initialDate: _startDateTime ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2099), + ); + 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: AbsorbPointer( + child: TextFormField( + readOnly: true, + decoration: InputDecoration( + labelText: 'Fin', + border: const OutlineInputBorder(), + prefixIcon: const Icon(Icons.calendar_today), + suffixIcon: const Icon(Icons.edit_calendar), + ), + controller: TextEditingController( + text: _endDateTime == null + ? '' + : DateFormat('dd/MM/yyyy HH:mm') + .format(_endDateTime!), + ), + validator: (v) => _endDateTime == null + ? 'Champ requis' + : null, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _priceController, + decoration: const InputDecoration( + labelText: 'Prix (€)', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.euro), + ), + keyboardType: TextInputType.number, + ), + _buildSectionTitle('Détails'), + AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: BoxConstraints( + minHeight: 48, + maxHeight: 48.0 * 10, + ), + child: TextFormField( + controller: _descriptionController, + minLines: 1, + maxLines: _descriptionMaxLines > 10 + ? 10 + : _descriptionMaxLines, + decoration: const InputDecoration( + labelText: 'Description', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.description), + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: IntStepperField( + label: 'Installation (h)', + controller: _installationController, + min: 0, + max: 24, + ), + ), + const SizedBox(width: 16), + Expanded( + child: IntStepperField( + label: 'Démontage (h)', + controller: _disassemblyController, + min: 0, + max: 24, + ), + ), + ], + ), + _buildSectionTitle('Localisation'), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _latitudeController, + decoration: const InputDecoration( + labelText: 'Latitude', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on), + ), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _longitudeController, + decoration: const InputDecoration( + labelText: 'Longitude', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.location_on), + ), + keyboardType: TextInputType.number, + ), + ), + ], + ), + 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)), + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: _isLoading + ? null + : () => Navigator.of(context).pop(), + child: const Text('Annuler'), + ), + const SizedBox(width: 8), + ElevatedButton.icon( + icon: const Icon(Icons.check), + onPressed: _isLoading ? null : _submit, + label: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Créer'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/em2rp/lib/views/user_management_page.dart b/em2rp/lib/views/user_management_page.dart index c5cd43b..6257991 100644 --- a/em2rp/lib/views/user_management_page.dart +++ b/em2rp/lib/views/user_management_page.dart @@ -8,7 +8,7 @@ import 'package:em2rp/views/widgets/user_management/edit_user_dialog.dart'; import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/permission_gate.dart'; import 'package:em2rp/models/role_model.dart'; -import 'package:em2rp/widgets/custom_app_bar.dart'; +import 'package:em2rp/views/widgets/custom_app_bar.dart'; class UserManagementPage extends StatefulWidget { const UserManagementPage({super.key}); @@ -31,7 +31,7 @@ class _UserManagementPageState extends State { @override Widget build(BuildContext context) { return PermissionGate( - requiredPermissions: const [Permission.viewUsers], + requiredPermissions: const ['view_all_users'], fallback: const Scaffold( appBar: CustomAppBar( title: 'Accès refusé', @@ -110,7 +110,7 @@ class _UserManagementPageState extends State { final lastNameController = TextEditingController(); final emailController = TextEditingController(); final phoneController = TextEditingController(); - String selectedRole = Roles.values.first.name; + String selectedRole = 'ADMIN'; InputDecoration buildInputDecoration(String label, IconData icon) { return InputDecoration( @@ -188,10 +188,10 @@ class _UserManagementPageState extends State { value: selectedRole, decoration: buildInputDecoration( 'Rôle', Icons.admin_panel_settings_outlined), - items: Roles.values.map((Role role) { + items: ['ADMIN', 'CREW'].map((String role) { return DropdownMenuItem( - value: role.name, - child: Text(role.name), + value: role, + child: Text(role), ); }).toList(), onChanged: (String? newValue) { @@ -247,7 +247,7 @@ class _UserManagementPageState extends State { final scaffoldMessenger = ScaffoldMessenger.of(context); await Provider.of(context, listen: false) - .createUserWithEmailInvite(newUser); + .createUserWithEmailInvite(context, newUser); if (context.mounted) { Navigator.pop(context); diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart new file mode 100644 index 0000000..f8a88c1 --- /dev/null +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -0,0 +1,420 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/event_model.dart'; +import 'package:em2rp/utils/colors.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:latlong2/latlong.dart'; + +class EventDetails extends StatelessWidget { + final EventModel event; + final DateTime? selectedDate; + final List 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) { + final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); + final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); + final fullDateFormat = DateFormat('EEEE d MMMM y', 'fr_FR'); + // Trie les événements par date de début + final sortedEvents = List.from(events) + ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); + final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id); + final localUserProvider = Provider.of(context); + final isAdmin = localUserProvider.role == 'ADMIN'; + + return Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + onPressed: currentIndex > 0 + ? () { + final prevEvent = sortedEvents[currentIndex - 1]; + onSelectEvent(prevEvent, prevEvent.startDateTime); + } + : null, + icon: const Icon(Icons.arrow_back), + color: AppColors.rouge, + ), + if (selectedDate != null) + Expanded( + child: Center( + child: Text( + fullDateFormat.format(selectedDate!), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.rouge, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + IconButton( + onPressed: currentIndex < sortedEvents.length - 1 + ? () { + final nextEvent = sortedEvents[currentIndex + 1]; + onSelectEvent(nextEvent, nextEvent.startDateTime); + } + : null, + icon: const Icon(Icons.arrow_forward), + color: AppColors.rouge, + ), + ], + ), + const SizedBox(height: 16), + Text( + event.name, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + _buildInfoRow( + context, + Icons.calendar_today, + 'Date de début', + dateFormat.format(event.startDateTime), + ), + _buildInfoRow( + context, + Icons.calendar_today, + 'Date de fin', + dateFormat.format(event.endDateTime), + ), + _buildInfoRow( + context, + Icons.euro, + 'Prix', + currencyFormat.format(event.price), + ), + _buildInfoRow( + context, + Icons.build, + 'Temps d\'installation', + '${event.installationTime} heures', + ), + _buildInfoRow( + context, + Icons.construction, + 'Temps de démontage', + '${event.disassemblyTime} heures', + ), + const SizedBox(height: 16), + Text( + 'Description', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + event.description, + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 16), + Text( + 'Adresse', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + '${event.address.latitude}° N, ${event.address.longitude}° E', + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow( + BuildContext context, + IconData icon, + String label, + String value, + ) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Icon(icon, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + '$label : ', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: AppColors.noir, + fontWeight: FontWeight.bold, + ), + ), + Text( + value, + style: Theme.of(context).textTheme.titleMedium, + ), + ], + ), + ); + } +} + +class EventAddDialog extends StatefulWidget { + const EventAddDialog({super.key}); + + @override + State createState() => _EventAddDialogState(); +} + +class _EventAddDialogState extends State { + final _formKey = GlobalKey(); + 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(); + 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(); + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate() || + _startDateTime == null || + _endDateTime == null) return; + setState(() { + _isLoading = true; + _error = null; + _success = null; + }); + try { + final eventProvider = Provider.of(context, listen: false); + final newEvent = EventModel( + id: '', + name: _nameController.text.trim(), + description: _descriptionController.text.trim(), + startDateTime: _startDateTime!, + endDateTime: _endDateTime!, + price: 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: LatLng( + double.tryParse(_latitudeController.text) ?? 0.0, + double.tryParse(_longitudeController.text) ?? 0.0, + ), + workforce: [], + ); + 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, + ), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _latitudeController, + decoration: const InputDecoration(labelText: 'Latitude'), + keyboardType: TextInputType.number, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _longitudeController, + decoration: const InputDecoration(labelText: 'Longitude'), + keyboardType: TextInputType.number, + ), + ), + ], + ), + 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'), + ), + ], + ); + } +} diff --git a/em2rp/lib/widgets/custom_app_bar.dart b/em2rp/lib/views/widgets/custom_app_bar.dart similarity index 100% rename from em2rp/lib/widgets/custom_app_bar.dart rename to em2rp/lib/views/widgets/custom_app_bar.dart diff --git a/em2rp/lib/views/widgets/inputs/int_stepper_field.dart b/em2rp/lib/views/widgets/inputs/int_stepper_field.dart new file mode 100644 index 0000000..8f22af8 --- /dev/null +++ b/em2rp/lib/views/widgets/inputs/int_stepper_field.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; + +class IntStepperField extends StatelessWidget { + final String label; + final TextEditingController controller; + final int min; + final int max; + + const IntStepperField({ + super.key, + required this.label, + required this.controller, + this.min = 0, + this.max = 24, + }); + + @override + Widget build(BuildContext context) { + int value = int.tryParse(controller.text) ?? 0; + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 2.0), + child: Text( + label, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 13, + color: Colors.black87, + fontWeight: FontWeight.w500), + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 20), + splashRadius: 18, + onPressed: value > min + ? () { + value--; + controller.text = value.toString(); + (context as Element).markNeedsBuild(); + } + : null, + ), + SizedBox( + width: 60, + height: 36, + child: TextFormField( + controller: controller, + keyboardType: TextInputType.number, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 15), + decoration: const InputDecoration( + border: OutlineInputBorder(), + contentPadding: + EdgeInsets.symmetric(vertical: 6, horizontal: 6), + ), + onChanged: (val) { + int? v = int.tryParse(val); + if (v == null || v < min) { + controller.text = min.toString(); + } else if (v > max) { + controller.text = max.toString(); + } + }, + ), + ), + IconButton( + icon: const Icon(Icons.add_circle_outline, size: 20), + splashRadius: 18, + onPressed: value < max + ? () { + value++; + controller.text = value.toString(); + (context as Element).markNeedsBuild(); + } + : null, + ), + ], + ), + ], + ); + } +} diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index 1533af7..20c8ede 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -117,7 +117,7 @@ class MainDrawer extends StatelessWidget { }, ), PermissionGate( - requiredPermissions: const [Permission.viewUsers], + requiredPermissions: const ['view_all_users'], child: ListTile( leading: const Icon(Icons.group), title: const Text('Gestion des Utilisateurs'), diff --git a/em2rp/lib/widgets/event_details.dart b/em2rp/lib/widgets/event_details.dart deleted file mode 100644 index d7a6eba..0000000 --- a/em2rp/lib/widgets/event_details.dart +++ /dev/null @@ -1,172 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:em2rp/models/event_model.dart'; -import 'package:em2rp/utils/colors.dart'; -import 'package:intl/intl.dart'; - -class EventDetails extends StatelessWidget { - final EventModel event; - final DateTime? selectedDate; - final List 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) { - final dateFormat = DateFormat('dd/MM/yyyy HH:mm'); - final currencyFormat = NumberFormat.currency(locale: 'fr_FR', symbol: '€'); - final fullDateFormat = DateFormat('EEEE d MMMM y', 'fr_FR'); - // Trie les événements par date de début - final sortedEvents = List.from(events) - ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - final currentIndex = sortedEvents.indexWhere((e) => e.id == event.id); - - return Card( - margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - onPressed: currentIndex > 0 - ? () { - final prevEvent = sortedEvents[currentIndex - 1]; - onSelectEvent(prevEvent, prevEvent.startDateTime); - } - : null, - icon: const Icon(Icons.arrow_back), - color: AppColors.rouge, - ), - if (selectedDate != null) - Expanded( - child: Center( - child: Text( - fullDateFormat.format(selectedDate!), - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.rouge, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - IconButton( - onPressed: currentIndex < sortedEvents.length - 1 - ? () { - final nextEvent = sortedEvents[currentIndex + 1]; - onSelectEvent(nextEvent, nextEvent.startDateTime); - } - : null, - icon: const Icon(Icons.arrow_forward), - color: AppColors.rouge, - ), - ], - ), - const SizedBox(height: 16), - Text( - event.name, - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 16), - _buildInfoRow( - context, - Icons.calendar_today, - 'Date de début', - dateFormat.format(event.startDateTime), - ), - _buildInfoRow( - context, - Icons.calendar_today, - 'Date de fin', - dateFormat.format(event.endDateTime), - ), - _buildInfoRow( - context, - Icons.euro, - 'Prix', - currencyFormat.format(event.price), - ), - _buildInfoRow( - context, - Icons.build, - 'Temps d\'installation', - '${event.installationTime} heures', - ), - _buildInfoRow( - context, - Icons.construction, - 'Temps de démontage', - '${event.disassemblyTime} heures', - ), - const SizedBox(height: 16), - Text( - 'Description', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - event.description, - style: Theme.of(context).textTheme.bodyLarge, - ), - const SizedBox(height: 16), - Text( - 'Adresse', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 8), - Text( - '${event.address.latitude}° N, ${event.address.longitude}° E', - style: Theme.of(context).textTheme.bodyLarge, - ), - ], - ), - ), - ); - } - - Widget _buildInfoRow( - BuildContext context, - IconData icon, - String label, - String value, - ) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - Icon(icon, color: AppColors.rouge), - const SizedBox(width: 8), - Text( - '$label : ', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: AppColors.noir, - fontWeight: FontWeight.bold, - ), - ), - Text( - value, - style: Theme.of(context).textTheme.titleMedium, - ), - ], - ), - ); - } -}