boh
This commit is contained in:
@@ -16,6 +16,8 @@ class Tables {
|
||||
static const String staffInStores = 'staff_in_stores';
|
||||
static const String staffMembers = 'staff_members';
|
||||
static const String stores = 'stores';
|
||||
static const String tasks = 'tasks';
|
||||
static const String taskAssignments = 'task_assignments';
|
||||
static const String tickets = 'tickets';
|
||||
static const String trackings = 'trackings';
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import 'package:flux/features/master_data/providers/blocs/provider_list_cubit.da
|
||||
import 'package:flux/features/master_data/providers/models/provider_model.dart';
|
||||
import 'package:flux/features/master_data/providers/ui/provider_form_screen.dart';
|
||||
import 'package:flux/features/master_data/providers/ui/provider_list_screen.dart';
|
||||
import 'package:flux/features/master_data/staff/blocs/staff_cubit.dart';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/master_data/staff/ui/staff_screen.dart';
|
||||
import 'package:flux/features/master_data/store/bloc/store_cubit.dart';
|
||||
@@ -44,7 +45,9 @@ import 'package:flux/features/operations/ui/operation_form_screen.dart';
|
||||
import 'package:flux/features/operations/ui/operation_list_screen.dart';
|
||||
import 'package:flux/features/settings/settings_screen.dart';
|
||||
import 'package:flux/features/settings/theme_settings_view.dart';
|
||||
import 'package:flux/features/tasks/blocs/task_form_cubit.dart';
|
||||
import 'package:flux/features/tasks/models/task_model.dart';
|
||||
import 'package:flux/features/tasks/ui/task_form_screen.dart';
|
||||
import 'package:flux/features/tickets/blocs/ticket_form_cubit.dart';
|
||||
import 'package:flux/features/tickets/models/ticket_model.dart';
|
||||
import 'package:flux/features/tickets/ui/ticket_form_screen.dart';
|
||||
@@ -498,33 +501,39 @@ class AppRouter {
|
||||
);
|
||||
},
|
||||
),
|
||||
/* GoRoute(
|
||||
path: '/task/edit/:id',
|
||||
GoRoute(
|
||||
path: '/tasks/form/:id',
|
||||
name: Routes.taskForm,
|
||||
builder: (context, state) {
|
||||
final id = state.pathParameters['id']!;
|
||||
final TaskModel task = state.extra as TaskModel;
|
||||
final String pathId = state.pathParameters['id'] ?? 'new';
|
||||
final TaskModel? task = state.extra as TaskModel?;
|
||||
final String? realTaskId;
|
||||
if (pathId == 'new') {
|
||||
realTaskId = null;
|
||||
} else if (task?.id != null) {
|
||||
realTaskId = task!.id;
|
||||
} else {
|
||||
realTaskId = pathId;
|
||||
}
|
||||
|
||||
final allStaffList = context.read<StaffCubit>().state.allStaff;
|
||||
|
||||
// Creiamo il BLoC "al volo" solo per questa schermata
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AttachmentsBloc>(
|
||||
create: (context) => AttachmentsBloc(
|
||||
parentId: id,
|
||||
parentType: AttachmentParentType.note,
|
||||
),
|
||||
),
|
||||
BlocProvider<TaskFormCubit>(
|
||||
create: (context) => TaskFormCubit(
|
||||
existingTask: task,
|
||||
globalStaff: allStaffList,
|
||||
initialTask: task,
|
||||
initialTaskId: realTaskId,
|
||||
),
|
||||
)
|
||||
),
|
||||
],
|
||||
|
||||
child: TaskFormScreen(task: task),
|
||||
child: TaskFormScreen(),
|
||||
);
|
||||
},
|
||||
), */
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,9 +16,9 @@ class DashboardTaskListCubit extends Cubit<DashboardTaskListState> {
|
||||
|
||||
DashboardTaskListCubit() : super(DashboardTaskListState());
|
||||
|
||||
void startListening({required String staffId}) {
|
||||
void startListening({required String staffId}) async {
|
||||
emit(state.copyWith(status: DashboardTaskListStatus.loading));
|
||||
_loadTasks(staffId: staffId);
|
||||
await _loadTasks(staffId: staffId);
|
||||
_taskChannel?.unsubscribe();
|
||||
_taskChannel = _supabase
|
||||
.channel('public:tasks_staff_$staffId')
|
||||
|
||||
@@ -100,7 +100,7 @@ class _DashboardTasksCardContent extends StatelessWidget {
|
||||
if (state.status == DashboardTaskListStatus.failure) {
|
||||
return Center(
|
||||
child: Text(
|
||||
"Errore di caricamento",
|
||||
"Errore di caricamento ${state.errorMessage}",
|
||||
style: TextStyle(color: theme.colorScheme.error),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -236,23 +236,10 @@ class HomeScreen extends StatelessWidget {
|
||||
label: context.l10n.commonTask,
|
||||
color: Colors.teal,
|
||||
onTap: () {
|
||||
final companyId = context.read<SessionCubit>().state.company!.id!;
|
||||
final currentStaffId = context
|
||||
.read<SessionCubit>()
|
||||
.state
|
||||
.currentStaffMember!
|
||||
.id!;
|
||||
final emptyTask = TaskModel.empty(
|
||||
companyId: companyId,
|
||||
createdById: currentStaffId,
|
||||
);
|
||||
final savedTask = GetIt.I.get<TaskRepository>().createTask(
|
||||
emptyTask,
|
||||
);
|
||||
context.pushNamed(
|
||||
Routes.taskForm,
|
||||
pathParameters: {'id': 'new'},
|
||||
extra: savedTask,
|
||||
extra: (task: null),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -13,7 +13,10 @@ class StaffRepository {
|
||||
Future<List<StaffMemberModel>> getStaffMembers(String companyId) async {
|
||||
final response = await _supabase
|
||||
.from(Tables.staffMembers)
|
||||
.select()
|
||||
.select('''
|
||||
*,
|
||||
stores (*)
|
||||
''')
|
||||
.eq('company_id', companyId)
|
||||
.order('name', ascending: true);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:flux/features/master_data/store/models/store_model.dart';
|
||||
|
||||
// L'Enum magico e blindato per il sistema
|
||||
enum SystemRole {
|
||||
@@ -26,6 +27,7 @@ class StaffMemberModel extends Equatable {
|
||||
final SystemRole systemRole;
|
||||
final bool isActive;
|
||||
final bool hasJoined;
|
||||
final StoreModel? store;
|
||||
|
||||
const StaffMemberModel({
|
||||
this.id,
|
||||
@@ -38,6 +40,7 @@ class StaffMemberModel extends Equatable {
|
||||
this.systemRole = SystemRole.user,
|
||||
this.isActive = true,
|
||||
this.hasJoined = false,
|
||||
this.store,
|
||||
});
|
||||
|
||||
StaffMemberModel copyWith({
|
||||
@@ -52,6 +55,7 @@ class StaffMemberModel extends Equatable {
|
||||
SystemRole? systemRole,
|
||||
bool? isActive,
|
||||
bool? hasJoined,
|
||||
StoreModel? store,
|
||||
}) {
|
||||
return StaffMemberModel(
|
||||
id: id ?? this.id,
|
||||
@@ -64,6 +68,7 @@ class StaffMemberModel extends Equatable {
|
||||
systemRole: systemRole ?? this.systemRole,
|
||||
isActive: isActive ?? this.isActive,
|
||||
hasJoined: hasJoined ?? this.hasJoined,
|
||||
store: store ?? this.store,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +95,7 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -120,5 +126,6 @@ class StaffMemberModel extends Equatable {
|
||||
systemRole,
|
||||
isActive,
|
||||
hasJoined,
|
||||
store,
|
||||
];
|
||||
}
|
||||
|
||||
195
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
195
lib/features/tasks/blocs/task_form_cubit.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
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';
|
||||
import 'package:flux/features/master_data/staff/models/staff_member_model.dart';
|
||||
import 'package:flux/features/tasks/data/task_repository.dart';
|
||||
import 'package:flux/features/tasks/models/task_model.dart';
|
||||
import 'package:flux/features/tasks/models/task_status.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
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;
|
||||
|
||||
TaskFormCubit({
|
||||
required List<StaffMemberModel> globalStaff, // Iniettiamo la lista qui
|
||||
TaskModel? initialTask,
|
||||
String? initialTaskId,
|
||||
}) : _globalStaff = globalStaff,
|
||||
_initialTask = initialTask,
|
||||
_initialTaskId = initialTaskId,
|
||||
super(const TaskFormState()) {
|
||||
_initForm(task: initialTask, initialTaskId: initialTaskId);
|
||||
}
|
||||
|
||||
// --- 1. INIZIALIZZAZIONE SINCRONA ---
|
||||
void _initForm({TaskModel? task, String? initialTaskId}) {
|
||||
final isGlobalMode = task != null
|
||||
? task.storeId == null
|
||||
: currentStoreId == null;
|
||||
|
||||
// MAGIA: Estraiamo gli ID dagli oggetti staff, o facciamo fallback su assignedToIds se c'è
|
||||
final existingStaffIds =
|
||||
task?.assignedToStaff.map((s) => s.id!).toList() ??
|
||||
task?.assignedToIds ??
|
||||
[];
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
id: task?.id,
|
||||
title: task?.title ?? '',
|
||||
description: task?.description ?? '',
|
||||
dueDate: task?.dueDate,
|
||||
taskStatus: task?.status ?? TaskStatus.open,
|
||||
isGlobal: isGlobalMode,
|
||||
selectedStaffIds:
|
||||
existingStaffIds, // Ora non si perde più i dipendenti!
|
||||
),
|
||||
);
|
||||
|
||||
_updateStaffScope(isGlobalMode);
|
||||
}
|
||||
|
||||
// --- 2. SWITCH SCOPE E RAGGRUPPAMENTO ---
|
||||
void toggleGlobalScope(bool isGlobal) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
isGlobal: isGlobal,
|
||||
selectedStaffIds: [], // Resettiamo la selezione se si cambia scope
|
||||
),
|
||||
);
|
||||
_updateStaffScope(isGlobal);
|
||||
}
|
||||
|
||||
void _updateStaffScope(bool isGlobal) {
|
||||
// 1. Filtriamo in memoria
|
||||
final filteredStaff = isGlobal
|
||||
? _globalStaff
|
||||
: _globalStaff.where((s) => s.store?.id == currentStoreId).toList();
|
||||
|
||||
// 2. Raggruppiamo per nome negozio (usando groupBy del pacchetto collection)
|
||||
final groupedStaff = groupBy(
|
||||
filteredStaff,
|
||||
(StaffMemberModel s) => s.store?.name ?? 'Direzione / HQ',
|
||||
);
|
||||
|
||||
// 3. Emettiamo il nuovo stato all'istante
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: TaskFormStatus.initial,
|
||||
groupedAvailableStaff: groupedStaff,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- 3. SELEZIONE AVANZATA (SINGOLA E PER NEGOZIO) ---
|
||||
void toggleStaffSelection(String staffId) {
|
||||
final currentList = List<String>.from(state.selectedStaffIds);
|
||||
if (currentList.contains(staffId)) {
|
||||
currentList.remove(staffId);
|
||||
} else {
|
||||
currentList.add(staffId);
|
||||
}
|
||||
emit(state.copyWith(selectedStaffIds: currentList));
|
||||
}
|
||||
|
||||
void toggleStoreSelection(String storeName, bool selectAll) {
|
||||
// Recupera tutti i membri di quel gruppo
|
||||
final staffInStore = state.groupedAvailableStaff[storeName] ?? [];
|
||||
final idsInStore = staffInStore.map((s) => s.id!).toList();
|
||||
|
||||
final currentSelection = Set<String>.from(state.selectedStaffIds);
|
||||
|
||||
if (selectAll) {
|
||||
currentSelection.addAll(idsInStore);
|
||||
} else {
|
||||
currentSelection.removeAll(idsInStore);
|
||||
}
|
||||
|
||||
emit(state.copyWith(selectedStaffIds: currentSelection.toList()));
|
||||
}
|
||||
|
||||
// --- 4. AGGIORNAMENTO CAMPI STANDARD ---
|
||||
void updateTitle(String title) => emit(state.copyWith(title: title));
|
||||
|
||||
void updateDescription(String desc) =>
|
||||
emit(state.copyWith(description: desc));
|
||||
|
||||
void updateDueDate(DateTime? date) =>
|
||||
emit(state.copyWith(dueDate: date, clearDueDate: date == null));
|
||||
|
||||
void updateLinkedTicket(String? ticketId) =>
|
||||
emit(state.copyWith(linkedTicketId: ticketId));
|
||||
|
||||
// --- 5. LOGICA DEI REMINDER FINTI ---
|
||||
void addMockReminder(String type, int minutes) {
|
||||
final currentReminders = List<TaskReminder>.from(state.reminders);
|
||||
currentReminders.add(TaskReminder(type: type, minutesBefore: minutes));
|
||||
emit(state.copyWith(reminders: currentReminders));
|
||||
}
|
||||
|
||||
void removeMockReminder(int index) {
|
||||
final currentReminders = List<TaskReminder>.from(state.reminders);
|
||||
currentReminders.removeAt(index);
|
||||
emit(state.copyWith(reminders: currentReminders));
|
||||
}
|
||||
|
||||
// --- 6. SALVATAGGIO FINALE ---
|
||||
Future<void> saveTask({required String currentUserId}) async {
|
||||
if (!state.isFormValid) return;
|
||||
|
||||
emit(state.copyWith(status: TaskFormStatus.submitting));
|
||||
|
||||
try {
|
||||
final taskToSave = TaskModel(
|
||||
id: state.id,
|
||||
companyId: currentCompanyId,
|
||||
storeId: state.isGlobal
|
||||
? null
|
||||
: currentStoreId, // La vera discriminante Globale/Store!
|
||||
createdById: state.id == null
|
||||
? currentUserId
|
||||
: null, // Lo settiamo solo alla creazione
|
||||
title: state.title.trim(),
|
||||
description: state.description.trim().isEmpty
|
||||
? null
|
||||
: state.description.trim(),
|
||||
dueDate: state.dueDate,
|
||||
status: state.taskStatus,
|
||||
assignedToIds: state
|
||||
.selectedStaffIds, // L'array che andrà a popolare la tabella di giunzione
|
||||
);
|
||||
|
||||
if (state.id == null) {
|
||||
await _taskRepository.createTask(taskToSave);
|
||||
} else {
|
||||
await _taskRepository.updateTask(taskToSave);
|
||||
}
|
||||
|
||||
emit(state.copyWith(status: TaskFormStatus.success));
|
||||
} catch (e) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: TaskFormStatus.failure,
|
||||
errorMessage: 'Errore durante il salvataggio: $e',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
109
lib/features/tasks/blocs/task_form_state.dart
Normal file
109
lib/features/tasks/blocs/task_form_state.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
part of 'task_form_cubit.dart';
|
||||
|
||||
enum TaskFormStatus { initial, loading, submitting, success, failure }
|
||||
|
||||
/// Placeholder finto per i futuri reminder (pg_cron)
|
||||
class TaskReminder extends Equatable {
|
||||
final String type; // es. 'email', 'push'
|
||||
final int minutesBefore;
|
||||
const TaskReminder({required this.type, required this.minutesBefore});
|
||||
@override
|
||||
List<Object?> get props => [type, minutesBefore];
|
||||
}
|
||||
|
||||
class TaskFormState extends Equatable {
|
||||
final TaskFormStatus status;
|
||||
final String? id; // Null se stiamo creando un nuovo task
|
||||
final String title;
|
||||
final String description;
|
||||
final DateTime? dueDate;
|
||||
final TaskStatus taskStatus;
|
||||
|
||||
// --- SCOPING & ASSIGNMENTS ---
|
||||
final bool isGlobal;
|
||||
final List<String> selectedStaffIds;
|
||||
final List<StaffMemberModel> availableStaff;
|
||||
final Map<String, List<StaffMemberModel>> groupedAvailableStaff;
|
||||
|
||||
// --- FUTURI ANCORAGGI ---
|
||||
final List<TaskReminder> reminders;
|
||||
final String? linkedTicketId;
|
||||
final String? linkedEmailId;
|
||||
|
||||
final String? errorMessage;
|
||||
|
||||
const TaskFormState({
|
||||
this.status = TaskFormStatus.initial,
|
||||
this.id,
|
||||
this.title = '',
|
||||
this.description = '',
|
||||
this.dueDate,
|
||||
this.taskStatus = TaskStatus.open,
|
||||
this.isGlobal = false,
|
||||
this.selectedStaffIds = const [],
|
||||
this.availableStaff = const [],
|
||||
this.groupedAvailableStaff = const {},
|
||||
this.reminders = const [],
|
||||
this.linkedTicketId,
|
||||
this.linkedEmailId,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
// MAGIA: Il form è valido solo se c'è un titolo e, opzionalmente, altre regole.
|
||||
bool get isFormValid => title.trim().isNotEmpty;
|
||||
|
||||
TaskFormState copyWith({
|
||||
TaskFormStatus? status,
|
||||
String? id,
|
||||
String? title,
|
||||
String? description,
|
||||
DateTime? dueDate,
|
||||
bool clearDueDate = false, // Trucco Ninja per rimettere a null una data!
|
||||
TaskStatus? taskStatus,
|
||||
bool? isGlobal,
|
||||
List<String>? selectedStaffIds,
|
||||
List<StaffMemberModel>? availableStaff,
|
||||
Map<String, List<StaffMemberModel>>? groupedAvailableStaff,
|
||||
List<TaskReminder>? reminders,
|
||||
String? linkedTicketId,
|
||||
String? linkedEmailId,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return TaskFormState(
|
||||
status: status ?? this.status,
|
||||
id: id ?? this.id,
|
||||
title: title ?? this.title,
|
||||
description: description ?? this.description,
|
||||
dueDate: clearDueDate ? null : (dueDate ?? this.dueDate),
|
||||
taskStatus: taskStatus ?? this.taskStatus,
|
||||
isGlobal: isGlobal ?? this.isGlobal,
|
||||
selectedStaffIds: selectedStaffIds ?? this.selectedStaffIds,
|
||||
availableStaff: availableStaff ?? this.availableStaff,
|
||||
groupedAvailableStaff:
|
||||
groupedAvailableStaff ?? this.groupedAvailableStaff,
|
||||
reminders: reminders ?? this.reminders,
|
||||
linkedTicketId: linkedTicketId ?? this.linkedTicketId,
|
||||
linkedEmailId: linkedEmailId ?? this.linkedEmailId,
|
||||
errorMessage:
|
||||
errorMessage, // Se copyWith è chiamato senza errore, lo pulisce in automatico
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
status,
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
dueDate,
|
||||
taskStatus,
|
||||
isGlobal,
|
||||
selectedStaffIds,
|
||||
availableStaff,
|
||||
groupedAvailableStaff,
|
||||
reminders,
|
||||
linkedTicketId,
|
||||
linkedEmailId,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flux/core/enums_and_consts/consts.dart';
|
||||
import 'package:flux/features/tasks/models/task_status.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
// Sostituisci con i percorsi corretti di FLUX
|
||||
@@ -18,10 +19,15 @@ class TaskRepository {
|
||||
int? limit,
|
||||
}) async {
|
||||
try {
|
||||
// 1. FASE FILTRI (PostgrestFilterBuilder)
|
||||
// 1. FASE FILTRI: Usa il join esplicito stile "Notes"
|
||||
var filterBuilder = _supabase
|
||||
.from('tasks')
|
||||
.select('*, assigned_to_staff:staff_members!task_assignments(*)')
|
||||
.select('''
|
||||
*,
|
||||
task_assignments (
|
||||
staff_members (*)
|
||||
)
|
||||
''')
|
||||
.eq('company_id', companyId);
|
||||
|
||||
if (storeId != null) {
|
||||
@@ -31,9 +37,8 @@ class TaskRepository {
|
||||
}
|
||||
|
||||
if (staffId != null) {
|
||||
filterBuilder = filterBuilder.or(
|
||||
'staff_id.eq.$staffId,staff_id.is.null',
|
||||
);
|
||||
// Grazie al trigger, hai l'array pronto per il filtro senza impazzire!
|
||||
filterBuilder = filterBuilder.contains('assigned_to_ids', [staffId]);
|
||||
}
|
||||
|
||||
if (statuses != null && statuses.isNotEmpty) {
|
||||
@@ -41,13 +46,11 @@ class TaskRepository {
|
||||
filterBuilder = filterBuilder.inFilter('status', statusValues);
|
||||
}
|
||||
|
||||
// 2. FASE TRASFORMAZIONI (PostgrestTransformBuilder)
|
||||
// L'ordinamento lo facciamo sempre, quindi partiamo da qui
|
||||
// 2. FASE TRASFORMAZIONI
|
||||
var transformBuilder = filterBuilder
|
||||
.order('due_date', ascending: true, nullsFirst: false)
|
||||
.order('created_at', ascending: false, nullsFirst: false);
|
||||
|
||||
// Aggiungiamo il limite se richiesto
|
||||
if (limit != null) {
|
||||
transformBuilder = transformBuilder.limit(limit);
|
||||
}
|
||||
@@ -55,6 +58,7 @@ class TaskRepository {
|
||||
// 3. ESECUZIONE DELLA QUERY
|
||||
final response = await transformBuilder;
|
||||
|
||||
// 4. PARSING DEI DATI
|
||||
return (response as List).map((json) => TaskModel.fromMap(json)).toList();
|
||||
} catch (e) {
|
||||
throw Exception('Errore nel recupero dei task: $e');
|
||||
@@ -95,8 +99,9 @@ class TaskRepository {
|
||||
|
||||
// --- 3. AGGIORNAMENTO DEL TASK ---
|
||||
Future<TaskModel> updateTask(TaskModel task) async {
|
||||
if (task.id == null)
|
||||
if (task.id == null) {
|
||||
throw Exception('ID Task mancante. Impossibile aggiornare.');
|
||||
}
|
||||
|
||||
try {
|
||||
final taskData = task.toMap();
|
||||
@@ -133,7 +138,7 @@ class TaskRepository {
|
||||
try {
|
||||
final response = await _supabase
|
||||
.from('tasks')
|
||||
.select('*, assigned_to_staff:staff!task_assignments(*)')
|
||||
.select('*, assigned_to_staff:staff_members!task_assignments(*)')
|
||||
.eq('id', taskId)
|
||||
.single();
|
||||
return TaskModel.fromMap(response);
|
||||
|
||||
@@ -91,37 +91,46 @@ class TaskModel extends Equatable {
|
||||
}
|
||||
|
||||
// --- SERIALIZZAZIONE DA SUPABASE ---
|
||||
factory TaskModel.fromMap(Map<String, dynamic> json) {
|
||||
factory TaskModel.fromMap(Map<String, dynamic> map) {
|
||||
// 1. Gestiamo l'array nullo di Supabase trasformandolo in lista vuota
|
||||
final List<String> parsedAssignedToIds = json['assigned_to_ids'] != null
|
||||
? List<String>.from(json['assigned_to_ids'])
|
||||
final List<String> parsedAssignedToIds = map['assigned_to_ids'] != null
|
||||
? List<String>.from(map['assigned_to_ids'])
|
||||
: [];
|
||||
|
||||
// 2. Mappiamo il JOIN dello staff, se presente
|
||||
List<StaffMemberModel> parsedAssignedToStaff = [];
|
||||
if (json['assigned_to_staff'] != null) {
|
||||
final staffList = json['assigned_to_staff'] as List;
|
||||
parsedAssignedToStaff = staffList
|
||||
.map((s) => StaffMemberModel.fromMap(s as Map<String, dynamic>))
|
||||
List<StaffMemberModel> staffList = [];
|
||||
|
||||
// Gestione del JSON proveniente dal Join nidificato (es. task_assignments -> staff_members)
|
||||
if (map['task_assignments'] != null) {
|
||||
staffList = (map['task_assignments'] as List)
|
||||
.map((a) => a['staff_members'])
|
||||
.where((s) => s != null)
|
||||
.map((s) => StaffMemberModel.fromMap(s))
|
||||
.toList();
|
||||
}
|
||||
// Gestione del JSON piatto (se mai lo userai in altre chiamate RPC o viste)
|
||||
else if (map['assigned_to_staff'] != null) {
|
||||
staffList = (map['assigned_to_staff'] as List)
|
||||
.map((s) => StaffMemberModel.fromMap(s))
|
||||
.toList();
|
||||
}
|
||||
|
||||
return TaskModel(
|
||||
id: json['id'] as String?,
|
||||
companyId: json['company_id'] as String?,
|
||||
title: json['title'] as String? ?? '',
|
||||
description: json['description'] as String?,
|
||||
id: map['id'] as String?,
|
||||
companyId: map['company_id'] as String?,
|
||||
title: map['title'] as String? ?? '',
|
||||
description: map['description'] as String?,
|
||||
assignedToIds: parsedAssignedToIds,
|
||||
assignedToStaff: parsedAssignedToStaff,
|
||||
createdById: json['created_by_id'] as String?,
|
||||
dueDate: json['due_date'] != null
|
||||
? DateTime.parse(json['due_date'] as String).toLocal()
|
||||
assignedToStaff: staffList,
|
||||
createdById: map['created_by_id'] as String?,
|
||||
dueDate: map['due_date'] != null
|
||||
? DateTime.parse(map['due_date'] as String).toLocal()
|
||||
: null,
|
||||
status: TaskStatusExtension.fromString(json['status'] as String?),
|
||||
createdAt: json['created_at'] != null
|
||||
? DateTime.parse(json['created_at'] as String).toLocal()
|
||||
status: TaskStatusExtension.fromString(map['status'] as String?),
|
||||
createdAt: map['created_at'] != null
|
||||
? DateTime.parse(map['created_at'] as String).toLocal()
|
||||
: null,
|
||||
storeId: json['store_id'] as String?,
|
||||
storeId: map['store_id'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
397
lib/features/tasks/ui/task_form_screen.dart
Normal file
397
lib/features/tasks/ui/task_form_screen.dart
Normal file
@@ -0,0 +1,397 @@
|
||||
import 'package:flutter/material.dart';
|
||||
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 {
|
||||
const TaskFormScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<TaskFormCubit, TaskFormState>(
|
||||
listenWhen: (previous, current) => previous.status != current.status,
|
||||
listener: (context, state) {
|
||||
if (state.status == TaskFormStatus.success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Task salvato con successo! 🎉')),
|
||||
);
|
||||
context.pop(); // Usciamo dalla pagina
|
||||
} else if (state.status == TaskFormStatus.failure) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.errorMessage ?? 'Errore di salvataggio'),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (context, state) {
|
||||
final cubit = context.read<TaskFormCubit>();
|
||||
final isEditing = state.id != null;
|
||||
|
||||
return Scaffold(
|
||||
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),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
)
|
||||
else
|
||||
TextButton.icon(
|
||||
onPressed: state.isFormValid
|
||||
? () =>
|
||||
cubit.saveTask(
|
||||
currentUserId: 'TODO_USER_ID',
|
||||
) // Passa l'id utente loggato dal SessionCubit
|
||||
: null,
|
||||
icon: const Icon(Icons.save),
|
||||
label: const Text('Salva'),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.orange, // Il tuo colore primario
|
||||
disabledForegroundColor: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// --- 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),
|
||||
),
|
||||
),
|
||||
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
|
||||
// =========================================================================
|
||||
Widget _buildFormFields(
|
||||
BuildContext context,
|
||||
TaskFormState state,
|
||||
TaskFormCubit cubit,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// --- SCOPE TOGGLE ---
|
||||
Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).dividerColor.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
title: const Text(
|
||||
'Task Globale Aziendale',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
subtitle: const Text(
|
||||
'Visibile a tutta l\'azienda, non legato a un negozio specifico.',
|
||||
),
|
||||
value: state.isGlobal,
|
||||
activeThumbColor: Colors.orange,
|
||||
onChanged: (val) => cubit.toggleGlobalScope(val),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// --- TITOLO E DESCRIZIONE ---
|
||||
TextFormField(
|
||||
initialValue: state.title,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Titolo del Task*',
|
||||
border: OutlineInputBorder(),
|
||||
prefixIcon: Icon(Icons.title),
|
||||
),
|
||||
onChanged: cubit.updateTitle,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
initialValue: state.description,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Descrizione (opzionale)',
|
||||
border: OutlineInputBorder(),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
onChanged: cubit.updateDescription,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// --- SCADENZA ---
|
||||
ListTile(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: BorderSide(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
leading: const Icon(Icons.calendar_today, color: Colors.orange),
|
||||
title: Text(
|
||||
state.dueDate != null
|
||||
? 'Scadenza: ${state.dueDate!.day}/${state.dueDate!.month}/${state.dueDate!.year}'
|
||||
: 'Nessuna scadenza impostata',
|
||||
),
|
||||
trailing: state.dueDate != null
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => cubit.updateDueDate(null),
|
||||
)
|
||||
: null,
|
||||
onTap: () async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: state.dueDate ?? DateTime.now(),
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime(2100),
|
||||
);
|
||||
if (date != null) cubit.updateDueDate(date);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 2. SELEZIONE STAFF INLINE (PER DESKTOP/WIDE)
|
||||
// =========================================================================
|
||||
Widget _buildStaffSelectorInline(
|
||||
BuildContext context,
|
||||
TaskFormState state,
|
||||
TaskFormCubit cubit,
|
||||
) {
|
||||
return Container(
|
||||
color: Theme.of(context).cardColor,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
'Assegnazione Staff',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
children: _buildGroupedStaffList(context, state, cubit),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 3. BOTTOM SHEET SELEZIONE STAFF (PER MOBILE)
|
||||
// =========================================================================
|
||||
void _showStaffBottomSheet(BuildContext context, TaskFormCubit cubit) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (bottomSheetContext) {
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.7, // Occupa il 70% dello schermo in altezza
|
||||
minChildSize: 0.5,
|
||||
maxChildSize: 0.9,
|
||||
builder: (_, controller) {
|
||||
return BlocBuilder<TaskFormCubit, TaskFormState>(
|
||||
bloc:
|
||||
cubit, // Passiamo il cubit esistente per mantenere lo stato!
|
||||
builder: (context, state) {
|
||||
return Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
'Assegna Staff',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
controller: controller,
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
children: _buildGroupedStaffList(context, state, cubit),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// 4. GENERATORE DELLA LISTA RAGGRUPPATA (RIUTILIZZABILE)
|
||||
// =========================================================================
|
||||
List<Widget> _buildGroupedStaffList(
|
||||
BuildContext context,
|
||||
TaskFormState state,
|
||||
TaskFormCubit cubit,
|
||||
) {
|
||||
final widgets = <Widget>[];
|
||||
|
||||
if (state.groupedAvailableStaff.isEmpty) {
|
||||
return [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Nessun membro dello staff trovato.',
|
||||
style: TextStyle(fontStyle: FontStyle.italic),
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
// Iteriamo sulla mappa { "Nome Negozio" : [Lista Dipendenti] }
|
||||
for (final entry in state.groupedAvailableStaff.entries) {
|
||||
final storeName = entry.key;
|
||||
final staffList = entry.value;
|
||||
|
||||
// Verifichiamo se TUTTI i membri di questo negozio sono selezionati
|
||||
final allSelectedInStore = staffList.every(
|
||||
(staff) => state.selectedStaffIds.contains(staff.id),
|
||||
);
|
||||
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 24.0,
|
||||
bottom: 8.0,
|
||||
left: 16,
|
||||
right: 8,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
storeName.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
// IL MAGICO BOTTONE "SELEZIONA TUTTI" DEL NEGOZIO
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
cubit.toggleStoreSelection(storeName, !allSelectedInStore),
|
||||
icon: Icon(
|
||||
allSelectedInStore ? Icons.deselect : Icons.select_all,
|
||||
size: 18,
|
||||
),
|
||||
label: Text(
|
||||
allSelectedInStore ? 'Deseleziona' : 'Seleziona Tutti',
|
||||
),
|
||||
style: TextButton.styleFrom(
|
||||
visualDensity: VisualDensity.compact,
|
||||
foregroundColor: allSelectedInStore
|
||||
? Colors.grey
|
||||
: Colors.orange,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Renderizziamo i dipendenti di questo negozio usando dei Wrap con FilterChip
|
||||
widgets.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Wrap(
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: staffList.map((staff) {
|
||||
final isSelected = state.selectedStaffIds.contains(staff.id);
|
||||
return FilterChip(
|
||||
label: Text(staff.name),
|
||||
selected: isSelected,
|
||||
selectedColor: Colors.orange.withValues(alpha: 0.2),
|
||||
checkmarkColor: Colors.orange,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
onSelected: (_) => cubit.toggleStaffSelection(staff.id!),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return widgets;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user