diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0fd43e2..b24b47c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -25,11 +25,11 @@ - - - - - + + + + + diff --git a/lib/core/blocs/session/session_cubit.dart b/lib/core/blocs/session/session_cubit.dart index ec5f7c1..b21d693 100644 --- a/lib/core/blocs/session/session_cubit.dart +++ b/lib/core/blocs/session/session_cubit.dart @@ -39,8 +39,35 @@ class SessionCubit extends Cubit { } try { + // 1. CHI È QUESTO UTENTE? (Vediamo se ha un profilo staff, che sia Invitato o Admin) + StaffMemberModel? staff = await _repository.getStaffMemberByUserId( + user.id, + ); + CompanyModel? company; + if (staff != null) { + // --- LA MAGIA DEL SENSORE --- + if (staff.hasJoined == false) { + // È la primissima volta che entra! Aggiorniamo il DB. + await _repository.updateStaffMember(staff.id!, {'has_joined': true}); + // Aggiorniamo anche il nostro modello in memoria per questa sessione + staff = staff.copyWith(hasJoined: true); + } + + company = await _repository.getCompanyById(staff.companyId); + } else { + // È l'Admin in onboarding + company = await _repository.getCompanyByOwnerId(user.id); + } // 1. Controllo Azienda - final company = await _repository.getCompanyByOwnerId(user.id); + + if (staff != null) { + // L'utente esiste già nel sistema! Carichiamo l'azienda per cui lavora + company = await _repository.getCompanyById(staff.companyId); + } else { + // L'utente non ha profilo. Probabilmente è l'Admin che ha appena + // fatto Sign Up e sta iniziando l'Onboarding + company = await _repository.getCompanyByOwnerId(user.id); + } if (company == null) { return emit( state.copyWith( @@ -69,7 +96,6 @@ class SessionCubit extends Cubit { } // 3. Controllo Staff (Paziente Zero) - final staff = await _repository.getStaffMemberByUserId(user.id); if (staff == null) { return emit( state.copyWith( @@ -102,7 +128,7 @@ class SessionCubit extends Cubit { user: user, company: company, currentStore: activeStore, - currentStaff: staff, + currentStaffMember: staff, onboardingStep: OnboardingStep.none, // Svuotiamo l'onboarding ), ); diff --git a/lib/core/blocs/session/session_state.dart b/lib/core/blocs/session/session_state.dart index b215dcb..0bc69b9 100644 --- a/lib/core/blocs/session/session_state.dart +++ b/lib/core/blocs/session/session_state.dart @@ -22,7 +22,7 @@ class SessionState extends Equatable { final User? user; // Utente di Supabase Auth final CompanyModel? company; final StoreModel? currentStore; - final StaffMemberModel? currentStaff; + final StaffMemberModel? currentStaffMember; final OnboardingStep onboardingStep; final bool isMobileDevice; @@ -31,7 +31,7 @@ class SessionState extends Equatable { this.user, this.company, this.currentStore, - this.currentStaff, + this.currentStaffMember, this.onboardingStep = OnboardingStep.none, this.isMobileDevice = false, }); @@ -42,7 +42,7 @@ class SessionState extends Equatable { User? user, CompanyModel? company, StoreModel? currentStore, - StaffMemberModel? currentStaff, + StaffMemberModel? currentStaffMember, OnboardingStep? onboardingStep, bool? isMobileDevice, }) { @@ -51,7 +51,7 @@ class SessionState extends Equatable { user: user ?? this.user, company: company ?? this.company, currentStore: currentStore ?? this.currentStore, - currentStaff: currentStaff ?? this.currentStaff, + currentStaffMember: currentStaffMember ?? this.currentStaffMember, onboardingStep: onboardingStep ?? this.onboardingStep, isMobileDevice: isMobileDevice ?? this.isMobileDevice, ); @@ -63,7 +63,7 @@ class SessionState extends Equatable { user, company, currentStore, - currentStaff, + currentStaffMember, onboardingStep, isMobileDevice, ]; diff --git a/lib/core/data/constants.dart b/lib/core/data/constants.dart new file mode 100644 index 0000000..316b798 --- /dev/null +++ b/lib/core/data/constants.dart @@ -0,0 +1,2 @@ +const String resetPasswordUrl = + 'https://flux-web-invite.marco-6ba.workers.dev/'; diff --git a/lib/core/data/core_repository.dart b/lib/core/data/core_repository.dart index eebec0f..192e412 100644 --- a/lib/core/data/core_repository.dart +++ b/lib/core/data/core_repository.dart @@ -28,6 +28,21 @@ class CoreRepository { } } + Future getCompanyById(String companyId) async { + try { + final response = await _supabase + .from('company') + .select() + .eq('id', companyId) + .maybeSingle(); + if (response == null) return null; + return CompanyModel.fromMap(response); + } catch (e) { + debugPrint('Errore recupero azienda per ID: $e'); + return null; + } + } + Future> getStoresByCompanyId(String companyId) async { try { final response = await _supabase @@ -108,4 +123,19 @@ class CoreRepository { throw Exception('Creazione profilo staff fallita: $e'); } } + + // Assegna un membro a un negozio + Future assignStaffToStore(String staffId, String storeId) async { + await _supabase.from('staff_in_stores').insert({ + 'staff_member_id': staffId, + 'store_id': storeId, + }); + } + + Future updateStaffMember( + String staffId, + Map data, + ) async { + await _supabase.from('staff_member').update(data).eq('id', staffId); + } } diff --git a/lib/core/routes/app_router.dart b/lib/core/routes/app_router.dart index e7eb138..ea2c5d3 100644 --- a/lib/core/routes/app_router.dart +++ b/lib/core/routes/app_router.dart @@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; // Importa il tuo SessionCubit e lo State import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/data/core_repository.dart'; +import 'package:flux/core/widgets/set_password_screen.dart'; import 'package:flux/features/customers/ui/customer_mobile_upload_screen.dart'; import 'package:flux/features/auth/ui/auth_screen.dart'; import 'package:flux/features/customers/blocs/customer_files_bloc.dart'; @@ -33,6 +34,7 @@ class AppRouter { final sessionState = sessionCubit.state; final isGoingToLogin = state.matchedLocation == '/login'; final isGoingToOnboarding = state.matchedLocation == '/onboarding'; + final isGoingToSetPassword = state.matchedLocation == '/set-password'; // Caso 1: L'app si sta ancora avviando. // Restituiamo null per farlo rimanere sulla SplashScreen del main.dart @@ -43,7 +45,8 @@ class AppRouter { // Caso 2: Utente NON loggato. if (sessionState.status == SessionStatus.unauthenticated) { // Se sta già andando al login, lascialo andare. Altrimenti, forzalo al login. - return isGoingToLogin ? null : '/login'; + if (isGoingToLogin || isGoingToSetPassword) return null; + return '/login'; } // Caso 3: Utente loggato MA manca un pezzo dell'azienda (Flusso Canalizzatore) @@ -55,13 +58,13 @@ class AppRouter { // Caso 4: Utente loggato e configurato (Tutto OK!) if (sessionState.status == SessionStatus.authenticated) { - // Se per sbaglio cerca di tornare al login o all'onboarding, - // lo rimbalziamo alla home. + // Attenzione: un utente appena invitato viene considerato "loggato" + // da Supabase appena clicca il link. Quindi se sta andando su /set-password, + // dobbiamo permetterglielo e non rimbalzarlo! if (isGoingToLogin || isGoingToOnboarding) { return '/'; } - // Per tutte le altre rotte (dashboard, clienti, anagrafiche), lascialo passare. - return null; + return null; // Lascia passare per /, /customer, e anche /set-password } return null; @@ -72,6 +75,10 @@ class AppRouter { //builder: (context, state) => const LoginScreen(), builder: (context, state) => const AuthScreen(), ), + GoRoute( + path: '/set-password', + builder: (context, state) => const SetPasswordScreen(), + ), GoRoute( path: '/onboarding', builder: (context, state) => BlocProvider( diff --git a/lib/core/widgets/flux_text_field.dart b/lib/core/widgets/flux_text_field.dart index a9bafae..f2cbf24 100644 --- a/lib/core/widgets/flux_text_field.dart +++ b/lib/core/widgets/flux_text_field.dart @@ -20,6 +20,7 @@ class FluxTextField extends StatefulWidget { final List? inputFormatters; final TextCapitalization? textCapitalization; final bool? autocorrect; + final bool? enabled; const FluxTextField({ super.key, // Usiamo super.key per Flutter moderno @@ -39,6 +40,7 @@ class FluxTextField extends StatefulWidget { this.inputFormatters, this.textCapitalization, this.autocorrect, + this.enabled = true, }); @override @@ -115,6 +117,7 @@ class _FluxTextFieldState extends State { inputFormatters: widget.inputFormatters, textCapitalization: widget.textCapitalization ?? TextCapitalization.none, + enabled: widget.enabled, ); } } diff --git a/lib/core/widgets/set_password_screen.dart b/lib/core/widgets/set_password_screen.dart new file mode 100644 index 0000000..33e54b9 --- /dev/null +++ b/lib/core/widgets/set_password_screen.dart @@ -0,0 +1,121 @@ +import 'package:flutter/material.dart'; +import 'package:flux/core/widgets/flux_text_field.dart'; +import 'package:get_it/get_it.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:go_router/go_router.dart'; + +class SetPasswordScreen extends StatefulWidget { + const SetPasswordScreen({super.key}); + + @override + State createState() => _SetPasswordScreenState(); +} + +class _SetPasswordScreenState extends State { + final _passwordCtrl = TextEditingController(); + bool _isLoading = false; + + @override + void dispose() { + _passwordCtrl.dispose(); + super.dispose(); + } + + Future _savePassword() async { + final newPassword = _passwordCtrl.text.trim(); + if (newPassword.length < 6) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("La password deve avere almeno 6 caratteri"), + ), + ); + return; + } + + setState(() => _isLoading = true); + + try { + // 1. Aggiorniamo la password dell'utente (che Supabase ha già loggato grazie al link della mail) + await GetIt.I.get().auth.updateUser( + UserAttributes(password: newPassword), + ); + + // 2. Finito! Lo mandiamo alla home o facciamo ricaricare la sessione al SessionCubit + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("Password impostata! Benvenuto a bordo 🚀"), + ), + ); + context.go('/'); // Rimandiamo al router principale + } + } on AuthException catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Errore Auth: ${e.message}"))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text("Errore: $e"))); + } + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("Benvenuto in FLUX!"), + automaticallyImplyLeading: + false, // Non può tornare indietro, deve mettere la password! + ), + body: Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.lock_reset, size: 80, color: Colors.blueAccent), + const SizedBox(height: 24), + const Text( + "Imposta la tua Password", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + "Hai accettato l'invito. Scegli una password sicura per accedere in futuro.", + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey), + ), + const SizedBox(height: 32), + FluxTextField( + controller: _passwordCtrl, + label: "Nuova Password", + icon: Icons.lock, + isPassword: true, + ), + const SizedBox(height: 32), + ElevatedButton( + onPressed: _isLoading ? null : _savePassword, + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + child: _isLoading + ? const CircularProgressIndicator(color: Colors.white) + : const Text( + "SALVA E INIZIA", + style: TextStyle(fontSize: 16), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/auth/bloc/auth_cubit.dart b/lib/features/auth/bloc/auth_cubit.dart index 6f062fe..53411e0 100644 --- a/lib/features/auth/bloc/auth_cubit.dart +++ b/lib/features/auth/bloc/auth_cubit.dart @@ -1,6 +1,7 @@ import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; +import 'package:flux/core/data/constants.dart'; import 'package:get_it/get_it.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; part 'auth_state.dart'; @@ -64,6 +65,28 @@ class AuthCubit extends Cubit { } } + Future requestPasswordReset(String email) async { + if (email.isEmpty) { + emit( + state.copyWith( + status: AuthStatus.failure, + errorMessage: 'Devi inserire l\'indirizzo email', + ), + ); + return; + } + await _supabase.auth.resetPasswordForEmail( + email, + redirectTo: resetPasswordUrl, + ); + emit( + state.copyWith( + status: AuthStatus.pwResetSent, + infoMessage: "Email per reset password inviata a $email!", + ), + ); + } + Future requestLogout() async { await _supabase.auth.signOut(); emit(state.copyWith(status: AuthStatus.initial)); diff --git a/lib/features/auth/bloc/auth_state.dart b/lib/features/auth/bloc/auth_state.dart index e491094..f3c237e 100644 --- a/lib/features/auth/bloc/auth_state.dart +++ b/lib/features/auth/bloc/auth_state.dart @@ -1,6 +1,6 @@ part of 'auth_cubit.dart'; -enum AuthStatus { initial, loading, failure } +enum AuthStatus { initial, pwResetSent, loading, failure } class AuthState extends Equatable { final AuthStatus status; diff --git a/lib/features/auth/ui/auth_screen.dart b/lib/features/auth/ui/auth_screen.dart index 00543d9..f6327d8 100644 --- a/lib/features/auth/ui/auth_screen.dart +++ b/lib/features/auth/ui/auth_screen.dart @@ -162,6 +162,21 @@ class _AuthScreenState extends State { ), ), ), + if (state.isLoginMode) ...[ + const SizedBox(height: 24), + TextButton( + onPressed: () => context + .read() + .requestPasswordReset(_emailController.text.trim()), + child: Text( + 'Pw dimenticata/Invito scaduto?', + style: TextStyle( + color: context.accent, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ], ), ), diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart index 9c37a93..548addb 100644 --- a/lib/features/master_data/staff/blocs/staff_cubit.dart +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -16,7 +16,7 @@ class StaffCubit extends Cubit { // Carica tutto lo staff della compagnia Future loadAllStaff() async { - emit(state.copyWith(isLoading: true, error: null)); + emit(state.copyWith(status: StaffStatus.loading, error: null)); try { final staff = await _repository.getStaffMembers( _sessionCubit.state.company!.id!, @@ -27,18 +27,19 @@ class StaffCubit extends Cubit { } emit( state.copyWith( + status: StaffStatus.success, allStaff: staff, - isLoading: false, storesByStaff: storesByStaff, ), ); } catch (e) { - emit(state.copyWith(isLoading: false, error: e.toString())); + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); } } Future> loadStoresByStaff(String staffId) async { try { + emit(state.copyWith(error: null)); return await _repository.getStaffMemberStore(staffId); } catch (e) { emit(state.copyWith(error: e.toString())); @@ -48,6 +49,7 @@ class StaffCubit extends Cubit { // Carica lo staff di uno specifico negozio e aggiorna la mappa Future loadStaffForStore(String storeId) async { + emit(state.copyWith(error: null)); try { final staffInStore = await _repository.getStaffMembersInStore(storeId); final newMap = Map>.from( @@ -56,48 +58,87 @@ class StaffCubit extends Cubit { newMap[storeId] = staffInStore; emit(state.copyWith(staffByStore: newMap)); } catch (e) { - // Qui potresti gestire l'errore silenziosamente per non bloccare tutta l'UI + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); } } // Salva o aggiorna un membro Future saveStaffMember(StaffMemberModel member) async { - emit(state.copyWith(isLoading: true)); + emit(state.copyWith(status: StaffStatus.loading, error: null)); try { await _repository.saveStaffMember(member); await loadAllStaff(); // Ricarichiamo la lista aggiornata } catch (e) { - emit(state.copyWith(isLoading: false, error: e.toString())); + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); + } + } + + Future inviteStaffMember({ + required StaffMemberModel member, + required List selectedStoreIds, + }) async { + emit(state.copyWith(status: StaffStatus.loading, error: null)); + + try { + // 1. Invitiamo il membro e ci facciamo dare l'ID + final newStaffId = await _repository.inviteStaffMember(member); + + // 2. Assegniamo i negozi uno ad uno (usando il metodo che avevi già nel repo!) + if (selectedStoreIds.isNotEmpty) { + final List assignTasks = []; + for (var storeId in selectedStoreIds) { + assignTasks.add(_repository.assignStaffToStore(newStaffId, storeId)); + } + await Future.wait(assignTasks); // In parallelo per la massima velocità + } + + // 3. Ricarichiamo la lista globale così la UI si aggiorna + await loadAllStaff(); + // (Nota: se hai un loadStaffForStore o loadAllStaff, chiamalo qui per rinfrescare lo stato) + + emit(state.copyWith(status: StaffStatus.success)); + } catch (e) { + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); } } // Associa un dipendente a un negozio Future assignMemberToStore(String staffId, String storeId) async { try { - await _repository.assignToStore(staffId, storeId); + await _repository.assignStaffToStore(staffId, storeId); final stuffStores = await loadStoresByStaff(staffId); final Map> storesByStaff = Map.from( state.storesByStaff, ); storesByStaff[staffId] = stuffStores; - emit(state.copyWith(storesByStaff: storesByStaff)); + emit(state.copyWith(storesByStaff: storesByStaff, error: null)); } catch (e) { - emit(state.copyWith(error: "Errore nell'assegnazione: $e")); + emit( + state.copyWith( + status: StaffStatus.error, + error: "Errore nell'assegnazione: $e", + ), + ); } } // Rimuove un dipendente da un negozio Future removeMemberFromStore(String staffId, String storeId) async { try { - await _repository.removeFromStore(staffId, storeId); + await _repository.removeStaffFromStore(staffId, storeId); final stuffStores = await loadStoresByStaff(staffId); final Map> storesByStaff = Map.from( state.storesByStaff, ); storesByStaff[staffId] = stuffStores; - emit(state.copyWith(storesByStaff: storesByStaff)); + emit(state.copyWith(storesByStaff: storesByStaff, error: null)); } catch (e) { - emit(state.copyWith(error: "Errore nella rimozione: $e")); + emit( + state.copyWith( + status: StaffStatus.error, + error: "Errore nella rimozione: $e", + ), + ); } } @@ -105,7 +146,7 @@ class StaffCubit extends Cubit { required StaffMemberModel member, required List selectedStoreIds, }) async { - emit(state.copyWith(isLoading: true)); + emit(state.copyWith(status: StaffStatus.loading, error: null)); try { // 1. Salva o aggiorna l'anagrafica (ci serve l'ID) // Se è un nuovo membro, Supabase ci restituirà l'ID generato @@ -120,7 +161,7 @@ class StaffCubit extends Cubit { if (selectedStoreIds.isNotEmpty) { await Future.wait( selectedStoreIds.map( - (storeId) => _repository.assignToStore(staffId, storeId), + (storeId) => _repository.assignStaffToStore(staffId, storeId), ), ); } @@ -128,9 +169,9 @@ class StaffCubit extends Cubit { // 3. Rinfresca i dati await loadAllStaff(); - emit(state.copyWith(isLoading: false)); + emit(state.copyWith(status: StaffStatus.success)); } catch (e) { - emit(state.copyWith(isLoading: false, error: e.toString())); + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); } } } diff --git a/lib/features/master_data/staff/blocs/staff_state.dart b/lib/features/master_data/staff/blocs/staff_state.dart index a8fbfd4..5ef9055 100644 --- a/lib/features/master_data/staff/blocs/staff_state.dart +++ b/lib/features/master_data/staff/blocs/staff_state.dart @@ -1,42 +1,44 @@ part of 'staff_cubit.dart'; +enum StaffStatus { initial, loading, success, error } + class StaffState extends Equatable { + final StaffStatus status; final List allStaff; final Map> storesByStaff; final Map> staffByStore; - final bool isLoading; final String? error; const StaffState({ + this.status = StaffStatus.initial, this.allStaff = const [], this.storesByStaff = const {}, this.staffByStore = const {}, - this.isLoading = false, this.error, }); StaffState copyWith({ + StaffStatus? status, List? allStaff, Map>? storesByStaff, Map>? staffByStore, - bool? isLoading, String? error, }) { return StaffState( + status: status ?? this.status, allStaff: allStaff ?? this.allStaff, storesByStaff: storesByStaff ?? this.storesByStaff, staffByStore: staffByStore ?? this.staffByStore, - isLoading: isLoading ?? this.isLoading, error: error, ); } @override List get props => [ + status, allStaff, storesByStaff, staffByStore, - isLoading, error, ]; } diff --git a/lib/features/master_data/staff/data/staff_repository.dart b/lib/features/master_data/staff/data/staff_repository.dart index 89032c2..ec35c7c 100644 --- a/lib/features/master_data/staff/data/staff_repository.dart +++ b/lib/features/master_data/staff/data/staff_repository.dart @@ -29,6 +29,37 @@ class StaffRepository { return StaffMemberModel.fromMap(response); } + // --- LOGICA DI INVITO (Tramite Edge Function) --- + + Future inviteStaffMember(StaffMemberModel newMember) async { + if (newMember.email == null || newMember.email!.isEmpty) { + throw Exception( + "L'indirizzo email è obbligatorio per invitare un collega.", + ); + } + + try { + final response = await _supabase.functions.invoke( + 'invite_staff', + body: newMember.toMap(), + ); + + if (response.status != 200) { + throw Exception("Errore dal server: ${response.data}"); + } + + // La funzione ci restituisce l'ID fresco di database! + final responseData = response.data as Map; + return responseData['user_id'] as String; + } on FunctionException catch (e) { + throw Exception( + "Errore di comunicazione con il server: ${e.reasonPhrase}", + ); + } catch (e) { + throw Exception("Impossibile invitare il collega: $e"); + } + } + // --- LOGICA DI GIUNZIONE (Staff <-> Store) --- // Recupera i membri assegnati a uno specifico negozio @@ -62,7 +93,7 @@ class StaffRepository { } // Assegna un membro a un negozio - Future assignToStore(String staffId, String storeId) async { + Future assignStaffToStore(String staffId, String storeId) async { await _supabase.from('staff_in_stores').insert({ 'staff_member_id': staffId, 'store_id': storeId, @@ -70,7 +101,7 @@ class StaffRepository { } // Rimuove l'assegnazione - Future removeFromStore(String staffId, String storeId) async { + Future removeStaffFromStore(String staffId, String storeId) async { await _supabase .from('staff_in_stores') .delete() diff --git a/lib/features/master_data/staff/models/staff_member_model.dart b/lib/features/master_data/staff/models/staff_member_model.dart index 1368716..ac3b3d3 100644 --- a/lib/features/master_data/staff/models/staff_member_model.dart +++ b/lib/features/master_data/staff/models/staff_member_model.dart @@ -25,6 +25,7 @@ class StaffMemberModel extends Equatable { final String? jobTitle; final SystemRole systemRole; final bool isActive; + final bool hasJoined; const StaffMemberModel({ this.id, @@ -36,6 +37,7 @@ class StaffMemberModel extends Equatable { this.jobTitle, this.systemRole = SystemRole.user, this.isActive = true, + this.hasJoined = false, }); StaffMemberModel copyWith({ @@ -49,6 +51,7 @@ class StaffMemberModel extends Equatable { String? jobTitle, SystemRole? systemRole, bool? isActive, + bool? hasJoined, }) { return StaffMemberModel( id: id ?? this.id, @@ -60,6 +63,7 @@ class StaffMemberModel extends Equatable { jobTitle: jobTitle ?? this.jobTitle, systemRole: systemRole ?? this.systemRole, isActive: isActive ?? this.isActive, + hasJoined: hasJoined ?? this.hasJoined, ); } @@ -71,8 +75,6 @@ class StaffMemberModel extends Equatable { email: '', phoneNumber: '', jobTitle: '', - systemRole: SystemRole.user, - isActive: true, ); } @@ -87,6 +89,7 @@ class StaffMemberModel extends Equatable { jobTitle: map['job_title'] as String?, systemRole: SystemRole.fromString(map['system_role']), isActive: map['is_active'] ?? true, + hasJoined: map['has_joined'] ?? false, ); } @@ -101,6 +104,7 @@ class StaffMemberModel extends Equatable { if (jobTitle != null) 'job_title': jobTitle, 'system_role': systemRole.name, // Trasforma SystemRole.admin in 'admin' 'is_active': isActive, + 'has_joined': hasJoined, }; } @@ -115,5 +119,6 @@ class StaffMemberModel extends Equatable { jobTitle, systemRole, isActive, + hasJoined, ]; } diff --git a/lib/features/master_data/staff/ui/staff_screen.dart b/lib/features/master_data/staff/ui/staff_screen.dart index 86553e8..5d1c774 100644 --- a/lib/features/master_data/staff/ui/staff_screen.dart +++ b/lib/features/master_data/staff/ui/staff_screen.dart @@ -28,6 +28,14 @@ class _StaffScreenState extends State { @override Widget build(BuildContext context) { + // 1. Peschiamo chi siamo noi e che poteri abbiamo + final myRole = context + .read() + .state + .currentStaffMember + ?.systemRole; + final canManageStaff = + myRole == SystemRole.admin || myRole == SystemRole.manager; return Scaffold( backgroundColor: context.background, appBar: AppBar( @@ -45,52 +53,66 @@ class _StaffScreenState extends State { ), ], ), - body: Column( - children: [ - // --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') --- - AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _showAllCompanyStaff ? 0 : 80, - child: _showAllCompanyStaff - ? const SizedBox() - : _buildStoreSelector(), - ), - - // --- LISTA PERSONALE --- - Expanded( - child: BlocBuilder( - builder: (context, state) { - final list = _showAllCompanyStaff - ? state.allStaff - : (state.staffByStore[_selectedStoreId] ?? []); - - if (state.isLoading && list.isEmpty) { - return const Center(child: CircularProgressIndicator()); - } - - if (list.isEmpty) { - return _buildEmptyState(); - } - - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: list.length, - separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (context, index) { - final member = list[index]; - return _buildStaffCard(member); - }, - ); - }, + body: BlocListener( + listener: (context, state) { + if (state.status == StaffStatus.error) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.error ?? 'Errore sconosciuto'), + backgroundColor: Colors.red, + ), + ); + } + }, + child: Column( + children: [ + // --- BARRA FILTRO NEGOZIO (Visibile solo se non 'Tutta l'Azienda') --- + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _showAllCompanyStaff ? 0 : 80, + child: _showAllCompanyStaff + ? const SizedBox() + : _buildStoreSelector(), ), - ), - ], - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () => _openStaffForm(context), - label: const Text("Aggiungi"), - icon: const Icon(Icons.person_add_alt_1), + + // --- LISTA PERSONALE --- + Expanded( + child: BlocBuilder( + builder: (context, state) { + final list = _showAllCompanyStaff + ? state.allStaff + : (state.staffByStore[_selectedStoreId] ?? []); + + if (state.status == StaffStatus.loading && list.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + if (list.isEmpty) { + return _buildEmptyState(); + } + + return ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: list.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final member = list[index]; + return _buildStaffCard(member); + }, + ); + }, + ), + ), + ], + ), ), + floatingActionButton: canManageStaff + ? FloatingActionButton.extended( + onPressed: () => _openStaffForm(context), + label: const Text("Aggiungi"), + icon: const Icon(Icons.person_add_alt_1), + ) + : null, ); } @@ -117,6 +139,13 @@ class _StaffScreenState extends State { } Widget _buildStaffCard(StaffMemberModel member) { + final myRole = context + .read() + .state + .currentStaffMember + ?.systemRole; + final canManageStaff = + myRole == SystemRole.admin || myRole == SystemRole.manager; return Card( elevation: 0, shape: RoundedRectangleBorder( @@ -145,8 +174,9 @@ class _StaffScreenState extends State { ), ], ), - trailing: const Icon(Icons.edit_note), - onTap: () => _openStaffForm(context, member: member), + trailing: canManageStaff ? const Icon(Icons.edit_note) : null, + onTap: () => + canManageStaff ? _openStaffForm(context, member: member) : null, ), ); } @@ -155,9 +185,10 @@ class _StaffScreenState extends State { final nameController = TextEditingController(text: member?.name); final emailController = TextEditingController(text: member?.email); final phoneController = TextEditingController(text: member?.phoneNumber); + final jobTitleController = TextEditingController(text: member?.jobTitle); - // 1. Inizializziamo la lista temporanea attingendo dallo stato del Cubit - // Usiamo storesByStaff (la mappa che indicizza i negozi per ogni ID dipendente) + // Variabili di stato per il BottomSheet + SystemRole selectedRole = member?.systemRole ?? SystemRole.user; List tempSelectedStores = context .read() @@ -172,7 +203,6 @@ class _StaffScreenState extends State { isScrollControlled: true, backgroundColor: Colors.transparent, builder: (context) => StatefulBuilder( - // <--- QUESTO è il segreto per le Chip builder: (context, setModalState) { return Container( decoration: BoxDecoration( @@ -194,7 +224,7 @@ class _StaffScreenState extends State { children: [ Text( member == null - ? "Nuovo Collaboratore" + ? "Invita Collaboratore" // Cambiato il titolo per chiarezza! : "Modifica Collaboratore", style: const TextStyle( fontSize: 20, @@ -202,32 +232,77 @@ class _StaffScreenState extends State { ), ), const SizedBox(height: 24), + FluxTextField( controller: nameController, label: "Nome e Cognome", icon: Icons.person, ), const SizedBox(height: 16), + + // Reso visivamente obbligatorio se è un nuovo utente FluxTextField( controller: emailController, - label: "Email", + label: member == null + ? "Email (Obbligatoria per invito)*" + : "Email", icon: Icons.email, + enabled: + member == + null, // UX: Di solito l'email non si cambia dopo l'invito ), const SizedBox(height: 16), + FluxTextField( controller: phoneController, label: "Telefono", icon: Icons.phone, ), + const SizedBox(height: 16), + + // --- NOVITÀ: SCELTA DEL RUOLO E MANSIONE --- + Row( + children: [ + Expanded( + flex: 2, + child: DropdownButtonFormField( + initialValue: selectedRole, + decoration: const InputDecoration( + labelText: "Ruolo di Sistema", + prefixIcon: Icon(Icons.admin_panel_settings), + ), + items: SystemRole.values.map((role) { + return DropdownMenuItem( + value: role, + child: Text(role.name.toUpperCase()), + ); + }).toList(), + onChanged: (val) { + if (val != null) { + setModalState(() => selectedRole = val); + } + }, + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: FluxTextField( + controller: jobTitleController, + label: "Qualifica (Es. Addetto)", + icon: Icons.badge, + ), + ), + ], + ), const SizedBox(height: 24), + const Text( "Assegna ai Negozi", style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 12), - // --- SELETTORE NEGOZI (CHIPS) --- - // Qui usiamo il BlocBuilder per i negozi, ma il setModalState per il refresh BlocBuilder( builder: (context, storeState) { if (storeState.status == StoreStatus.loading) { @@ -244,7 +319,6 @@ class _StaffScreenState extends State { label: Text(store.nome), selected: isSelected, onSelected: (selected) { - // IMPORTANTE: setModalState aggiorna l'UI del BottomSheet setModalState(() { if (selected) { tempSelectedStores.add(store.id!); @@ -269,11 +343,26 @@ class _StaffScreenState extends State { height: 50, child: ElevatedButton( onPressed: () { + // Validazione di base per i nuovi inviti + if (member == null && + emailController.text.trim().isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "L'email è obbligatoria per invitare!", + ), + ), + ); + return; + } + final updatedMember = StaffMemberModel( - id: member?.id, - name: nameController.text, - email: emailController.text, - phoneNumber: phoneController.text, + id: member?.id, // Sarà null se è nuovo + name: nameController.text.trim(), + email: emailController.text.trim(), + phoneNumber: phoneController.text.trim(), + jobTitle: jobTitleController.text.trim(), + systemRole: selectedRole, companyId: GetIt.I .get() .state @@ -282,15 +371,28 @@ class _StaffScreenState extends State { userId: GetIt.I.get().state.user!.id, ); - // Chiamiamo il metodo atomico nel Cubit - context.read().saveStaffWithStores( - member: updatedMember, - selectedStoreIds: tempSelectedStores, - ); + // --- IL BIVIO LOGICO MAGICO --- + if (member == null) { + // 1. UTENTE NUOVO -> Chiamiamo la Edge Function + // (Nota: Per i negozi, potresti dover fare una logica a parte nel Cubit + // perché l'ID del database viene generato DOPO che l'Edge Function ha finito) + context.read().inviteStaffMember( + member: updatedMember, + selectedStoreIds: tempSelectedStores, + ); + } else { + // 2. UTENTE ESISTENTE -> Modifica classica + context.read().saveStaffWithStores( + member: updatedMember, + selectedStoreIds: tempSelectedStores, + ); + } Navigator.pop(context); }, - child: const Text("SALVA COLLABORATORE"), + child: Text( + member == null ? "INVIA INVITO" : "SALVA MODIFICHE", + ), ), ), ], diff --git a/lib/features/master_data/store/bloc/store_cubit.dart b/lib/features/master_data/store/bloc/store_cubit.dart index 09d31d0..ea85d01 100644 --- a/lib/features/master_data/store/bloc/store_cubit.dart +++ b/lib/features/master_data/store/bloc/store_cubit.dart @@ -137,7 +137,7 @@ class StoreCubit extends Cubit { Future assignStaffToStore(String storeId, String staffId) async { try { - await _staffRepository.assignToStore(staffId, storeId); + await _staffRepository.assignStaffToStore(staffId, storeId); // Dopo l'assegnazione, potresti voler ricaricare lo staff per quel negozio loadStores(); } catch (e) { @@ -150,7 +150,7 @@ class StoreCubit extends Cubit { // Rimuove un dipendente da un negozio Future removeStaffFromStore(String staffId, String storeId) async { try { - await _staffRepository.removeFromStore(staffId, storeId); + await _staffRepository.removeStaffFromStore(staffId, storeId); loadStores(); } catch (e) { emit( diff --git a/lib/features/onboarding/blocs/onboarding_cubit.dart b/lib/features/onboarding/blocs/onboarding_cubit.dart index 5c7186c..d00cb33 100644 --- a/lib/features/onboarding/blocs/onboarding_cubit.dart +++ b/lib/features/onboarding/blocs/onboarding_cubit.dart @@ -13,11 +13,13 @@ class OnboardingCubit extends Cubit { final SessionCubit _sessionCubit; OnboardingCubit(this._sessionCubit, this._repository) - : super(OnboardingState( - step: _sessionCubit.state.onboardingStep, - companyId: _sessionCubit.state.company?.id, - storeId: _sessionCubit.state.currentStore?.id, - )); + : super( + OnboardingState( + step: _sessionCubit.state.onboardingStep, + companyId: _sessionCubit.state.company?.id, + storeId: _sessionCubit.state.currentStore?.id, + ), + ); // --- STEP 1: REGISTRAZIONE AZIENDA --- Future saveCompany(String companyName) async { @@ -86,11 +88,17 @@ class OnboardingCubit extends Cubit { // PARANOIA MODE: Forziamo i legami e il ruolo di sistema 'admin' final staffToSave = staff.copyWith( companyId: state.companyId!, - userId: _sessionCubit.state.user!.id, // Dall'utente loggato in Supabase - systemRole: SystemRole.admin, // Blindato! + userId: _sessionCubit.state.user!.id, + systemRole: SystemRole.admin, ); - await _repository.createStaffMember(staffToSave); + // 1. Salviamo lo staff e CI FACCIAMO RESTITUIRE IL MODELLO (con l'id generato!) + final savedStaff = await _repository.createStaffMember(staffToSave); + + // 2. LA MAGIA: Colleghiamo il Paziente Zero al Negozio appena creato! + if (state.storeId != null && savedStaff.id != null) { + await _repository.assignStaffToStore(savedStaff.id!, state.storeId!); + } emit(state.copyWith(isLoading: false, step: OnboardingStep.completed)); diff --git a/lib/features/onboarding/ui/staff_onboarding_form.dart b/lib/features/onboarding/ui/staff_onboarding_form.dart index a280058..54d0fd7 100644 --- a/lib/features/onboarding/ui/staff_onboarding_form.dart +++ b/lib/features/onboarding/ui/staff_onboarding_form.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flux/core/blocs/session/session_cubit.dart'; import 'package:flux/core/utils/validators.dart'; import 'package:flux/core/widgets/flux_text_field.dart'; import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/onboarding/blocs/onboarding_cubit.dart'; +import 'package:get_it/get_it.dart'; class StaffOnboardingForm extends StatefulWidget { const StaffOnboardingForm({super.key}); @@ -26,6 +28,12 @@ class _StaffOnboardingFormState extends State { super.dispose(); } + @override + void initState() { + _emailCtrl.text = GetIt.I.get().state.user?.email ?? ''; + super.initState(); + } + @override Widget build(BuildContext context) { return Padding( diff --git a/lib/features/services/ui/service_form_screen/attachment_section.dart b/lib/features/services/ui/service_form_screen/attachment_section.dart index cb81224..6883f65 100644 --- a/lib/features/services/ui/service_form_screen/attachment_section.dart +++ b/lib/features/services/ui/service_form_screen/attachment_section.dart @@ -174,7 +174,6 @@ class AttachmentsSection extends StatelessWidget { ); }, ), - // --- PANNELLO AZIONI CONTESTUALI (LA MAGIA) --- // Appare SOLO se c'è almeno un file selezionato if (state.selectedFiles.isNotEmpty)