diff --git a/lib/widgets/task_assignment_section.dart b/lib/widgets/task_assignment_section.dart index d16c9825..7c3c4ebe 100644 --- a/lib/widgets/task_assignment_section.dart +++ b/lib/widgets/task_assignment_section.dart @@ -5,6 +5,7 @@ import '../models/profile.dart'; import '../providers/profile_provider.dart'; import '../providers/tasks_provider.dart'; import '../theme/app_surfaces.dart'; +import '../utils/snackbar.dart'; class TaskAssignmentSection extends ConsumerWidget { const TaskAssignmentSection({ @@ -39,36 +40,11 @@ class TaskAssignmentSection extends ConsumerWidget { .toList(); final assignedIds = assignedForTask.map((a) => a.userId).toSet(); - final activeTaskIds = tasks - .where( - (task) => task.status == 'queued' || task.status == 'in_progress', - ) - .map((task) => task.id) - .toSet(); - - final activeAssignmentsByUser = >{}; - for (final assignment in assignments) { - if (!activeTaskIds.contains(assignment.taskId)) { - continue; - } - activeAssignmentsByUser - .putIfAbsent(assignment.userId, () => {}) - .add(assignment.taskId); - } - - bool isVacant(String userId) { - final active = activeAssignmentsByUser[userId]; - if (active == null || active.isEmpty) { - return true; - } - return active.length == 1 && active.contains(taskId); - } - - final eligibleStaff = itStaff - .where( - (profile) => isVacant(profile.id) || assignedIds.contains(profile.id), - ) - .toList(); + // With concurrent assignments allowed we no longer restrict the + // eligible list based on whether the staff member already has an active + // task. All IT staff can be assigned to any number of tasks at once. Keep + // the original sort order derived from itStaff rather than filtering. + final eligibleStaff = List.from(itStaff); return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -137,6 +113,9 @@ class TaskAssignmentSection extends ConsumerWidget { Set assignedIds, String? taskTicketId, ) async { + // If there are no IT staff at all we still need to bail out. We don't + // consider vacancy anymore because everyone is eligible, so the only + // reason for the dialog to be unusable is an empty staff list. if (eligibleStaff.isEmpty && assignedIds.isEmpty) { await showDialog( context: context, @@ -144,7 +123,7 @@ class TaskAssignmentSection extends ConsumerWidget { return AlertDialog( shape: AppSurfaces.of(context).dialogShape, title: const Text('Assign IT Staff'), - content: const Text('No vacant IT staff available.'), + content: const Text('No IT staff available.'), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), @@ -161,6 +140,7 @@ class TaskAssignmentSection extends ConsumerWidget { await showDialog( context: context, builder: (dialogContext) { + var isSaving = false; return StatefulBuilder( builder: (context, setState) { return AlertDialog( @@ -187,15 +167,17 @@ class TaskAssignmentSection extends ConsumerWidget { horizontal: 12, vertical: 2, ), - onChanged: (value) { - setState(() { - if (value == true) { - selection.add(staff.id); - } else { - selection.remove(staff.id); - } - }); - }, + onChanged: isSaving + ? null + : (value) { + setState(() { + if (value == true) { + selection.add(staff.id); + } else { + selection.remove(staff.id); + } + }); + }, ); }, ), @@ -203,24 +185,59 @@ class TaskAssignmentSection extends ConsumerWidget { ), actions: [ TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), + onPressed: isSaving + ? null + : () => Navigator.of(dialogContext).pop(), child: const Text('Cancel'), ), FilledButton( - onPressed: () async { - await ref - .read(taskAssignmentsControllerProvider) - .replaceAssignments( - taskId: taskId, - ticketId: taskTicketId, - newUserIds: selection.toList(), - currentUserIds: assignedIds.toList(), - ); - if (context.mounted) { - Navigator.of(dialogContext).pop(); - } - }, - child: const Text('Save'), + onPressed: isSaving + ? null + : () async { + setState(() => isSaving = true); + try { + await ref + .read(taskAssignmentsControllerProvider) + .replaceAssignments( + taskId: taskId, + ticketId: taskTicketId, + newUserIds: selection.toList(), + currentUserIds: assignedIds.toList(), + ); + if (context.mounted) { + showSuccessSnackBar( + context, + 'Assignment saved successfully', + ); + } + if (context.mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + if (context.mounted) { + showErrorSnackBar( + context, + 'Failed to save assignment', + ); + } + } finally { + if (context.mounted) { + setState(() => isSaving = false); + } + } + }, + child: isSaving + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Theme.of(context).colorScheme.onPrimary, + ), + ), + ) + : const Text('Save'), ), ], ); diff --git a/test/task_assignment_section_test.dart b/test/task_assignment_section_test.dart new file mode 100644 index 00000000..e03ab64a --- /dev/null +++ b/test/task_assignment_section_test.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'dart:async'; + +import 'package:tasq/models/profile.dart'; +import 'package:tasq/models/task.dart'; +import 'package:tasq/models/task_assignment.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/providers/tasks_provider.dart'; +import 'package:tasq/utils/snackbar.dart'; +import 'package:tasq/utils/app_time.dart'; +import 'package:tasq/widgets/task_assignment_section.dart'; + +void main() { + setUp(() { + AppTime.initialize(); + }); + + testWidgets( + 'allows assigning IT staff to a task even when they are already on another active task', + (tester) async { + // prepare two it_staff profiles + final profile1 = Profile( + id: 'u1', + fullName: 'User One', + role: 'it_staff', + ); + final profile2 = Profile( + id: 'u2', + fullName: 'User Two', + role: 'it_staff', + ); + + // create two active tasks; staff u1 is already assigned to the first + final now = DateTime.now(); + final task1 = Task.fromMap({ + 'id': 't1', + 'status': 'queued', + 'title': 'First task', + 'description': '', + 'created_at': now.toIso8601String(), + 'priority': 1, + }); + final task2 = Task.fromMap({ + 'id': 't2', + 'status': 'queued', + 'title': 'Second task', + 'description': '', + 'created_at': now.toIso8601String(), + 'priority': 1, + }); + + final assignment1 = TaskAssignment( + taskId: 't1', + userId: 'u1', + createdAt: now, + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profilesProvider.overrideWith( + (ref) => Stream.value([profile1, profile2]), + ), + tasksProvider.overrideWith((ref) => Stream.value([task1, task2])), + taskAssignmentsProvider.overrideWith( + (ref) => Stream.value([assignment1]), + ), + ], + child: MaterialApp( + home: Scaffold( + body: TaskAssignmentSection(taskId: 't2', canAssign: true), + ), + ), + ), + ); + // allow provider streams to deliver their first value + await tester.pumpAndSettle(); + + // open the assignment dialog + await tester.tap(find.text('Assign')); + await tester.pumpAndSettle(); + + // both IT staff users should be listed even though u1 is already busy + expect(find.byType(CheckboxListTile), findsNWidgets(2)); + expect(find.widgetWithText(CheckboxListTile, 'User One'), findsOneWidget); + expect(find.widgetWithText(CheckboxListTile, 'User Two'), findsOneWidget); + }, + ); + + testWidgets('dialog shows message when there are no IT staff at all', ( + tester, + ) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + profilesProvider.overrideWith( + (ref) => Stream.value(const []), + ), + tasksProvider.overrideWith( + (ref) => Stream.value([ + Task.fromMap({ + 'id': 't', + 'status': 'queued', + 'title': 't', + 'description': '', + 'created_at': DateTime.now().toIso8601String(), + 'priority': 1, + }), + ]), + ), + taskAssignmentsProvider.overrideWith( + (ref) => Stream.value(const []), + ), + ], + child: MaterialApp( + home: Scaffold( + body: TaskAssignmentSection(taskId: 't', canAssign: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Assign')); + await tester.pumpAndSettle(); + + expect(find.text('No IT staff available.'), findsOneWidget); + }); + + testWidgets('save button shows spinner and snack on success', (tester) async { + final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff'); + final task = Task.fromMap({ + 'id': 't', + 'status': 'queued', + 'title': 't', + 'description': '', + 'created_at': DateTime.now().toIso8601String(), + 'priority': 1, + }); + final completer = Completer(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profilesProvider.overrideWith((ref) => Stream.value([profile])), + tasksProvider.overrideWith((ref) => Stream.value([task])), + taskAssignmentsProvider.overrideWith( + (ref) => Stream.value(const []), + ), + taskAssignmentsControllerProvider.overrideWith( + (ref) => _DelayedController(completer), + ), + ], + child: MaterialApp( + home: Scaffold( + body: TaskAssignmentSection(taskId: 't', canAssign: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // open dialog and select the only staff + await tester.tap(find.text('Assign')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + + // tap save, spinner should appear + await tester.tap(find.text('Save')); + await tester.pump(); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + + // resolve future and let dialog close + completer.complete(); + await tester.pumpAndSettle(); + + // snackbar with success message should be visible + expect(find.text('Assignment saved successfully'), findsOneWidget); + }); + + testWidgets('error snackbar shows when save fails', (tester) async { + final profile = Profile(id: 'u1', fullName: 'User One', role: 'it_staff'); + final task = Task.fromMap({ + 'id': 't', + 'status': 'queued', + 'title': 't', + 'description': '', + 'created_at': DateTime.now().toIso8601String(), + 'priority': 1, + }); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + profilesProvider.overrideWith((ref) => Stream.value([profile])), + tasksProvider.overrideWith((ref) => Stream.value([task])), + taskAssignmentsProvider.overrideWith( + (ref) => Stream.value(const []), + ), + taskAssignmentsControllerProvider.overrideWith( + (ref) => _FailingController(), + ), + ], + child: MaterialApp( + home: Scaffold( + body: TaskAssignmentSection(taskId: 't', canAssign: true), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Assign')); + await tester.pumpAndSettle(); + await tester.tap(find.byType(CheckboxListTile)); + await tester.pump(); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('Failed to save assignment'), findsOneWidget); + }); +} + +// helper fakes used by the tests below +class _DelayedController implements TaskAssignmentsController { + _DelayedController(this._completer); + final Completer _completer; + + @override + Future replaceAssignments({ + required String taskId, + required String? ticketId, + required List newUserIds, + required List currentUserIds, + }) { + return _completer.future; + } + + @override + Future removeAssignment({ + required String taskId, + required String userId, + }) { + // not needed for these tests + return Future.value(); + } +} + +class _FailingController implements TaskAssignmentsController { + @override + Future replaceAssignments({ + required String taskId, + required String? ticketId, + required List newUserIds, + required List currentUserIds, + }) async { + throw Exception('server error'); + } + + @override + Future removeAssignment({ + required String taskId, + required String userId, + }) { + return Future.value(); + } +}