diff --git a/lib/features/home/ui/home_screen.dart b/lib/features/home/ui/home_screen.dart index 8f8f520..f99352f 100644 --- a/lib/features/home/ui/home_screen.dart +++ b/lib/features/home/ui/home_screen.dart @@ -14,8 +14,6 @@ import 'package:flux/features/master_data/staff/models/staff_member_model.dart'; import 'package:flux/features/notes/data/notes_repository.dart'; import 'package:flux/features/notes/models/note_model.dart'; import 'package:flux/features/notes/ui/dashboard_notes_widget.dart'; -import 'package:flux/features/tasks/data/task_repository.dart'; -import 'package:flux/features/tasks/models/task_model.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; @@ -236,11 +234,7 @@ class HomeScreen extends StatelessWidget { label: context.l10n.commonTask, color: Colors.teal, onTap: () { - context.pushNamed( - Routes.taskForm, - pathParameters: {'id': 'new'}, - extra: (task: null), - ); + context.pushNamed(Routes.taskForm, pathParameters: {'id': 'new'}); }, ), ], diff --git a/lib/features/master_data/staff/blocs/staff_cubit.dart b/lib/features/master_data/staff/blocs/staff_cubit.dart index d231167..6681415 100644 --- a/lib/features/master_data/staff/blocs/staff_cubit.dart +++ b/lib/features/master_data/staff/blocs/staff_cubit.dart @@ -12,7 +12,22 @@ class StaffCubit extends Cubit { final StaffRepository _repository = GetIt.I.get(); final SessionCubit _sessionCubit = GetIt.I(); - StaffCubit() : super(const StaffState()); + StaffCubit() : super(const StaffState()) { + init(); + } + + Future init() async { + emit(state.copyWith(status: StaffStatus.loading, error: null)); + try { + final allStaff = await _repository.getStaffMembers( + _sessionCubit.state.company!.id!, + ); + + emit(state.copyWith(status: StaffStatus.success, allStaff: allStaff)); + } catch (e) { + emit(state.copyWith(status: StaffStatus.error, error: e.toString())); + } + } // Carica tutto lo staff della compagnia Future loadAllStaff() async { diff --git a/lib/features/master_data/staff/data/staff_repository.dart b/lib/features/master_data/staff/data/staff_repository.dart index ab30490..9617f99 100644 --- a/lib/features/master_data/staff/data/staff_repository.dart +++ b/lib/features/master_data/staff/data/staff_repository.dart @@ -15,7 +15,9 @@ class StaffRepository { .from(Tables.staffMembers) .select(''' *, - stores (*) + store_assignments:${Tables.staffInStores} ( + ${Tables.stores}(*) + ) ''') .eq('company_id', companyId) .order('name', ascending: true); @@ -27,7 +29,12 @@ class StaffRepository { try { final response = await _supabase .from(Tables.staffMembers) - .select() + .select(''' + *, + store_assignments:${Tables.staffInStores} ( + ${Tables.stores}(*) + ) + ''') .eq('id', staffId) .single(); return StaffMemberModel.fromMap(response); 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 7f7678d..5b8cfea 100644 --- a/lib/features/master_data/staff/models/staff_member_model.dart +++ b/lib/features/master_data/staff/models/staff_member_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flux/core/enums_and_consts/consts.dart'; import 'package:flux/features/master_data/store/models/store_model.dart'; // L'Enum magico e blindato per il sistema @@ -27,7 +28,8 @@ class StaffMemberModel extends Equatable { final SystemRole systemRole; final bool isActive; final bool hasJoined; - final StoreModel? store; + final List assignedStoreIds; + final List assignedStores; const StaffMemberModel({ this.id, @@ -40,7 +42,8 @@ class StaffMemberModel extends Equatable { this.systemRole = SystemRole.user, this.isActive = true, this.hasJoined = false, - this.store, + this.assignedStoreIds = const [], + this.assignedStores = const [], }); StaffMemberModel copyWith({ @@ -55,7 +58,8 @@ class StaffMemberModel extends Equatable { SystemRole? systemRole, bool? isActive, bool? hasJoined, - StoreModel? store, + List? assignedStoreIds, + List? assignedStores, }) { return StaffMemberModel( id: id ?? this.id, @@ -68,7 +72,8 @@ class StaffMemberModel extends Equatable { systemRole: systemRole ?? this.systemRole, isActive: isActive ?? this.isActive, hasJoined: hasJoined ?? this.hasJoined, - store: store ?? this.store, + assignedStoreIds: assignedStoreIds ?? this.assignedStoreIds, + assignedStores: assignedStores ?? this.assignedStores, ); } @@ -84,6 +89,24 @@ class StaffMemberModel extends Equatable { } factory StaffMemberModel.fromMap(Map map) { + // 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota + final List parsedAssignedStoreIds = + map['assigned_store_ids'] != null + ? List.from(map['assigned_store_ids']) + : []; + + // 2. Mappiamo il JOIN degli store, se presente + List storeList = []; + + // Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members) + if (map['store_assignments'] != null) { + storeList = (map['store_assignments'] as List) + .map((a) => a[Tables.stores]) + .where((s) => s != null) + .map((s) => StoreModel.fromMap(s)) + .toList(); + } + return StaffMemberModel( id: map['id'] as String?, companyId: map['company_id'] ?? '', @@ -95,7 +118,8 @@ class StaffMemberModel extends Equatable { systemRole: SystemRole.fromString(map['system_role']), isActive: map['is_active'] ?? true, hasJoined: map['has_joined'] ?? false, - store: map['store'] != null ? StoreModel.fromMap(map['store']) : null, + assignedStoreIds: parsedAssignedStoreIds, + assignedStores: storeList, ); } @@ -126,6 +150,7 @@ class StaffMemberModel extends Equatable { systemRole, isActive, hasJoined, - store, + assignedStoreIds, + assignedStores, ]; } diff --git a/lib/features/operations/models/operation_model.dart b/lib/features/operations/models/operation_model.dart index 94b3c4f..57639f6 100644 --- a/lib/features/operations/models/operation_model.dart +++ b/lib/features/operations/models/operation_model.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:flux/core/enums_and_consts/consts.dart'; import 'package:flux/core/utils/extensions.dart'; import 'package:flux/features/attachments/models/attachment_model.dart'; import 'package:flux/features/customers/models/customer_model.dart'; @@ -184,10 +185,11 @@ class OperationModel extends Equatable { // I campi relazionali nullabili restano rigorosamente null! providerId: map['provider_id'] as String?, // MAGIA ANTI-CRASH: Usiamo ?['chiave'] per non far esplodere i join vuoti - providerDisplayName: (map['provider']?['name'] as String?)?.myFormat(), + providerDisplayName: (map[Tables.providers]?['name'] as String?) + ?.myFormat(), modelId: map['model_id'] as String?, - modelDisplayName: (map['model']?['name_with_brand'] as String?) + modelDisplayName: (map[Tables.models]?['name_with_brand'] as String?) ?.myFormat(), description: map['description'] as String?, @@ -202,25 +204,26 @@ class OperationModel extends Equatable { storeId: map['store_id'] as String? ?? '', // Questo è non-nullable nella tua classe - storeDisplayName: (map['store']?['name'] as String?)?.myFormat(), + storeDisplayName: (map[Tables.stores]?['name'] as String?)?.myFormat(), quantity: map['quantity'] is int ? map['quantity'] : int.tryParse(map['quantity']?.toString() ?? '1') ?? 1, staffId: map['staff_id'] as String?, - staffDisplayName: (map['staff_member']?['name'] as String?)?.myFormat(), + staffDisplayName: (map[Tables.staffMembers]?['name'] as String?) + ?.myFormat(), lastCampaignId: map['last_campaign_id'] as String?, status: OperationStatus.fromString(map['status'] ?? 'draft'), customerId: map['customer_id'] as String?, - customer: map['customer'] != null - ? CustomerModel.fromMap(map['customer'] as Map) + customer: map[Tables.customers] != null + ? CustomerModel.fromMap(map[Tables.customers] as Map) : null, attachments: - (map['attachment'] as List?) + (map[Tables.attachments] as List?) ?.map((x) => AttachmentModel.fromMap(x)) .toList() ?? const [], diff --git a/lib/features/tasks/blocs/task_form_cubit.dart b/lib/features/tasks/blocs/task_form_cubit.dart index 9060d78..79d6d91 100644 --- a/lib/features/tasks/blocs/task_form_cubit.dart +++ b/lib/features/tasks/blocs/task_form_cubit.dart @@ -1,4 +1,3 @@ -import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/core/blocs/session/session_cubit.dart'; @@ -11,55 +10,60 @@ part 'task_form_state.dart'; class TaskFormCubit extends Cubit { final TaskRepository _taskRepository = GetIt.I.get(); - final TaskModel? _initialTask; - final String? _initialTaskId; - // Cache in memoria proveniente dallo StaffCubit! final List _globalStaff; - - final String currentCompanyId = GetIt.I - .get() - .state - .company! - .id!; - final String? currentStoreId = GetIt.I - .get() - .state - .currentStore - ?.id; + final String currentCompanyId = GetIt.I().state.company!.id!; + final String? currentStoreId = GetIt.I().state.currentStore?.id; TaskFormCubit({ - required List globalStaff, // Iniettiamo la lista qui - TaskModel? initialTask, - String? initialTaskId, + required List globalStaff, + + TaskModel? initialTask, // Arriva dalla navigazione interna (extra) + String? initialTaskId, // Arriva dal Deep Link (parametro URL) }) : _globalStaff = globalStaff, - _initialTask = initialTask, - _initialTaskId = initialTaskId, super(const TaskFormState()) { - _initForm(task: initialTask, initialTaskId: initialTaskId); + _initForm(initialTask, initialTaskId); } - // --- 1. INIZIALIZZAZIONE SINCRONA --- - void _initForm({TaskModel? task, String? initialTaskId}) { - final isGlobalMode = task != null - ? task.storeId == null - : currentStoreId == null; + Future _initForm(TaskModel? task, String? taskId) async { + // 1. Mettiamo subito il form in caricamento + emit(state.copyWith(status: TaskFormStatus.loading)); - // MAGIA: Estraiamo gli ID dagli oggetti staff, o facciamo fallback su assignedToIds se c'è + TaskModel? taskToLoad = task; + + // 2. SCENARIO DEEP LINK: Non abbiamo l'oggetto, ma abbiamo un ID valido + if (taskToLoad == null && taskId != null && taskId != 'new') { + try { + taskToLoad = await _taskRepository.getTaskById(taskId); + } catch (e) { + emit( + state.copyWith( + status: TaskFormStatus.failure, + errorMessage: 'Impossibile caricare il task dal link: $e', + ), + ); + return; // Ci fermiamo qui + } + } + + // 3. Popoliamo lo stato con i dati (sia che arrivino dall'extra, sia dal DB, sia nulli) + final isGlobalMode = taskToLoad != null + ? taskToLoad.storeId == null + : currentStoreId == null; final existingStaffIds = - task?.assignedToStaff.map((s) => s.id!).toList() ?? - task?.assignedToIds ?? + taskToLoad?.assignedToStaff.map((s) => s.id!).toList() ?? + taskToLoad?.assignedToIds ?? []; emit( state.copyWith( - id: task?.id, - title: task?.title ?? '', - description: task?.description ?? '', - dueDate: task?.dueDate, - taskStatus: task?.status ?? TaskStatus.open, + id: taskToLoad?.id, + title: taskToLoad?.title ?? '', + description: taskToLoad?.description ?? '', + dueDate: taskToLoad?.dueDate, + taskStatus: taskToLoad?.status ?? TaskStatus.open, isGlobal: isGlobalMode, - selectedStaffIds: - existingStaffIds, // Ora non si perde più i dipendenti! + selectedStaffIds: existingStaffIds, + status: TaskFormStatus.initial, // Caricamento finito, form pronto! ), ); @@ -78,16 +82,28 @@ class TaskFormCubit extends Cubit { } void _updateStaffScope(bool isGlobal) { - // 1. Filtriamo in memoria + // 1. Filtriamo in memoria: cerchiamo nell'array degli ID! final filteredStaff = isGlobal ? _globalStaff - : _globalStaff.where((s) => s.store?.id == currentStoreId).toList(); + : _globalStaff + .where((s) => s.assignedStoreIds.contains(currentStoreId)) + .toList(); - // 2. Raggruppiamo per nome negozio (usando groupBy del pacchetto collection) - final groupedStaff = groupBy( - filteredStaff, - (StaffMemberModel s) => s.store?.name ?? 'Direzione / HQ', - ); + // 2. Raggruppamento M2M (Ciclo manuale) + final Map> groupedStaff = {}; + + for (final staff in filteredStaff) { + // Se non ha nessun negozio assegnato, finisce in Direzione + if (staff.assignedStores.isEmpty) { + groupedStaff.putIfAbsent('Direzione / HQ', () => []).add(staff); + } else { + // Se ha più negozi, clona la sua presenza in ogni gruppo! + for (final store in staff.assignedStores) { + final storeName = store.name; + groupedStaff.putIfAbsent(storeName, () => []).add(staff); + } + } + } // 3. Emettiamo il nuovo stato all'istante emit( diff --git a/lib/features/tasks/data/task_repository.dart b/lib/features/tasks/data/task_repository.dart index aa9dbff..32c963f 100644 --- a/lib/features/tasks/data/task_repository.dart +++ b/lib/features/tasks/data/task_repository.dart @@ -24,7 +24,7 @@ class TaskRepository { .from(Tables.tasks) .select(''' *, - ${Tables.taskAssignments} ( + task_assignments:${Tables.taskAssignments} ( ${Tables.staffMembers} (*) ) ''') @@ -138,9 +138,12 @@ class TaskRepository { try { final response = await _supabase .from(Tables.tasks) - .select( - '*, assigned_to_staff:${Tables.staffMembers}!${Tables.taskAssignments}(*)', - ) + .select(''' + *, + task_assignments:${Tables.taskAssignments} ( + ${Tables.staffMembers} (*) + ) + ''') .eq('id', taskId) .single(); return TaskModel.fromMap(response); diff --git a/lib/features/tasks/ui/task_form_screen.dart b/lib/features/tasks/ui/task_form_screen.dart index da4ab37..31c3f76 100644 --- a/lib/features/tasks/ui/task_form_screen.dart +++ b/lib/features/tasks/ui/task_form_screen.dart @@ -3,19 +3,53 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flux/features/tasks/blocs/task_form_cubit.dart'; import 'package:go_router/go_router.dart'; -class TaskFormScreen extends StatelessWidget { +class TaskFormScreen extends StatefulWidget { const TaskFormScreen({super.key}); + @override + State createState() => _TaskFormScreenState(); +} + +class _TaskFormScreenState extends State { + late final TextEditingController _titleController; + late final TextEditingController _descController; + + @override + void initState() { + super.initState(); + // Leggiamo lo stato iniziale dal Cubit (che ha già i dati del task esistente) + final initialState = context.read().state; + + _titleController = TextEditingController(text: initialState.title); + _descController = TextEditingController(text: initialState.description); + } + + @override + void dispose() { + _titleController.dispose(); + _descController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return BlocConsumer( listenWhen: (previous, current) => previous.status != current.status, listener: (context, state) { + // GESTIONE DEEP LINK: Se eravamo in caricamento e ora siamo pronti, popoliamo i controller! + if (state.status == TaskFormStatus.initial) { + if (_titleController.text != state.title) { + _titleController.text = state.title; + } + if (_descController.text != state.description) { + _descController.text = state.description; + } + } if (state.status == TaskFormStatus.success) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Task salvato con successo! 🎉')), ); - context.pop(); // Usciamo dalla pagina + context.pop(); } else if (state.status == TaskFormStatus.failure) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -33,7 +67,6 @@ class TaskFormScreen extends StatelessWidget { appBar: AppBar( title: Text(isEditing ? 'Modifica Task' : 'Nuovo Task'), actions: [ - // Tasto Salva (abilitato solo se il form è valido) if (state.status == TaskFormStatus.submitting) const Padding( padding: EdgeInsets.all(16.0), @@ -46,81 +79,84 @@ class TaskFormScreen extends StatelessWidget { else TextButton.icon( onPressed: state.isFormValid - ? () => - cubit.saveTask( - currentUserId: 'TODO_USER_ID', - ) // Passa l'id utente loggato dal SessionCubit + ? () => cubit.saveTask(currentUserId: 'TODO_USER_ID') : null, icon: const Icon(Icons.save), label: const Text('Salva'), style: TextButton.styleFrom( - foregroundColor: Colors.orange, // Il tuo colore primario + foregroundColor: Colors.orange, disabledForegroundColor: Colors.grey, ), ), ], ), - body: LayoutBuilder( - builder: (context, constraints) { - final isWideScreen = constraints.maxWidth > 800; + body: state.status == TaskFormStatus.loading + // Se sta scaricando i dati dal Deep Link, mostriamo un bel loader centrato + ? const Center(child: CircularProgressIndicator()) + : LayoutBuilder( + builder: (context, constraints) { + final isWideScreen = constraints.maxWidth > 800; - if (isWideScreen) { - // --- VISTA DESKTOP / TABLET LARGHI (2 Colonne) --- - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - flex: 6, - child: SingleChildScrollView( - padding: const EdgeInsets.all(24.0), - child: _buildFormFields(context, state, cubit), - ), - ), - VerticalDivider(color: Theme.of(context).dividerColor), - Expanded( - flex: 4, - child: _buildStaffSelectorInline(context, state, cubit), - ), - ], - ); - } + if (isWideScreen) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 6, + child: SingleChildScrollView( + padding: const EdgeInsets.all(24.0), + child: _buildFormFields(context, state, cubit), + ), + ), + VerticalDivider( + color: Theme.of(context).dividerColor, + ), + Expanded( + flex: 4, + child: _buildStaffSelectorInline( + context, + state, + cubit, + ), + ), + ], + ); + } - // --- VISTA MOBILE (1 Colonna + BottomSheet) --- - return SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildFormFields(context, state, cubit), - const SizedBox(height: 30), - ElevatedButton.icon( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + return SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildFormFields(context, state, cubit), + const SizedBox(height: 30), + ElevatedButton.icon( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + onPressed: () => + _showStaffBottomSheet(context, cubit), + icon: const Icon(Icons.group_add), + label: Text( + state.selectedStaffIds.isEmpty + ? 'Assegna Staff' + : 'Assegnato a ${state.selectedStaffIds.length} persone', + ), + ), + ], ), - onPressed: () => _showStaffBottomSheet(context, cubit), - icon: const Icon(Icons.group_add), - label: Text( - state.selectedStaffIds.isEmpty - ? 'Assegna Staff' - : 'Assegnato a ${state.selectedStaffIds.length} persone', - ), - ), - ], + ); + }, ), - ); - }, - ), ); }, ); } - // ========================================================================= - // 1. I CAMPI DEL FORM PRINCIPALE - // ========================================================================= + // --- I CAMPI DEL FORM (Aggiornati con i Controller) --- Widget _buildFormFields( BuildContext context, TaskFormState state, @@ -129,7 +165,6 @@ class TaskFormScreen extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // --- SCOPE TOGGLE --- Card( elevation: 0, shape: RoundedRectangleBorder( @@ -153,9 +188,9 @@ class TaskFormScreen extends StatelessWidget { ), const SizedBox(height: 24), - // --- TITOLO E DESCRIZIONE --- + // Addio initialValue, benvenuto controller! TextFormField( - initialValue: state.title, + controller: _titleController, decoration: const InputDecoration( labelText: 'Titolo del Task*', border: OutlineInputBorder(), @@ -165,7 +200,7 @@ class TaskFormScreen extends StatelessWidget { ), const SizedBox(height: 16), TextFormField( - initialValue: state.description, + controller: _descController, maxLines: 4, decoration: const InputDecoration( labelText: 'Descrizione (opzionale)',