Activity logs
This commit is contained in:
parent
5a74299a1c
commit
e99b87bd20
|
|
@ -142,8 +142,9 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||||
?.toString();
|
?.toString();
|
||||||
if (taskNumber != null && taskNumber.isNotEmpty) {
|
if (taskNumber != null && taskNumber.isNotEmpty) {
|
||||||
sb.write('tasknum:$taskNumber');
|
sb.write('tasknum:$taskNumber');
|
||||||
} else if (taskId != null && taskId.isNotEmpty)
|
} else if (taskId != null && taskId.isNotEmpty) {
|
||||||
sb.write('task:$taskId');
|
sb.write('task:$taskId');
|
||||||
|
}
|
||||||
if (ticketId != null && ticketId.isNotEmpty) {
|
if (ticketId != null && ticketId.isNotEmpty) {
|
||||||
if (sb.isNotEmpty) sb.write('|');
|
if (sb.isNotEmpty) sb.write('|');
|
||||||
sb.write('ticket:$ticketId');
|
sb.write('ticket:$ticketId');
|
||||||
|
|
@ -320,10 +321,11 @@ Future<void> main() async {
|
||||||
String? name;
|
String? name;
|
||||||
if (res['full_name'] != null) {
|
if (res['full_name'] != null) {
|
||||||
name = res['full_name'].toString();
|
name = res['full_name'].toString();
|
||||||
} else if (res['display_name'] != null)
|
} else if (res['display_name'] != null) {
|
||||||
name = res['display_name'].toString();
|
name = res['display_name'].toString();
|
||||||
else if (res['name'] != null)
|
} else if (res['name'] != null) {
|
||||||
name = res['name'].toString();
|
name = res['name'].toString();
|
||||||
|
}
|
||||||
if (name != null && name.isNotEmpty) {
|
if (name != null && name.isNotEmpty) {
|
||||||
dataForFormatting['actor_name'] = name;
|
dataForFormatting['actor_name'] = name;
|
||||||
}
|
}
|
||||||
|
|
@ -362,8 +364,9 @@ Future<void> main() async {
|
||||||
?.toString();
|
?.toString();
|
||||||
if (taskNumber != null && taskNumber.isNotEmpty) {
|
if (taskNumber != null && taskNumber.isNotEmpty) {
|
||||||
sb.write('tasknum:$taskNumber');
|
sb.write('tasknum:$taskNumber');
|
||||||
} else if (taskId != null && taskId.isNotEmpty)
|
} else if (taskId != null && taskId.isNotEmpty) {
|
||||||
sb.write('task:$taskId');
|
sb.write('task:$taskId');
|
||||||
|
}
|
||||||
if (ticketId != null && ticketId.isNotEmpty) {
|
if (ticketId != null && ticketId.isNotEmpty) {
|
||||||
if (sb.isNotEmpty) sb.write('|');
|
if (sb.isNotEmpty) sb.write('|');
|
||||||
sb.write('ticket:$ticketId');
|
sb.write('ticket:$ticketId');
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ class Task {
|
||||||
this.requestTypeOther,
|
this.requestTypeOther,
|
||||||
this.requestCategory,
|
this.requestCategory,
|
||||||
this.actionTaken,
|
this.actionTaken,
|
||||||
|
this.cancellationReason,
|
||||||
|
this.cancelledAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
|
|
@ -53,6 +55,10 @@ class Task {
|
||||||
// JSON serialized rich text for action taken (Quill Delta JSON encoded)
|
// JSON serialized rich text for action taken (Quill Delta JSON encoded)
|
||||||
final String? actionTaken;
|
final String? actionTaken;
|
||||||
|
|
||||||
|
/// Cancellation details when a task was cancelled.
|
||||||
|
final String? cancellationReason;
|
||||||
|
final DateTime? cancelledAt;
|
||||||
|
|
||||||
/// Helper that indicates whether a completed task still has missing
|
/// Helper that indicates whether a completed task still has missing
|
||||||
/// metadata such as signatories or action details. The parameter is used
|
/// 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
|
/// by UI to surface a warning icon/banner when a task has been closed but
|
||||||
|
|
@ -91,6 +97,10 @@ class Task {
|
||||||
requestedBy: map['requested_by'] as String?,
|
requestedBy: map['requested_by'] as String?,
|
||||||
notedBy: map['noted_by'] as String?,
|
notedBy: map['noted_by'] as String?,
|
||||||
receivedBy: map['received_by'] as String?,
|
receivedBy: map['received_by'] as String?,
|
||||||
|
cancellationReason: map['cancellation_reason'] as String?,
|
||||||
|
cancelledAt: map['cancelled_at'] == null
|
||||||
|
? null
|
||||||
|
: AppTime.parse(map['cancelled_at'] as String),
|
||||||
actionTaken: (() {
|
actionTaken: (() {
|
||||||
final at = map['action_taken'];
|
final at = map['action_taken'];
|
||||||
if (at == null) return null;
|
if (at == null) return null;
|
||||||
|
|
|
||||||
|
|
@ -210,8 +210,10 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
|
||||||
return 1;
|
return 1;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
return 2;
|
return 2;
|
||||||
default:
|
case 'cancelled':
|
||||||
return 3;
|
return 3;
|
||||||
|
default:
|
||||||
|
return 4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -700,7 +702,13 @@ class TasksController {
|
||||||
Future<void> updateTaskStatus({
|
Future<void> updateTaskStatus({
|
||||||
required String taskId,
|
required String taskId,
|
||||||
required String status,
|
required String status,
|
||||||
|
String? reason,
|
||||||
}) async {
|
}) async {
|
||||||
|
if (status == 'cancelled') {
|
||||||
|
if (reason == null || reason.trim().isEmpty) {
|
||||||
|
throw Exception('Cancellation requires a reason.');
|
||||||
|
}
|
||||||
|
}
|
||||||
if (status == 'completed') {
|
if (status == 'completed') {
|
||||||
// fetch current metadata to validate several required fields
|
// fetch current metadata to validate several required fields
|
||||||
try {
|
try {
|
||||||
|
|
@ -751,7 +759,23 @@ class TasksController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _client.from('tasks').update({'status': status}).eq('id', taskId);
|
// persist status and cancellation reason (when provided)
|
||||||
|
final payload = <String, dynamic>{'status': status};
|
||||||
|
if (status == 'cancelled') {
|
||||||
|
payload['cancellation_reason'] = reason;
|
||||||
|
}
|
||||||
|
await _client.from('tasks').update(payload).eq('id', taskId);
|
||||||
|
|
||||||
|
// if cancelled, also set cancelled_at timestamp
|
||||||
|
if (status == 'cancelled') {
|
||||||
|
try {
|
||||||
|
final cancelledAt = AppTime.now().toIso8601String();
|
||||||
|
await _client
|
||||||
|
.from('tasks')
|
||||||
|
.update({'cancelled_at': cancelledAt})
|
||||||
|
.eq('id', taskId);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
// Log important status transitions
|
// Log important status transitions
|
||||||
try {
|
try {
|
||||||
|
|
@ -768,6 +792,13 @@ class TasksController {
|
||||||
'actor_id': actorId,
|
'actor_id': actorId,
|
||||||
'action_type': 'completed',
|
'action_type': 'completed',
|
||||||
});
|
});
|
||||||
|
} else if (status == 'cancelled') {
|
||||||
|
await _insertActivityRows(_client, {
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'cancelled',
|
||||||
|
'meta': {'reason': reason},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// ignore logging failures
|
// ignore logging failures
|
||||||
|
|
@ -823,7 +854,66 @@ class TasksController {
|
||||||
if (payload.isEmpty) {
|
if (payload.isEmpty) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _client.from('tasks').update(payload).eq('id', taskId);
|
await _client.from('tasks').update(payload).eq('id', taskId);
|
||||||
|
|
||||||
|
// Record activity logs for any metadata/signatory fields that were changed
|
||||||
|
try {
|
||||||
|
final actorId = _client.auth.currentUser?.id;
|
||||||
|
final List<Map<String, dynamic>> logRows = [];
|
||||||
|
if (requestType != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_request_type',
|
||||||
|
'meta': {'value': requestType},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestCategory != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_request_category',
|
||||||
|
'meta': {'value': requestCategory},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (requestedBy != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_requested_by',
|
||||||
|
'meta': {'value': requestedBy},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (notedBy != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_noted_by',
|
||||||
|
'meta': {'value': notedBy},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (receivedBy != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_received_by',
|
||||||
|
'meta': {'value': receivedBy},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (actionTaken != null) {
|
||||||
|
logRows.add({
|
||||||
|
'task_id': taskId,
|
||||||
|
'actor_id': actorId,
|
||||||
|
'action_type': 'filled_action_taken',
|
||||||
|
'meta': {'value': actionTaken},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logRows.isNotEmpty) {
|
||||||
|
await _insertActivityRows(_client, logRows);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update editable task fields such as title, description, office or linked ticket.
|
/// Update editable task fields such as title, description, office or linked ticket.
|
||||||
|
|
@ -1230,11 +1320,12 @@ class TaskAssignmentsController {
|
||||||
if (p != null) {
|
if (p != null) {
|
||||||
if (p['full_name'] != null) {
|
if (p['full_name'] != null) {
|
||||||
actorName = p['full_name'].toString();
|
actorName = p['full_name'].toString();
|
||||||
} else if (p['display_name'] != null)
|
} else if (p['display_name'] != null) {
|
||||||
actorName = p['display_name'].toString();
|
actorName = p['display_name'].toString();
|
||||||
else if (p['name'] != null)
|
} else if (p['name'] != null) {
|
||||||
actorName = p['name'].toString();
|
actorName = p['name'].toString();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -488,10 +488,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
canAssign: showAssign,
|
canAssign: showAssign,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Align(
|
const SizedBox.shrink(),
|
||||||
alignment: Alignment.bottomRight,
|
|
||||||
child: _buildTatSection(task),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -2259,12 +2256,35 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'started':
|
case 'started':
|
||||||
timeline.add(_activityRow('Task started', actorName, l.createdAt));
|
{
|
||||||
|
var label = 'Task started';
|
||||||
|
if (latestAssignment != null &&
|
||||||
|
l.actorId == latestAssignment.userId &&
|
||||||
|
responseDuration != null) {
|
||||||
|
final assigneeName =
|
||||||
|
profileById[latestAssignment.userId]?.fullName ??
|
||||||
|
latestAssignment.userId;
|
||||||
|
final resp = responseAt ?? AppTime.now();
|
||||||
|
label =
|
||||||
|
'Task started — Response: ${_formatDuration(responseDuration)} ($assigneeName responded at ${AppTime.formatDate(resp)} ${AppTime.formatTime(resp)})';
|
||||||
|
}
|
||||||
|
timeline.add(_activityRow(label, actorName, l.createdAt));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'completed':
|
case 'completed':
|
||||||
timeline.add(
|
{
|
||||||
_activityRow('Task completed', actorName, l.createdAt),
|
var label = 'Task completed';
|
||||||
);
|
if (task.startedAt != null) {
|
||||||
|
final start = task.startedAt!;
|
||||||
|
final end = task.completedAt ?? l.createdAt;
|
||||||
|
final exec = end.difference(start);
|
||||||
|
if (exec.inMilliseconds > 0) {
|
||||||
|
label =
|
||||||
|
'Task completed — Execution: ${_formatDuration(exec)} (${AppTime.formatDate(start)} ${AppTime.formatTime(start)} → ${AppTime.formatDate(end)} ${AppTime.formatTime(end)})';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timeline.add(_activityRow(label, actorName, l.createdAt));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
timeline.add(_activityRow(l.actionType, actorName, l.createdAt));
|
timeline.add(_activityRow(l.actionType, actorName, l.createdAt));
|
||||||
|
|
@ -2272,26 +2292,8 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (responseDuration != null) {
|
// Response and execution times are now merged into the related
|
||||||
final assigneeName =
|
// 'Task started' and 'Task completed' timeline entries above.
|
||||||
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(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|
@ -2301,46 +2303,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTatSection(Task task) {
|
// TAT helpers removed; timings are shown inline in the activity timeline.
|
||||||
final animateQueue = task.status == 'queued';
|
|
||||||
final animateExecution = task.startedAt != null && task.completedAt == null;
|
|
||||||
|
|
||||||
if (!animateQueue && !animateExecution) {
|
|
||||||
return _buildTatContent(task, AppTime.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
return StreamBuilder<int>(
|
|
||||||
stream: Stream.periodic(
|
|
||||||
const Duration(seconds: 1),
|
|
||||||
(tick) => tick,
|
|
||||||
).asBroadcastStream(),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
return _buildTatContent(task, AppTime.now());
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTatContent(Task task, DateTime now) {
|
|
||||||
final queueDuration = task.status == 'queued'
|
|
||||||
? now.difference(task.createdAt)
|
|
||||||
: _safeDuration(task.startedAt?.difference(task.createdAt));
|
|
||||||
final executionDuration = task.status == 'queued'
|
|
||||||
? null
|
|
||||||
: task.startedAt == null
|
|
||||||
? null
|
|
||||||
: task.completedAt == null
|
|
||||||
? now.difference(task.startedAt!)
|
|
||||||
: task.completedAt!.difference(task.startedAt!);
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text('Queue duration: ${_formatDuration(queueDuration)}'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('Task execution time: ${_formatDuration(executionDuration)}'),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _activityRow(String title, String actor, DateTime at) {
|
Widget _activityRow(String title, String actor, DateTime at) {
|
||||||
return Padding(
|
return Padding(
|
||||||
|
|
@ -2361,7 +2324,7 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
Text(title, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'$actor • ${at.toLocal()}',
|
'$actor • ${AppTime.formatDate(at)} ${AppTime.formatTime(at)}',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -2372,13 +2335,6 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration? _safeDuration(Duration? duration) {
|
|
||||||
if (duration == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return duration.isNegative ? Duration.zero : duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDuration(Duration? duration) {
|
String _formatDuration(Duration? duration) {
|
||||||
if (duration == null) {
|
if (duration == null) {
|
||||||
return 'Pending';
|
return 'Pending';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user