Allow task completion even if signatories are still empty, put an indicator for completed tasks with incomplete details.
This commit is contained in:
parent
892fbee456
commit
546c254326
|
|
@ -53,6 +53,19 @@ class Task {
|
|||
// JSON serialized rich text for action taken (Quill Delta JSON encoded)
|
||||
final String? actionTaken;
|
||||
|
||||
/// Helper that indicates whether a completed task still has missing
|
||||
/// metadata such as signatories or action details. The parameter is used
|
||||
/// by UI to surface a warning icon/banner when a task has been closed but
|
||||
/// the user skipped filling out all fields.
|
||||
bool get hasIncompleteDetails {
|
||||
if (status != 'completed') return false;
|
||||
bool empty(String? v) => v == null || v.trim().isEmpty;
|
||||
return empty(requestedBy) ||
|
||||
empty(notedBy) ||
|
||||
empty(receivedBy) ||
|
||||
empty(actionTaken);
|
||||
}
|
||||
|
||||
factory Task.fromMap(Map<String, dynamic> map) {
|
||||
return Task(
|
||||
id: map['id'] as String,
|
||||
|
|
|
|||
|
|
@ -562,7 +562,9 @@ class TasksController {
|
|||
.from('tasks')
|
||||
// include all columns that must be non-null/empty before completing
|
||||
.select(
|
||||
'request_type, request_category, requested_by, noted_by, received_by, action_taken',
|
||||
// signatories are not needed for validation; action_taken is still
|
||||
// required so we include it alongside the type/category fields.
|
||||
'request_type, request_category, action_taken',
|
||||
)
|
||||
.eq('id', taskId)
|
||||
.maybeSingle();
|
||||
|
|
@ -572,9 +574,6 @@ class TasksController {
|
|||
|
||||
final rt = row['request_type'];
|
||||
final rc = row['request_category'];
|
||||
final requested = row['requested_by'];
|
||||
final noted = row['noted_by'];
|
||||
final received = row['received_by'];
|
||||
final action = row['action_taken'];
|
||||
|
||||
final missing = <String>[];
|
||||
|
|
@ -584,17 +583,13 @@ class TasksController {
|
|||
if (rc == null || (rc is String && rc.trim().isEmpty)) {
|
||||
missing.add('request category');
|
||||
}
|
||||
if (requested == null ||
|
||||
(requested is String && requested.trim().isEmpty)) {
|
||||
missing.add('requested by');
|
||||
}
|
||||
if (noted == null || (noted is String && noted.trim().isEmpty)) {
|
||||
missing.add('noted by');
|
||||
}
|
||||
if (received == null ||
|
||||
(received is String && received.trim().isEmpty)) {
|
||||
missing.add('received by');
|
||||
}
|
||||
// signatories are no longer required for completion; they can be
|
||||
// filled in later. we still require action taken to document what
|
||||
// was done.
|
||||
|
||||
// if you want to enforce action taken you can uncomment this block,
|
||||
// but current business rule only mandates request metadata. we keep
|
||||
// action taken non-null for clarity.
|
||||
if (action == null || (action is String && action.trim().isEmpty)) {
|
||||
missing.add('action taken');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -426,6 +426,28 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
|||
const SizedBox(height: 12),
|
||||
Text(description),
|
||||
],
|
||||
|
||||
// warning banner for completed tasks with missing metadata
|
||||
if (task.status == 'completed' && task.hasIncompleteDetails) ...[
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Task completed but some details are still empty.',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
// Collapsible tabbed details: Assignees / Type & Category / Signatories
|
||||
ExpansionTile(
|
||||
|
|
|
|||
|
|
@ -297,8 +297,21 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Status',
|
||||
cellBuilder: (context, task) =>
|
||||
cellBuilder: (context, task) => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_StatusBadge(status: task.status),
|
||||
if (task.status == 'completed' &&
|
||||
task.hasIncompleteDetails) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
TasQColumn<Task>(
|
||||
header: 'Timestamp',
|
||||
|
|
@ -352,6 +365,15 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_StatusBadge(status: task.status),
|
||||
if (task.status == 'completed' &&
|
||||
task.hasIncompleteDetails) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
size: 16,
|
||||
color: Colors.orange,
|
||||
),
|
||||
],
|
||||
if (showTyping) ...[
|
||||
const SizedBox(width: 6),
|
||||
TypingDots(
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import 'package:tasq/models/task.dart';
|
|||
import 'package:tasq/models/ticket.dart';
|
||||
import 'package:tasq/models/user_office.dart';
|
||||
import 'package:tasq/models/team.dart';
|
||||
import 'package:tasq/utils/app_time.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:tasq/models/team_member.dart';
|
||||
import 'package:tasq/providers/notifications_provider.dart';
|
||||
import 'package:tasq/providers/profile_provider.dart';
|
||||
|
|
@ -23,6 +25,7 @@ import 'package:tasq/screens/tasks/tasks_list_screen.dart';
|
|||
import 'package:tasq/screens/tickets/tickets_list_screen.dart';
|
||||
import 'package:tasq/screens/tickets/ticket_detail_screen.dart';
|
||||
import 'package:tasq/screens/teams/teams_screen.dart';
|
||||
import 'package:tasq/screens/shared/permissions_screen.dart';
|
||||
import 'package:tasq/providers/teams_provider.dart';
|
||||
import 'package:tasq/widgets/app_shell.dart';
|
||||
|
||||
|
|
@ -40,6 +43,17 @@ class FakeNotificationsController implements NotificationsController {
|
|||
String? taskId,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> createNotification({
|
||||
required List<String> userIds,
|
||||
required String type,
|
||||
required String actorId,
|
||||
Map<String, dynamic>? fields,
|
||||
String? pushTitle,
|
||||
String? pushBody,
|
||||
Map<String, dynamic>? pushData,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
Future<void> markRead(String id) async {}
|
||||
|
||||
|
|
@ -48,6 +62,21 @@ class FakeNotificationsController implements NotificationsController {
|
|||
|
||||
@override
|
||||
Future<void> markReadForTask(String taskId) async {}
|
||||
|
||||
@override
|
||||
Future<void> registerFcmToken(String token) async {}
|
||||
|
||||
@override
|
||||
Future<void> unregisterFcmToken(String token) async {}
|
||||
|
||||
@override
|
||||
Future<void> sendPush({
|
||||
List<String>? tokens,
|
||||
List<String>? userIds,
|
||||
required String title,
|
||||
required String body,
|
||||
Map<String, dynamic>? data,
|
||||
}) async {}
|
||||
}
|
||||
|
||||
// test doubles for controllers that allow us to intercept create operations
|
||||
|
|
@ -228,6 +257,39 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('Completed task with missing details shows warning icon', (
|
||||
tester,
|
||||
) async {
|
||||
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||
AppTime.initialize();
|
||||
|
||||
// create a finished task with no signatories or actionTaken
|
||||
final now = DateTime.now();
|
||||
final incompleteTask = Task.fromMap({
|
||||
'id': 't1',
|
||||
'status': 'completed',
|
||||
'title': 'Incomplete',
|
||||
'description': '',
|
||||
'created_at': now.toIso8601String(),
|
||||
'priority': 1,
|
||||
});
|
||||
|
||||
await _pumpScreen(
|
||||
tester,
|
||||
const TasksListScreen(),
|
||||
overrides: [
|
||||
...baseOverrides(),
|
||||
tasksProvider.overrideWith((ref) => Stream.value([incompleteTask])),
|
||||
],
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// warning icon should appear next to status badge in both desktop and
|
||||
// mobile rows; in this layout smoke test we just ensure at least one is
|
||||
// present.
|
||||
expect(find.byIcon(Icons.warning_amber_rounded), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets('Offices screen renders without layout exceptions', (
|
||||
tester,
|
||||
) async {
|
||||
|
|
@ -262,15 +324,41 @@ void main() {
|
|||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('App shell shows Geofence test nav item for admin', (
|
||||
testWidgets('Permissions screen renders without layout exceptions', (
|
||||
tester,
|
||||
) async {
|
||||
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||
await _pumpScreen(
|
||||
tester,
|
||||
const AppScaffold(child: SizedBox()),
|
||||
const PermissionsScreen(),
|
||||
overrides: baseOverrides(),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('App shell shows Geofence test nav item for admin', (
|
||||
tester,
|
||||
) async {
|
||||
await _setSurfaceSize(tester, const Size(1024, 800));
|
||||
// AppScaffold needs a GoRouter state above it; create a minimal router.
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: baseOverrides(),
|
||||
child: MaterialApp.router(
|
||||
routerConfig: GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) =>
|
||||
const AppScaffold(child: SizedBox()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Geofence test'), findsOneWidget);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/providers/tasks_provider.dart';
|
||||
import 'package:tasq/models/task.dart';
|
||||
|
||||
// Minimal fake supabase client similar to integration test work,
|
||||
// only implements the methods used by TasksController.
|
||||
|
|
@ -101,7 +102,7 @@ void main() {
|
|||
);
|
||||
});
|
||||
|
||||
test('cannot complete when signatories or action taken missing', () async {
|
||||
test('cannot complete when action taken is missing', () async {
|
||||
// insert a task that has the basic request metadata but nothing else
|
||||
final row = {
|
||||
'id': 'tsk-3',
|
||||
|
|
@ -111,13 +112,14 @@ void main() {
|
|||
};
|
||||
fake.tables['tasks']!.add(row);
|
||||
|
||||
// still missing signatories/actionTaken
|
||||
// missing actionTaken should still prevent completion even though
|
||||
// signatories are not required.
|
||||
expect(
|
||||
() => controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed'),
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
// add signatories but actionTaken still missing
|
||||
// add some signatories but no actionTaken yet
|
||||
await controller.updateTask(
|
||||
taskId: 'tsk-3',
|
||||
requestedBy: 'Alice',
|
||||
|
|
@ -129,7 +131,9 @@ void main() {
|
|||
throwsA(isA<Exception>()),
|
||||
);
|
||||
|
||||
// add action taken (empty JSON string)
|
||||
// once action taken is provided completion should succeed even if
|
||||
// signatories remain empty (they already have values here, but the
|
||||
// previous checks show they aren't required).
|
||||
await controller.updateTask(taskId: 'tsk-3', actionTaken: '{}');
|
||||
await controller.updateTaskStatus(taskId: 'tsk-3', status: 'completed');
|
||||
expect(
|
||||
|
|
@ -143,11 +147,12 @@ void main() {
|
|||
final row = {'id': 'tsk-2', 'status': 'queued'};
|
||||
fake.tables['tasks']!.add(row);
|
||||
|
||||
// update metadata via updateTask
|
||||
// update metadata via updateTask including actionTaken
|
||||
await controller.updateTask(
|
||||
taskId: 'tsk-2',
|
||||
requestType: 'Repair',
|
||||
requestCategory: 'Hardware',
|
||||
actionTaken: '{}',
|
||||
);
|
||||
|
||||
await controller.updateTaskStatus(taskId: 'tsk-2', status: 'completed');
|
||||
|
|
@ -156,5 +161,49 @@ void main() {
|
|||
'completed',
|
||||
);
|
||||
});
|
||||
|
||||
test('Task.hasIncompleteDetails flag works correctly', () {
|
||||
final now = DateTime.now();
|
||||
final base = Task(
|
||||
id: 'x',
|
||||
ticketId: null,
|
||||
taskNumber: null,
|
||||
title: 't',
|
||||
description: '',
|
||||
officeId: null,
|
||||
status: 'completed',
|
||||
priority: 1,
|
||||
queueOrder: null,
|
||||
createdAt: now,
|
||||
creatorId: null,
|
||||
startedAt: null,
|
||||
completedAt: now,
|
||||
// leave all optional metadata null
|
||||
);
|
||||
expect(base.hasIncompleteDetails, isTrue);
|
||||
|
||||
final full = Task(
|
||||
id: 'x',
|
||||
ticketId: null,
|
||||
taskNumber: null,
|
||||
title: 't',
|
||||
description: '',
|
||||
officeId: null,
|
||||
status: 'completed',
|
||||
priority: 1,
|
||||
queueOrder: null,
|
||||
createdAt: now,
|
||||
creatorId: null,
|
||||
startedAt: null,
|
||||
completedAt: now,
|
||||
requestedBy: 'A',
|
||||
notedBy: 'B',
|
||||
receivedBy: 'C',
|
||||
requestType: 'foo',
|
||||
requestCategory: 'bar',
|
||||
actionTaken: '{}',
|
||||
);
|
||||
expect(full.hasIncompleteDetails, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user