Auto Task Assignment
This commit is contained in:
parent
372928d8e7
commit
5ec57a1cec
30
lib/models/task_activity_log.dart
Normal file
30
lib/models/task_activity_log.dart
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
|
class TaskActivityLog {
|
||||||
|
TaskActivityLog({
|
||||||
|
required this.id,
|
||||||
|
required this.taskId,
|
||||||
|
this.actorId,
|
||||||
|
required this.actionType,
|
||||||
|
this.meta,
|
||||||
|
required this.createdAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String id;
|
||||||
|
final String taskId;
|
||||||
|
final String? actorId;
|
||||||
|
final String actionType; // created, assigned, reassigned, started, completed
|
||||||
|
final Map<String, dynamic>? meta;
|
||||||
|
final DateTime createdAt;
|
||||||
|
|
||||||
|
factory TaskActivityLog.fromMap(Map<String, dynamic> map) {
|
||||||
|
return TaskActivityLog(
|
||||||
|
id: map['id'] as String,
|
||||||
|
taskId: map['task_id'] as String,
|
||||||
|
actorId: map['actor_id'] as String?,
|
||||||
|
actionType: map['action_type'] as String? ?? 'unknown',
|
||||||
|
meta: map['meta'] as Map<String, dynamic>?,
|
||||||
|
createdAt: AppTime.parse(map['created_at'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,9 +9,11 @@ import 'supabase_provider.dart';
|
||||||
|
|
||||||
final currentUserIdProvider = Provider<String?>((ref) {
|
final currentUserIdProvider = Provider<String?>((ref) {
|
||||||
final authState = ref.watch(authStateChangesProvider);
|
final authState = ref.watch(authStateChangesProvider);
|
||||||
return authState.maybeWhen(
|
// Be explicit about loading/error to avoid dynamic dispatch problems.
|
||||||
|
return authState.when(
|
||||||
data: (state) => state.session?.user.id,
|
data: (state) => state.session?.user.id,
|
||||||
orElse: () => ref.watch(sessionProvider)?.user.id,
|
loading: () => ref.watch(sessionProvider)?.user.id,
|
||||||
|
error: (_, __) => ref.watch(sessionProvider)?.user.id,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@ import 'dart:async';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import '../models/task.dart';
|
import '../models/task.dart';
|
||||||
|
import '../models/task_activity_log.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../models/task_assignment.dart';
|
import '../models/task_assignment.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
import 'tickets_provider.dart';
|
import 'tickets_provider.dart';
|
||||||
import 'user_offices_provider.dart';
|
import 'user_offices_provider.dart';
|
||||||
|
import '../utils/app_time.dart';
|
||||||
|
|
||||||
/// Task query parameters for server-side pagination and filtering.
|
/// Task query parameters for server-side pagination and filtering.
|
||||||
class TaskQuery {
|
class TaskQuery {
|
||||||
|
|
@ -179,6 +181,18 @@ final taskAssignmentsProvider = StreamProvider<List<TaskAssignment>>((ref) {
|
||||||
.map((rows) => rows.map(TaskAssignment.fromMap).toList());
|
.map((rows) => rows.map(TaskAssignment.fromMap).toList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Stream of activity logs for a single task.
|
||||||
|
final taskActivityLogsProvider =
|
||||||
|
StreamProvider.family<List<TaskActivityLog>, String>((ref, taskId) {
|
||||||
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
return client
|
||||||
|
.from('task_activity_logs')
|
||||||
|
.stream(primaryKey: ['id'])
|
||||||
|
.eq('task_id', taskId)
|
||||||
|
.order('created_at', ascending: false)
|
||||||
|
.map((rows) => rows.map((r) => TaskActivityLog.fromMap(r)).toList());
|
||||||
|
});
|
||||||
|
|
||||||
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
|
|
@ -196,30 +210,48 @@ class TasksController {
|
||||||
|
|
||||||
final SupabaseClient _client;
|
final SupabaseClient _client;
|
||||||
|
|
||||||
Future<void> updateTaskStatus({
|
|
||||||
required String taskId,
|
|
||||||
required String status,
|
|
||||||
}) async {
|
|
||||||
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createTask({
|
Future<void> createTask({
|
||||||
required String title,
|
required String title,
|
||||||
required String description,
|
required String description,
|
||||||
required String officeId,
|
String? officeId,
|
||||||
|
String? ticketId,
|
||||||
}) async {
|
}) async {
|
||||||
final actorId = _client.auth.currentUser?.id;
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
if (officeId != null) payload['office_id'] = officeId;
|
||||||
|
if (ticketId != null) payload['ticket_id'] = ticketId;
|
||||||
|
|
||||||
final data = await _client
|
final data = await _client
|
||||||
.from('tasks')
|
.from('tasks')
|
||||||
.insert({
|
.insert(payload)
|
||||||
'title': title,
|
|
||||||
'description': description,
|
|
||||||
'office_id': officeId,
|
|
||||||
})
|
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
final taskId = data['id'] as String?;
|
final taskId = data['id'] as String?;
|
||||||
if (taskId == null) return;
|
if (taskId == null) return;
|
||||||
|
|
||||||
|
// Activity log: created
|
||||||
|
try {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'created',
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assignment should run once on creation (best-effort).
|
||||||
|
try {
|
||||||
|
await _autoAssignTask(taskId: taskId, officeId: officeId ?? '');
|
||||||
|
} catch (e, st) {
|
||||||
|
// keep creation successful but surface the error in logs for debugging
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('autoAssignTask failed for task=$taskId: $e\n$st');
|
||||||
|
}
|
||||||
|
|
||||||
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
|
unawaited(_notifyCreated(taskId: taskId, actorId: actorId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -269,6 +301,238 @@ class TasksController {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateTaskStatus({
|
||||||
|
required String taskId,
|
||||||
|
required String status,
|
||||||
|
}) async {
|
||||||
|
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
||||||
|
|
||||||
|
// Log important status transitions
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
if (status == 'in_progress') {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'started',
|
||||||
|
});
|
||||||
|
} else if (status == 'completed') {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'completed',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore logging failures
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assignment logic executed once on creation.
|
||||||
|
Future<void> _autoAssignTask({
|
||||||
|
required String taskId,
|
||||||
|
required String officeId,
|
||||||
|
}) async {
|
||||||
|
if (officeId.isEmpty) return;
|
||||||
|
|
||||||
|
final now = AppTime.now();
|
||||||
|
final startOfDay = DateTime(now.year, now.month, now.day);
|
||||||
|
final nextDay = startOfDay.add(const Duration(days: 1));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1) Find teams covering the office
|
||||||
|
final teamsRows =
|
||||||
|
(await _client.from('teams').select()) as List<dynamic>? ?? [];
|
||||||
|
final teamIds = teamsRows
|
||||||
|
.where((r) => (r['office_ids'] as List?)?.contains(officeId) == true)
|
||||||
|
.map((r) => r['id'] as String)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
if (teamIds.isEmpty) return;
|
||||||
|
|
||||||
|
// 2) Get members of those teams
|
||||||
|
final memberRows =
|
||||||
|
(await _client
|
||||||
|
.from('team_members')
|
||||||
|
.select('user_id')
|
||||||
|
.inFilter('team_id', teamIds))
|
||||||
|
as List<dynamic>? ??
|
||||||
|
[];
|
||||||
|
final candidateIds = memberRows
|
||||||
|
.map((r) => r['user_id'] as String)
|
||||||
|
.toSet()
|
||||||
|
.toList();
|
||||||
|
if (candidateIds.isEmpty) return;
|
||||||
|
|
||||||
|
// 3) Filter by "On Duty" (have a check-in record for today)
|
||||||
|
final dsRows =
|
||||||
|
(await _client
|
||||||
|
.from('duty_schedules')
|
||||||
|
.select('user_id, check_in_at')
|
||||||
|
.inFilter('user_id', candidateIds))
|
||||||
|
as List<dynamic>? ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
final Map<String, DateTime> onDuty = {};
|
||||||
|
for (final r in dsRows) {
|
||||||
|
final userId = r['user_id'] as String?;
|
||||||
|
final checkIn = r['check_in_at'] as String?;
|
||||||
|
if (userId == null || checkIn == null) continue;
|
||||||
|
final dt = DateTime.tryParse(checkIn);
|
||||||
|
if (dt == null) continue;
|
||||||
|
if (dt.isAfter(startOfDay.subtract(const Duration(seconds: 1))) &&
|
||||||
|
dt.isBefore(nextDay.add(const Duration(seconds: 1)))) {
|
||||||
|
onDuty[userId] = dt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (onDuty.isEmpty) {
|
||||||
|
// record a failed auto-assign attempt for observability
|
||||||
|
try {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': null,
|
||||||
|
'action_type': 'auto_assign_failed',
|
||||||
|
'meta': {'reason': 'no_on_duty_candidates'},
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) For each on-duty user compute completed_tasks_count for today
|
||||||
|
final List<_Candidate> candidates = [];
|
||||||
|
for (final userId in onDuty.keys) {
|
||||||
|
// get task ids assigned to user
|
||||||
|
final taRows =
|
||||||
|
(await _client
|
||||||
|
.from('task_assignments')
|
||||||
|
.select('task_id')
|
||||||
|
.eq('user_id', userId))
|
||||||
|
as List<dynamic>? ??
|
||||||
|
[];
|
||||||
|
final assignedTaskIds = taRows
|
||||||
|
.map((r) => r['task_id'] as String)
|
||||||
|
.toList();
|
||||||
|
int completedCount = 0;
|
||||||
|
if (assignedTaskIds.isNotEmpty) {
|
||||||
|
final tasksRows =
|
||||||
|
(await _client
|
||||||
|
.from('tasks')
|
||||||
|
.select('id')
|
||||||
|
.inFilter('id', assignedTaskIds)
|
||||||
|
.gte('completed_at', startOfDay.toIso8601String())
|
||||||
|
.lt('completed_at', nextDay.toIso8601String()))
|
||||||
|
as List<dynamic>? ??
|
||||||
|
[];
|
||||||
|
completedCount = tasksRows.length;
|
||||||
|
}
|
||||||
|
candidates.add(
|
||||||
|
_Candidate(
|
||||||
|
userId: userId,
|
||||||
|
checkInAt: onDuty[userId]!,
|
||||||
|
completedToday: completedCount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty) {
|
||||||
|
try {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': null,
|
||||||
|
'action_type': 'auto_assign_failed',
|
||||||
|
'meta': {'reason': 'no_eligible_candidates'},
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5) Sort: latest check-in first (desc), then lowest completed_today
|
||||||
|
candidates.sort((a, b) {
|
||||||
|
final c = b.checkInAt.compareTo(a.checkInAt);
|
||||||
|
if (c != 0) return c;
|
||||||
|
return a.completedToday.compareTo(b.completedToday);
|
||||||
|
});
|
||||||
|
|
||||||
|
final chosen = candidates.first;
|
||||||
|
|
||||||
|
// 6) Insert assignment + activity log + notification
|
||||||
|
await _client.from('task_assignments').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'user_id': chosen.userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': null,
|
||||||
|
'action_type': 'assigned',
|
||||||
|
'meta': {'auto': true, 'user_id': chosen.userId},
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _client.from('notifications').insert({
|
||||||
|
'user_id': chosen.userId,
|
||||||
|
'actor_id': null,
|
||||||
|
'task_id': taskId,
|
||||||
|
'type': 'assignment',
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
} catch (e, st) {
|
||||||
|
// Log error for visibility and record a failed auto-assign activity
|
||||||
|
// ignore: avoid_print
|
||||||
|
print('autoAssignTask error for task=$taskId: $e\n$st');
|
||||||
|
try {
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': null,
|
||||||
|
'action_type': 'auto_assign_failed',
|
||||||
|
'meta': {'reason': 'exception', 'error': e.toString()},
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public DTO used by unit tests to validate selection logic.
|
||||||
|
class AutoAssignCandidate {
|
||||||
|
AutoAssignCandidate({
|
||||||
|
required this.userId,
|
||||||
|
required this.checkInAt,
|
||||||
|
required this.completedToday,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String userId;
|
||||||
|
final DateTime checkInAt;
|
||||||
|
final int completedToday;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Choose the best candidate according to auto-assignment rules:
|
||||||
|
/// - latest check-in first (late-comer priority)
|
||||||
|
/// - tie-breaker: lowest completedTasks (today)
|
||||||
|
/// Returns the chosen userId or null when candidates is empty.
|
||||||
|
String? chooseAutoAssignCandidate(List<AutoAssignCandidate> candidates) {
|
||||||
|
if (candidates.isEmpty) return null;
|
||||||
|
final list = List<AutoAssignCandidate>.from(candidates);
|
||||||
|
list.sort((a, b) {
|
||||||
|
final c = b.checkInAt.compareTo(a.checkInAt);
|
||||||
|
if (c != 0) return c;
|
||||||
|
return a.completedToday.compareTo(b.completedToday);
|
||||||
|
});
|
||||||
|
return list.first.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Candidate {
|
||||||
|
_Candidate({
|
||||||
|
required this.userId,
|
||||||
|
required this.checkInAt,
|
||||||
|
required this.completedToday,
|
||||||
|
});
|
||||||
|
final String userId;
|
||||||
|
final DateTime checkInAt;
|
||||||
|
final int completedToday;
|
||||||
}
|
}
|
||||||
|
|
||||||
class TaskAssignmentsController {
|
class TaskAssignmentsController {
|
||||||
|
|
@ -292,6 +556,25 @@ class TaskAssignmentsController {
|
||||||
.map((userId) => {'task_id': taskId, 'user_id': userId})
|
.map((userId) => {'task_id': taskId, 'user_id': userId})
|
||||||
.toList();
|
.toList();
|
||||||
await _client.from('task_assignments').insert(rows);
|
await _client.from('task_assignments').insert(rows);
|
||||||
|
|
||||||
|
// Insert activity log(s) for assignment(s).
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
final logRows = toAdd
|
||||||
|
.map(
|
||||||
|
(userId) => {
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'assigned',
|
||||||
|
'meta': {'user_id': userId},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
await _client.from('task_activity_logs').insert(logRows);
|
||||||
|
} catch (_) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
|
||||||
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
|
await _notifyAssigned(taskId: taskId, ticketId: ticketId, userIds: toAdd);
|
||||||
}
|
}
|
||||||
if (toRemove.isNotEmpty) {
|
if (toRemove.isNotEmpty) {
|
||||||
|
|
@ -300,6 +583,19 @@ class TaskAssignmentsController {
|
||||||
.delete()
|
.delete()
|
||||||
.eq('task_id', taskId)
|
.eq('task_id', taskId)
|
||||||
.inFilter('user_id', toRemove);
|
.inFilter('user_id', toRemove);
|
||||||
|
|
||||||
|
// Record a reassignment event (who removed -> who added)
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
await _client.from('task_activity_logs').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'reassigned',
|
||||||
|
'meta': {'from': toRemove, 'to': toAdd},
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import '../models/ticket_message.dart';
|
||||||
import 'profile_provider.dart';
|
import 'profile_provider.dart';
|
||||||
import 'supabase_provider.dart';
|
import 'supabase_provider.dart';
|
||||||
import 'user_offices_provider.dart';
|
import 'user_offices_provider.dart';
|
||||||
|
import 'tasks_provider.dart';
|
||||||
|
|
||||||
final officesProvider = StreamProvider<List<Office>>((ref) {
|
final officesProvider = StreamProvider<List<Office>>((ref) {
|
||||||
final client = ref.watch(supabaseClientProvider);
|
final client = ref.watch(supabaseClientProvider);
|
||||||
|
|
@ -334,6 +335,39 @@ class TicketsController {
|
||||||
required String status,
|
required String status,
|
||||||
}) async {
|
}) async {
|
||||||
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
await _client.from('tickets').update({'status': status}).eq('id', ticketId);
|
||||||
|
|
||||||
|
// If ticket is promoted, create a linked Task (only once) — the
|
||||||
|
// TasksController.createTask already runs auto-assignment on creation.
|
||||||
|
if (status == 'promoted') {
|
||||||
|
try {
|
||||||
|
final existing = await _client
|
||||||
|
.from('tasks')
|
||||||
|
.select('id')
|
||||||
|
.eq('ticket_id', ticketId)
|
||||||
|
.maybeSingle();
|
||||||
|
if (existing != null) return;
|
||||||
|
|
||||||
|
final ticketRow = await _client
|
||||||
|
.from('tickets')
|
||||||
|
.select('subject, description, office_id')
|
||||||
|
.eq('id', ticketId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
final title = (ticketRow?['subject'] as String?) ?? 'Task from ticket';
|
||||||
|
final description = (ticketRow?['description'] as String?) ?? '';
|
||||||
|
final officeId = ticketRow?['office_id'] as String?;
|
||||||
|
|
||||||
|
final tasksCtrl = TasksController(_client);
|
||||||
|
await tasksCtrl.createTask(
|
||||||
|
title: title,
|
||||||
|
description: description,
|
||||||
|
officeId: officeId,
|
||||||
|
ticketId: ticketId,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
// best-effort — don't fail the ticket status update
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||||
refreshListenable: notifier,
|
refreshListenable: notifier,
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
final authState = ref.read(authStateChangesProvider);
|
final authState = ref.read(authStateChangesProvider);
|
||||||
final session = authState.maybeWhen(
|
final session = authState.when(
|
||||||
data: (state) => state.session,
|
data: (state) => state.session,
|
||||||
orElse: () => ref.read(sessionProvider),
|
loading: () => ref.read(sessionProvider),
|
||||||
|
error: (_, __) => ref.read(sessionProvider),
|
||||||
);
|
);
|
||||||
final isAuthRoute =
|
final isAuthRoute =
|
||||||
state.fullPath == '/login' || state.fullPath == '/signup';
|
state.fullPath == '/login' || state.fullPath == '/signup';
|
||||||
|
|
@ -150,7 +151,7 @@ class RouterNotifier extends ChangeNotifier {
|
||||||
RouterNotifier(this.ref) {
|
RouterNotifier(this.ref) {
|
||||||
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
|
_authSub = ref.listen(authStateChangesProvider, (previous, next) {
|
||||||
// Enforce auth-level ban when a session becomes available.
|
// Enforce auth-level ban when a session becomes available.
|
||||||
next.maybeWhen(
|
next.when(
|
||||||
data: (authState) {
|
data: (authState) {
|
||||||
final session = authState.session;
|
final session = authState.session;
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
|
|
@ -158,7 +159,8 @@ class RouterNotifier extends ChangeNotifier {
|
||||||
enforceLockForCurrentUser(ref.read(supabaseClientProvider));
|
enforceLockForCurrentUser(ref.read(supabaseClientProvider));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
orElse: () {},
|
loading: () {},
|
||||||
|
error: (_, __) {},
|
||||||
);
|
);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../models/profile.dart';
|
import '../../models/profile.dart';
|
||||||
import '../../models/task.dart';
|
import '../../models/task.dart';
|
||||||
import '../../models/task_assignment.dart';
|
import '../../models/task_assignment.dart';
|
||||||
|
import '../../models/task_activity_log.dart';
|
||||||
import '../../models/ticket.dart';
|
import '../../models/ticket.dart';
|
||||||
import '../../models/ticket_message.dart';
|
import '../../models/ticket_message.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
|
|
@ -162,125 +163,195 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
final messagesCard = Card(
|
// Tabbed area: Chat + Activity
|
||||||
child: Padding(
|
final tabbedCard = Card(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
child: DefaultTabController(
|
||||||
|
length: 2,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Material(
|
||||||
child: messagesAsync.when(
|
color: Theme.of(context).colorScheme.surface,
|
||||||
data: (messages) => _buildMessages(
|
child: TabBar(
|
||||||
context,
|
labelColor: Theme.of(context).colorScheme.onSurface,
|
||||||
messages,
|
indicatorColor: Theme.of(context).colorScheme.primary,
|
||||||
profilesAsync.valueOrNull ?? [],
|
tabs: const [
|
||||||
),
|
Tab(text: 'Chat'),
|
||||||
loading: () =>
|
Tab(text: 'Activity'),
|
||||||
const Center(child: CircularProgressIndicator()),
|
],
|
||||||
error: (error, _) => Center(
|
|
||||||
child: Text('Failed to load messages: $error'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SafeArea(
|
SizedBox(height: 8),
|
||||||
top: false,
|
Expanded(
|
||||||
child: Padding(
|
child: TabBarView(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 12),
|
children: [
|
||||||
child: Column(
|
// Chat tab (existing messages UI)
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Padding(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
if (typingState.userIds.isNotEmpty)
|
child: Column(
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.surfaceContainerHighest,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_typingLabel(
|
|
||||||
typingState.userIds,
|
|
||||||
profilesAsync,
|
|
||||||
),
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.labelSmall,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
TypingDots(
|
|
||||||
size: 8,
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (_mentionQuery != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: _buildMentionList(profilesAsync),
|
|
||||||
),
|
|
||||||
if (!canSendMessages)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Text(
|
|
||||||
'Messaging is disabled for completed tasks.',
|
|
||||||
style: Theme.of(context).textTheme.labelMedium,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextField(
|
child: messagesAsync.when(
|
||||||
controller: _messageController,
|
data: (messages) => _buildMessages(
|
||||||
decoration: const InputDecoration(
|
context,
|
||||||
hintText: 'Message...',
|
messages,
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.send,
|
|
||||||
enabled: canSendMessages,
|
|
||||||
onChanged: (_) => _handleComposerChanged(
|
|
||||||
profilesAsync.valueOrNull ?? [],
|
profilesAsync.valueOrNull ?? [],
|
||||||
ref.read(currentUserIdProvider),
|
|
||||||
canSendMessages,
|
|
||||||
typingChannelId,
|
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _handleSendMessage(
|
loading: () => const Center(
|
||||||
task,
|
child: CircularProgressIndicator(),
|
||||||
profilesAsync.valueOrNull ?? [],
|
),
|
||||||
ref.read(currentUserIdProvider),
|
error: (error, _) => Center(
|
||||||
canSendMessages,
|
child: Text(
|
||||||
typingChannelId,
|
'Failed to load messages: $error',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
SafeArea(
|
||||||
IconButton(
|
top: false,
|
||||||
tooltip: 'Send',
|
child: Padding(
|
||||||
onPressed: canSendMessages
|
padding: const EdgeInsets.fromLTRB(
|
||||||
? () => _handleSendMessage(
|
0,
|
||||||
task,
|
8,
|
||||||
profilesAsync.valueOrNull ?? [],
|
0,
|
||||||
ref.read(currentUserIdProvider),
|
12,
|
||||||
canSendMessages,
|
),
|
||||||
typingChannelId,
|
child: Column(
|
||||||
)
|
crossAxisAlignment:
|
||||||
: null,
|
CrossAxisAlignment.start,
|
||||||
icon: const Icon(Icons.send),
|
children: [
|
||||||
|
if (typingState.userIds.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 6,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest,
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_typingLabel(
|
||||||
|
typingState.userIds,
|
||||||
|
profilesAsync,
|
||||||
|
),
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
TypingDots(
|
||||||
|
size: 8,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_mentionQuery != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: _buildMentionList(
|
||||||
|
profilesAsync,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!canSendMessages)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 8,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Messaging is disabled for completed tasks.',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Message...',
|
||||||
|
),
|
||||||
|
textInputAction:
|
||||||
|
TextInputAction.send,
|
||||||
|
enabled: canSendMessages,
|
||||||
|
onChanged: (_) =>
|
||||||
|
_handleComposerChanged(
|
||||||
|
profilesAsync.valueOrNull ??
|
||||||
|
[],
|
||||||
|
ref.read(
|
||||||
|
currentUserIdProvider,
|
||||||
|
),
|
||||||
|
canSendMessages,
|
||||||
|
typingChannelId,
|
||||||
|
),
|
||||||
|
onSubmitted: (_) =>
|
||||||
|
_handleSendMessage(
|
||||||
|
task,
|
||||||
|
profilesAsync.valueOrNull ??
|
||||||
|
[],
|
||||||
|
ref.read(
|
||||||
|
currentUserIdProvider,
|
||||||
|
),
|
||||||
|
canSendMessages,
|
||||||
|
typingChannelId,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Send',
|
||||||
|
onPressed: canSendMessages
|
||||||
|
? () => _handleSendMessage(
|
||||||
|
task,
|
||||||
|
profilesAsync.valueOrNull ??
|
||||||
|
[],
|
||||||
|
ref.read(
|
||||||
|
currentUserIdProvider,
|
||||||
|
),
|
||||||
|
canSendMessages,
|
||||||
|
typingChannelId,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
icon: const Icon(Icons.send),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
|
// Activity tab
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
|
||||||
|
child: _buildActivityTab(
|
||||||
|
task,
|
||||||
|
assignments,
|
||||||
|
messagesAsync,
|
||||||
|
profilesAsync.valueOrNull ?? [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -293,7 +364,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
Expanded(flex: 2, child: detailsCard),
|
Expanded(flex: 2, child: detailsCard),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
Expanded(flex: 3, child: messagesCard),
|
Expanded(flex: 3, child: tabbedCard),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -302,7 +373,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
children: [
|
children: [
|
||||||
detailsCard,
|
detailsCard,
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Expanded(child: messagesCard),
|
Expanded(child: tabbedCard),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -401,6 +472,155 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildActivityTab(
|
||||||
|
Task task,
|
||||||
|
List<TaskAssignment> assignments,
|
||||||
|
AsyncValue<List<TicketMessage>> messagesAsync,
|
||||||
|
List<Profile> profiles,
|
||||||
|
) {
|
||||||
|
final logsAsync = ref.watch(taskActivityLogsProvider(task.id));
|
||||||
|
final logs = logsAsync.valueOrNull ?? <TaskActivityLog>[];
|
||||||
|
final profileById = {for (final p in profiles) p.id: p};
|
||||||
|
|
||||||
|
// Find the latest assignment (by createdAt)
|
||||||
|
final assignedForTask =
|
||||||
|
assignments.where((a) => a.taskId == task.id).toList()
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
final latestAssignment = assignedForTask.isEmpty
|
||||||
|
? null
|
||||||
|
: assignedForTask.last;
|
||||||
|
|
||||||
|
DateTime? firstMessageByAssignee;
|
||||||
|
if (latestAssignment != null) {
|
||||||
|
messagesAsync.when(
|
||||||
|
data: (messages) {
|
||||||
|
final byAssignee =
|
||||||
|
messages
|
||||||
|
.where((m) => m.senderId == latestAssignment.userId)
|
||||||
|
.where((m) => m.createdAt.isAfter(latestAssignment.createdAt))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.createdAt.compareTo(b.createdAt));
|
||||||
|
if (byAssignee.isNotEmpty) {
|
||||||
|
firstMessageByAssignee = byAssignee.first.createdAt;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
loading: () {},
|
||||||
|
error: (err, stack) {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime? startedByAssignee;
|
||||||
|
for (final l in logs) {
|
||||||
|
if (l.actionType == 'started' && latestAssignment != null) {
|
||||||
|
if (l.actorId == latestAssignment.userId &&
|
||||||
|
l.createdAt.isAfter(latestAssignment.createdAt)) {
|
||||||
|
startedByAssignee = l.createdAt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Duration? responseDuration;
|
||||||
|
DateTime? responseAt;
|
||||||
|
if (latestAssignment != null) {
|
||||||
|
final assignedAt = latestAssignment.createdAt;
|
||||||
|
final candidates = <DateTime>[];
|
||||||
|
if (firstMessageByAssignee != null) {
|
||||||
|
candidates.add(firstMessageByAssignee!);
|
||||||
|
}
|
||||||
|
if (startedByAssignee != null) {
|
||||||
|
candidates.add(startedByAssignee);
|
||||||
|
}
|
||||||
|
if (candidates.isNotEmpty) {
|
||||||
|
candidates.sort();
|
||||||
|
responseAt = candidates.first;
|
||||||
|
responseDuration = responseAt.difference(assignedAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render timeline (oldest -> newest)
|
||||||
|
final timeline = <Widget>[];
|
||||||
|
if (logs.isEmpty) {
|
||||||
|
timeline.add(const Text('No activity yet.'));
|
||||||
|
} else {
|
||||||
|
final chronological = List.from(logs.reversed);
|
||||||
|
for (final l in chronological) {
|
||||||
|
final actorName = l.actorId == null
|
||||||
|
? 'System'
|
||||||
|
: (profileById[l.actorId]?.fullName ?? l.actorId!);
|
||||||
|
switch (l.actionType) {
|
||||||
|
case 'created':
|
||||||
|
timeline.add(_activityRow('Task created', actorName, l.createdAt));
|
||||||
|
break;
|
||||||
|
case 'assigned':
|
||||||
|
final meta = l.meta ?? {};
|
||||||
|
final userId = meta['user_id'] as String?;
|
||||||
|
final auto = meta['auto'] == true;
|
||||||
|
final name = userId == null
|
||||||
|
? 'Unknown'
|
||||||
|
: (profileById[userId]?.fullName ?? userId);
|
||||||
|
timeline.add(
|
||||||
|
_activityRow(
|
||||||
|
auto ? 'Auto-assigned to $name' : 'Assigned to $name',
|
||||||
|
actorName,
|
||||||
|
l.createdAt,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'reassigned':
|
||||||
|
final meta = l.meta ?? {};
|
||||||
|
|
||||||
|
final to = (meta['to'] as List?) ?? [];
|
||||||
|
final toNames = to
|
||||||
|
.map((id) => profileById[id]?.fullName ?? id)
|
||||||
|
.join(', ');
|
||||||
|
timeline.add(
|
||||||
|
_activityRow('Reassigned to $toNames', actorName, l.createdAt),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case 'started':
|
||||||
|
timeline.add(_activityRow('Task started', actorName, l.createdAt));
|
||||||
|
break;
|
||||||
|
case 'completed':
|
||||||
|
timeline.add(
|
||||||
|
_activityRow('Task completed', actorName, l.createdAt),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
timeline.add(_activityRow(l.actionType, actorName, l.createdAt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseDuration != null) {
|
||||||
|
final assigneeName =
|
||||||
|
profileById[latestAssignment!.userId]?.fullName ??
|
||||||
|
latestAssignment.userId;
|
||||||
|
timeline.add(const SizedBox(height: 12));
|
||||||
|
timeline.add(
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.timer, size: 18),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'Response Time: ${_formatDuration(responseDuration)} ($assigneeName responded at ${responseAt!.toLocal().toString()})',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: timeline,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildTatSection(Task task) {
|
Widget _buildTatSection(Task task) {
|
||||||
final animateQueue = task.status == 'queued';
|
final animateQueue = task.status == 'queued';
|
||||||
final animateExecution = task.startedAt != null && task.completedAt == null;
|
final animateExecution = task.startedAt != null && task.completedAt == null;
|
||||||
|
|
@ -439,6 +659,36 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _activityRow(String title, String actor, DateTime at) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.circle,
|
||||||
|
size: 12,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'$actor • ${at.toLocal()}',
|
||||||
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Duration? _safeDuration(Duration? duration) {
|
Duration? _safeDuration(Duration? duration) {
|
||||||
if (duration == null) {
|
if (duration == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -579,7 +829,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
if (content.isEmpty) {
|
if (content.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
|
|
||||||
|
// Safely stop typing — controller may have been auto-disposed by Riverpod.
|
||||||
|
final typingController = _maybeTypingController(typingChannelId);
|
||||||
|
typingController?.stopTyping();
|
||||||
|
|
||||||
final message = await ref
|
final message = await ref
|
||||||
.read(ticketsControllerProvider)
|
.read(ticketsControllerProvider)
|
||||||
.sendTaskMessage(
|
.sendTaskMessage(
|
||||||
|
|
@ -620,11 +874,11 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
String typingChannelId,
|
String typingChannelId,
|
||||||
) {
|
) {
|
||||||
if (!canSendMessages) {
|
if (!canSendMessages) {
|
||||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).stopTyping();
|
_maybeTypingController(typingChannelId)?.stopTyping();
|
||||||
_clearMentions();
|
_clearMentions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(typingIndicatorProvider(typingChannelId).notifier).userTyping();
|
_maybeTypingController(typingChannelId)?.userTyping();
|
||||||
final text = _messageController.text;
|
final text = _messageController.text;
|
||||||
final cursor = _messageController.selection.baseOffset;
|
final cursor = _messageController.selection.baseOffset;
|
||||||
if (cursor < 0) {
|
if (cursor < 0) {
|
||||||
|
|
@ -672,6 +926,20 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safely obtain the typing controller for [channelId].
|
||||||
|
// Returns null if the provider has been disposed or is not mounted.
|
||||||
|
TypingIndicatorController? _maybeTypingController(String channelId) {
|
||||||
|
try {
|
||||||
|
final controller = ref.read(typingIndicatorProvider(channelId).notifier);
|
||||||
|
return controller.mounted ? controller : null;
|
||||||
|
} on StateError {
|
||||||
|
// provider was disposed concurrently
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _isWhitespace(String char) {
|
bool _isWhitespace(String char) {
|
||||||
return char.trim().isEmpty;
|
return char.trim().isEmpty;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import '../../models/notification_item.dart';
|
||||||
import '../../models/office.dart';
|
import '../../models/office.dart';
|
||||||
import '../../models/profile.dart';
|
import '../../models/profile.dart';
|
||||||
import '../../models/task.dart';
|
import '../../models/task.dart';
|
||||||
|
import '../../models/task_assignment.dart';
|
||||||
import '../../models/ticket.dart';
|
import '../../models/ticket.dart';
|
||||||
import '../../providers/notifications_provider.dart';
|
import '../../providers/notifications_provider.dart';
|
||||||
import '../../providers/profile_provider.dart';
|
import '../../providers/profile_provider.dart';
|
||||||
|
|
@ -55,6 +56,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
final profileAsync = ref.watch(currentProfileProvider);
|
final profileAsync = ref.watch(currentProfileProvider);
|
||||||
final notificationsAsync = ref.watch(notificationsProvider);
|
final notificationsAsync = ref.watch(notificationsProvider);
|
||||||
final profilesAsync = ref.watch(profilesProvider);
|
final profilesAsync = ref.watch(profilesProvider);
|
||||||
|
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
||||||
|
|
||||||
final canCreate = profileAsync.maybeWhen(
|
final canCreate = profileAsync.maybeWhen(
|
||||||
data: (profile) =>
|
data: (profile) =>
|
||||||
|
|
@ -102,6 +104,22 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
];
|
];
|
||||||
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
||||||
final statusOptions = _taskStatusOptions(tasks);
|
final statusOptions = _taskStatusOptions(tasks);
|
||||||
|
|
||||||
|
// derive latest assignee per task from task assignments stream
|
||||||
|
final assignments =
|
||||||
|
assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
|
||||||
|
final assignmentsByTask = <String, TaskAssignment>{};
|
||||||
|
for (final a in assignments) {
|
||||||
|
final current = assignmentsByTask[a.taskId];
|
||||||
|
if (current == null || a.createdAt.isAfter(current.createdAt)) {
|
||||||
|
assignmentsByTask[a.taskId] = a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final latestAssigneeByTaskId = <String, String?>{};
|
||||||
|
for (final entry in assignmentsByTask.entries) {
|
||||||
|
latestAssigneeByTaskId[entry.key] = entry.value.userId;
|
||||||
|
}
|
||||||
|
|
||||||
final filteredTasks = _applyTaskFilters(
|
final filteredTasks = _applyTaskFilters(
|
||||||
tasks,
|
tasks,
|
||||||
ticketById: ticketById,
|
ticketById: ticketById,
|
||||||
|
|
@ -110,6 +128,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
status: _selectedStatus,
|
status: _selectedStatus,
|
||||||
assigneeId: _selectedAssigneeId,
|
assigneeId: _selectedAssigneeId,
|
||||||
dateRange: _selectedDateRange,
|
dateRange: _selectedDateRange,
|
||||||
|
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
||||||
);
|
);
|
||||||
final summaryDashboard = _StatusSummaryRow(
|
final summaryDashboard = _StatusSummaryRow(
|
||||||
counts: _taskStatusCounts(filteredTasks),
|
counts: _taskStatusCounts(filteredTasks),
|
||||||
|
|
@ -249,8 +268,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
),
|
),
|
||||||
TasQColumn<Task>(
|
TasQColumn<Task>(
|
||||||
header: 'Assigned Agent',
|
header: 'Assigned Agent',
|
||||||
cellBuilder: (context, task) =>
|
cellBuilder: (context, task) {
|
||||||
Text(_assignedAgent(profileById, task.creatorId)),
|
final assigneeId = latestAssigneeByTaskId[task.id];
|
||||||
|
return Text(_assignedAgent(profileById, assigneeId));
|
||||||
|
},
|
||||||
),
|
),
|
||||||
TasQColumn<Task>(
|
TasQColumn<Task>(
|
||||||
header: 'Status',
|
header: 'Status',
|
||||||
|
|
@ -271,7 +292,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
|
||||||
final officeName = officeId == null
|
final officeName = officeId == null
|
||||||
? 'Unassigned office'
|
? 'Unassigned office'
|
||||||
: (officeById[officeId]?.name ?? officeId);
|
: (officeById[officeId]?.name ?? officeId);
|
||||||
final assigned = _assignedAgent(profileById, task.creatorId);
|
final assigned = _assignedAgent(
|
||||||
|
profileById,
|
||||||
|
latestAssigneeByTaskId[task.id],
|
||||||
|
);
|
||||||
final subtitle = _buildSubtitle(officeName, task.status);
|
final subtitle = _buildSubtitle(officeName, task.status);
|
||||||
final hasMention = _hasTaskMention(notificationsAsync, task);
|
final hasMention = _hasTaskMention(notificationsAsync, task);
|
||||||
final typingState = ref.watch(
|
final typingState = ref.watch(
|
||||||
|
|
@ -558,6 +582,7 @@ List<Task> _applyTaskFilters(
|
||||||
required String? status,
|
required String? status,
|
||||||
required String? assigneeId,
|
required String? assigneeId,
|
||||||
required DateTimeRange? dateRange,
|
required DateTimeRange? dateRange,
|
||||||
|
required Map<String, String?> latestAssigneeByTaskId,
|
||||||
}) {
|
}) {
|
||||||
final query = subjectQuery.trim().toLowerCase();
|
final query = subjectQuery.trim().toLowerCase();
|
||||||
return tasks.where((task) {
|
return tasks.where((task) {
|
||||||
|
|
@ -577,7 +602,8 @@ List<Task> _applyTaskFilters(
|
||||||
if (status != null && task.status != status) {
|
if (status != null && task.status != status) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (assigneeId != null && task.creatorId != assigneeId) {
|
final currentAssignee = latestAssigneeByTaskId[task.id];
|
||||||
|
if (assigneeId != null && currentAssignee != assigneeId) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (dateRange != null) {
|
if (dateRange != null) {
|
||||||
|
|
|
||||||
|
|
@ -482,7 +482,9 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
if (!canSendMessages) return;
|
if (!canSendMessages) return;
|
||||||
final content = _messageController.text.trim();
|
final content = _messageController.text.trim();
|
||||||
if (content.isEmpty) return;
|
if (content.isEmpty) return;
|
||||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
|
|
||||||
|
_maybeTypingController(widget.ticketId)?.stopTyping();
|
||||||
|
|
||||||
final message = await ref
|
final message = await ref
|
||||||
.read(ticketsControllerProvider)
|
.read(ticketsControllerProvider)
|
||||||
.sendTicketMessage(ticketId: widget.ticketId, content: content);
|
.sendTicketMessage(ticketId: widget.ticketId, content: content);
|
||||||
|
|
@ -533,11 +535,11 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
bool canSendMessages,
|
bool canSendMessages,
|
||||||
) {
|
) {
|
||||||
if (!canSendMessages) {
|
if (!canSendMessages) {
|
||||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).stopTyping();
|
_maybeTypingController(widget.ticketId)?.stopTyping();
|
||||||
_clearMentions();
|
_clearMentions();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ref.read(typingIndicatorProvider(widget.ticketId).notifier).userTyping();
|
_maybeTypingController(widget.ticketId)?.userTyping();
|
||||||
final text = _messageController.text;
|
final text = _messageController.text;
|
||||||
final cursor = _messageController.selection.baseOffset;
|
final cursor = _messageController.selection.baseOffset;
|
||||||
if (cursor < 0) {
|
if (cursor < 0) {
|
||||||
|
|
@ -585,6 +587,18 @@ class _TicketDetailScreenState extends ConsumerState<TicketDetailScreen> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Safely obtain the typing controller for [ticketId].
|
||||||
|
TypingIndicatorController? _maybeTypingController(String ticketId) {
|
||||||
|
try {
|
||||||
|
final controller = ref.read(typingIndicatorProvider(ticketId).notifier);
|
||||||
|
return controller.mounted ? controller : null;
|
||||||
|
} on StateError {
|
||||||
|
return null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool _isWhitespace(String char) {
|
bool _isWhitespace(String char) {
|
||||||
return char.trim().isEmpty;
|
return char.trim().isEmpty;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -193,7 +193,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TasQColumn<Ticket>(
|
TasQColumn<Ticket>(
|
||||||
header: 'Assigned Agent',
|
header: 'Filed by',
|
||||||
cellBuilder: (context, ticket) =>
|
cellBuilder: (context, ticket) =>
|
||||||
Text(_assignedAgent(profileById, ticket.creatorId)),
|
Text(_assignedAgent(profileById, ticket.creatorId)),
|
||||||
),
|
),
|
||||||
|
|
@ -232,7 +232,7 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
|
||||||
children: [
|
children: [
|
||||||
Text(officeName),
|
Text(officeName),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text('Assigned: $assigned'),
|
Text('Filed by: $assigned'),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
MonoText('ID ${ticket.id}'),
|
MonoText('ID ${ticket.id}'),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
-- Add task_activity_logs table to track task events (assign/started/completed/reassigned)
|
||||||
|
|
||||||
|
create table if not exists task_activity_logs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
task_id uuid not null references tasks(id) on delete cascade,
|
||||||
|
actor_id uuid references profiles(id),
|
||||||
|
action_type text not null,
|
||||||
|
meta jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_task_activity_logs_task_id on task_activity_logs(task_id);
|
||||||
45
test/auto_assign_test.dart
Normal file
45
test/auto_assign_test.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'chooseAutoAssignCandidate picks latest check-in (late-comer priority)',
|
||||||
|
() {
|
||||||
|
final now = DateTime(2026, 2, 18, 12, 0, 0);
|
||||||
|
final earlier = AutoAssignCandidate(
|
||||||
|
userId: 'user-1',
|
||||||
|
checkInAt: now.subtract(const Duration(hours: 2)),
|
||||||
|
completedToday: 0,
|
||||||
|
);
|
||||||
|
final later = AutoAssignCandidate(
|
||||||
|
userId: 'user-2',
|
||||||
|
checkInAt: now.subtract(const Duration(hours: 1)),
|
||||||
|
completedToday: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chosen = chooseAutoAssignCandidate([earlier, later]);
|
||||||
|
expect(chosen, equals('user-2'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test('chooseAutoAssignCandidate uses completed count as tie-breaker', () {
|
||||||
|
final now = DateTime(2026, 2, 18, 9, 0, 0);
|
||||||
|
final a = AutoAssignCandidate(
|
||||||
|
userId: 'a',
|
||||||
|
checkInAt: now,
|
||||||
|
completedToday: 5,
|
||||||
|
);
|
||||||
|
final b = AutoAssignCandidate(
|
||||||
|
userId: 'b',
|
||||||
|
checkInAt: now,
|
||||||
|
completedToday: 2,
|
||||||
|
);
|
||||||
|
|
||||||
|
final chosen = chooseAutoAssignCandidate([a, b]);
|
||||||
|
expect(chosen, equals('b'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('chooseAutoAssignCandidate returns null for empty list', () {
|
||||||
|
expect(chooseAutoAssignCandidate([]), isNull);
|
||||||
|
});
|
||||||
|
}
|
||||||
242
test/ticket_promotion_integration_test.dart
Normal file
242
test/ticket_promotion_integration_test.dart
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:tasq/providers/tasks_provider.dart';
|
||||||
|
|
||||||
|
// Lightweight in-memory fake Supabase client used only for this test.
|
||||||
|
class _FakeClient {
|
||||||
|
final Map<String, List<Map<String, dynamic>>> tables = {
|
||||||
|
'tickets': [],
|
||||||
|
'tasks': [],
|
||||||
|
'teams': [],
|
||||||
|
'team_members': [],
|
||||||
|
'duty_schedules': [],
|
||||||
|
'task_assignments': [],
|
||||||
|
'task_activity_logs': [],
|
||||||
|
'notifications': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FakeQuery {
|
||||||
|
final _FakeClient client;
|
||||||
|
final String table;
|
||||||
|
Map<String, dynamic>? _eq;
|
||||||
|
List? _in;
|
||||||
|
Map<String, dynamic>? _insertPayload;
|
||||||
|
Map<String, dynamic>? _updatePayload;
|
||||||
|
|
||||||
|
_FakeQuery(this.client, this.table);
|
||||||
|
|
||||||
|
_FakeQuery select([String? _]) => this;
|
||||||
|
_FakeQuery eq(String field, dynamic value) {
|
||||||
|
_eq = {field: value};
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
_FakeQuery inFilter(String field, List values) {
|
||||||
|
_in = values;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> maybeSingle() async {
|
||||||
|
final rows = client.tables[table] ?? [];
|
||||||
|
if (_eq != null) {
|
||||||
|
final field = _eq!.keys.first;
|
||||||
|
final value = _eq![field];
|
||||||
|
Map<String, dynamic>? found;
|
||||||
|
for (final r in rows) {
|
||||||
|
if (r[field] == value) {
|
||||||
|
found = Map<String, dynamic>.from(r);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
return rows.isEmpty ? null : Map<String, dynamic>.from(rows.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Map<String, dynamic>>> execute() async {
|
||||||
|
final rows = client.tables[table] ?? [];
|
||||||
|
if (_in != null) {
|
||||||
|
// return rows where the field (assume first) is in the list
|
||||||
|
return rows
|
||||||
|
.where((r) => _in!.contains(r.values.first))
|
||||||
|
.map((r) => Map<String, dynamic>.from(r))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
return rows.map((r) => Map<String, dynamic>.from(r)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
_FakeQuery insert(Map<String, dynamic> payload) {
|
||||||
|
_insertPayload = Map<String, dynamic>.from(payload);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> single() async {
|
||||||
|
if (_insertPayload != null) {
|
||||||
|
// emulate DB-generated id
|
||||||
|
final id = 'tsk-${client.tables['tasks']!.length + 1}';
|
||||||
|
final row = Map<String, dynamic>.from(_insertPayload!);
|
||||||
|
row['id'] = id;
|
||||||
|
client.tables[table]!.add(row);
|
||||||
|
return Map<String, dynamic>.from(row);
|
||||||
|
}
|
||||||
|
if (_updatePayload != null && _eq != null) {
|
||||||
|
final field = _eq!.keys.first;
|
||||||
|
final value = _eq![field];
|
||||||
|
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
|
||||||
|
if (idx >= 0) {
|
||||||
|
client.tables[table]![idx] = {
|
||||||
|
...client.tables[table]![idx],
|
||||||
|
..._updatePayload!,
|
||||||
|
};
|
||||||
|
return Map<String, dynamic>.from(client.tables[table]![idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw Exception('single() called in unsupported context in fake');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> update(Map<String, dynamic> payload) async {
|
||||||
|
_updatePayload = payload;
|
||||||
|
if (_eq != null) {
|
||||||
|
final field = _eq!.keys.first;
|
||||||
|
final value = _eq![field];
|
||||||
|
final idx = client.tables[table]!.indexWhere((r) => r[field] == value);
|
||||||
|
if (idx >= 0) {
|
||||||
|
client.tables[table]![idx] = {
|
||||||
|
...client.tables[table]![idx],
|
||||||
|
...payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> delete() async {
|
||||||
|
if (_eq != null) {
|
||||||
|
final field = _eq!.keys.first;
|
||||||
|
final value = _eq![field];
|
||||||
|
client.tables[table]!.removeWhere((r) => r[field] == value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Minimal adapter used by controllers in production; this mirrors the small
|
||||||
|
// subset of API used by TicketsController/TasksController in tests.
|
||||||
|
extension FakeClientAdapter on _FakeClient {
|
||||||
|
_FakeQuery from(String table) => _FakeQuery(this, table);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'ticket promotion creates task and auto-assigns late-comer',
|
||||||
|
() async {
|
||||||
|
final fake = _FakeClient();
|
||||||
|
|
||||||
|
// ticket row
|
||||||
|
fake.tables['tickets']!.add({
|
||||||
|
'id': 'TCK-1',
|
||||||
|
'subject': 'Printer down',
|
||||||
|
'description': 'Paper jam',
|
||||||
|
'office_id': 'office-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
// team covering office-1
|
||||||
|
fake.tables['teams']!.add({
|
||||||
|
'id': 'team-1',
|
||||||
|
'office_ids': ['office-1'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// two team members
|
||||||
|
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u1'});
|
||||||
|
fake.tables['team_members']!.add({'team_id': 'team-1', 'user_id': 'u2'});
|
||||||
|
|
||||||
|
// both checked in today; u2 arrived later -> should be chosen
|
||||||
|
fake.tables['duty_schedules']!.add({
|
||||||
|
'user_id': 'u1',
|
||||||
|
'check_in_at': DateTime(2026, 2, 18, 8, 0).toIso8601String(),
|
||||||
|
});
|
||||||
|
fake.tables['duty_schedules']!.add({
|
||||||
|
'user_id': 'u2',
|
||||||
|
'check_in_at': DateTime(2026, 2, 18, 9, 0).toIso8601String(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// No existing tasks for ticket
|
||||||
|
|
||||||
|
// We'll reuse production controllers but point them at our fake client
|
||||||
|
// by creating minimal adapter wrappers. For this test we only need to
|
||||||
|
// exercise selection+assignment via TasksController.createTask.
|
||||||
|
|
||||||
|
// Create TicketsController-like flow manually (simulate promoted path):
|
||||||
|
// 1) create task row (simulate TasksController.createTask)
|
||||||
|
final taskPayload = {
|
||||||
|
'title': 'Printer down',
|
||||||
|
'description': 'Paper jam',
|
||||||
|
'office_id': 'office-1',
|
||||||
|
'ticket_id': 'TCK-1',
|
||||||
|
};
|
||||||
|
// emulate insert -> returns row with id
|
||||||
|
final insertedTask = await fake
|
||||||
|
.from('tasks')
|
||||||
|
.insert(taskPayload)
|
||||||
|
.single();
|
||||||
|
final taskId = insertedTask['id'] as String;
|
||||||
|
|
||||||
|
// 2) run simplified autoAssign logic (mirror of production selection)
|
||||||
|
// Gather team ids covering office
|
||||||
|
final teams = fake.tables['teams']!;
|
||||||
|
final teamIds = teams
|
||||||
|
.where((t) => (t['office_ids'] as List).contains('office-1'))
|
||||||
|
.map((t) => t['id'] as String)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final memberRows = fake.tables['team_members']!
|
||||||
|
.where((m) => teamIds.contains(m['team_id']))
|
||||||
|
.toList();
|
||||||
|
final candidateIds = memberRows
|
||||||
|
.map((r) => r['user_id'] as String)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// read duty_schedules for candidates
|
||||||
|
final onDuty = <String, DateTime>{};
|
||||||
|
for (final s in fake.tables['duty_schedules']!) {
|
||||||
|
final uid = s['user_id'] as String;
|
||||||
|
if (!candidateIds.contains(uid)) continue;
|
||||||
|
final checkIn = DateTime.parse(s['check_in_at'] as String);
|
||||||
|
onDuty[uid] = checkIn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compute completedToday as 0 (no completed tasks in fake DB)
|
||||||
|
final candidates = onDuty.entries
|
||||||
|
.map(
|
||||||
|
(e) => AutoAssignCandidate(
|
||||||
|
userId: e.key,
|
||||||
|
checkInAt: e.value,
|
||||||
|
completedToday: 0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final chosen = chooseAutoAssignCandidate(candidates);
|
||||||
|
expect(chosen, equals('u2'), reason: 'Late-comer u2 should be chosen');
|
||||||
|
|
||||||
|
// Insert assignment row (what _autoAssignTask would do)
|
||||||
|
await fake.from('task_assignments').insert({
|
||||||
|
'task_id': taskId,
|
||||||
|
'user_id': chosen,
|
||||||
|
}).single();
|
||||||
|
|
||||||
|
// Assert task exists and assignment was created
|
||||||
|
final createdTask = fake.tables['tasks']!.firstWhere(
|
||||||
|
(t) => t['id'] == taskId,
|
||||||
|
);
|
||||||
|
expect(createdTask['ticket_id'], equals('TCK-1'));
|
||||||
|
|
||||||
|
final assignment = fake.tables['task_assignments']!.firstWhere(
|
||||||
|
(a) => a['task_id'] == taskId,
|
||||||
|
orElse: () => {},
|
||||||
|
);
|
||||||
|
expect(assignment['user_id'], equals('u2'));
|
||||||
|
},
|
||||||
|
timeout: Timeout(Duration(seconds: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user