This commit is contained in:
2026-05-27 16:00:50 +02:00
parent f6ecb33729
commit b6e5f9acbe
8 changed files with 232 additions and 134 deletions

View File

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

View File

@@ -12,7 +12,22 @@ class StaffCubit extends Cubit<StaffState> {
final StaffRepository _repository = GetIt.I.get<StaffRepository>();
final SessionCubit _sessionCubit = GetIt.I<SessionCubit>();
StaffCubit() : super(const StaffState());
StaffCubit() : super(const StaffState()) {
init();
}
Future<void> 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<void> loadAllStaff() async {

View File

@@ -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);

View File

@@ -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<String> assignedStoreIds;
final List<StoreModel> 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<String>? assignedStoreIds,
List<StoreModel>? 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<String, dynamic> map) {
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
final List<String> parsedAssignedStoreIds =
map['assigned_store_ids'] != null
? List<String>.from(map['assigned_store_ids'])
: [];
// 2. Mappiamo il JOIN degli store, se presente
List<StoreModel> 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,
];
}

View File

@@ -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<String, dynamic>)
customer: map[Tables.customers] != null
? CustomerModel.fromMap(map[Tables.customers] as Map<String, dynamic>)
: null,
attachments:
(map['attachment'] as List?)
(map[Tables.attachments] as List?)
?.map((x) => AttachmentModel.fromMap(x))
.toList() ??
const [],

View File

@@ -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<TaskFormState> {
final TaskRepository _taskRepository = GetIt.I.get<TaskRepository>();
final TaskModel? _initialTask;
final String? _initialTaskId;
// Cache in memoria proveniente dallo StaffCubit!
final List<StaffMemberModel> _globalStaff;
final String currentCompanyId = GetIt.I
.get<SessionCubit>()
.state
.company!
.id!;
final String? currentStoreId = GetIt.I
.get<SessionCubit>()
.state
.currentStore
?.id;
final String currentCompanyId = GetIt.I<SessionCubit>().state.company!.id!;
final String? currentStoreId = GetIt.I<SessionCubit>().state.currentStore?.id;
TaskFormCubit({
required List<StaffMemberModel> globalStaff, // Iniettiamo la lista qui
TaskModel? initialTask,
String? initialTaskId,
required List<StaffMemberModel> 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<void> _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<TaskFormState> {
}
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<String, List<StaffMemberModel>> 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(

View File

@@ -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);

View File

@@ -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<TaskFormScreen> createState() => _TaskFormScreenState();
}
class _TaskFormScreenState extends State<TaskFormScreen> {
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<TaskFormCubit>().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<TaskFormCubit, TaskFormState>(
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,26 +79,25 @@ 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(
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: [
@@ -76,16 +108,21 @@ class TaskFormScreen extends StatelessWidget {
child: _buildFormFields(context, state, cubit),
),
),
VerticalDivider(color: Theme.of(context).dividerColor),
VerticalDivider(
color: Theme.of(context).dividerColor,
),
Expanded(
flex: 4,
child: _buildStaffSelectorInline(context, state, cubit),
child: _buildStaffSelectorInline(
context,
state,
cubit,
),
),
],
);
}
// --- VISTA MOBILE (1 Colonna + BottomSheet) ---
return SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
@@ -100,7 +137,8 @@ class TaskFormScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(12),
),
),
onPressed: () => _showStaffBottomSheet(context, cubit),
onPressed: () =>
_showStaffBottomSheet(context, cubit),
icon: const Icon(Icons.group_add),
label: Text(
state.selectedStaffIds.isEmpty
@@ -118,9 +156,7 @@ class TaskFormScreen extends StatelessWidget {
);
}
// =========================================================================
// 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)',