Fixed horizontal scrollbar, limit per page by 10 rows on desktop
Fixed duplicate entries in activity logs
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'dart:convert';
|
||||
|
||||
@@ -19,6 +18,82 @@ import 'stream_recovery.dart';
|
||||
import 'realtime_controller.dart';
|
||||
import '../utils/app_time.dart';
|
||||
|
||||
String _stableJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final keys = value.keys.map((k) => k.toString()).toList()..sort();
|
||||
final entries = <String, dynamic>{};
|
||||
for (final key in keys) {
|
||||
entries[key] = _stableJson(value[key]);
|
||||
}
|
||||
return jsonEncode(entries);
|
||||
}
|
||||
if (value is List) {
|
||||
return jsonEncode(value.map(_stableJson).toList(growable: false));
|
||||
}
|
||||
return jsonEncode(value);
|
||||
}
|
||||
|
||||
String _activityFingerprint(Map<String, dynamic> row) {
|
||||
final taskId = row['task_id']?.toString() ?? '';
|
||||
final actorId = row['actor_id']?.toString() ?? '';
|
||||
final actionType = row['action_type']?.toString() ?? '';
|
||||
final meta = row['meta'];
|
||||
return '$taskId|$actorId|$actionType|${_stableJson(meta)}';
|
||||
}
|
||||
|
||||
/// Build a UI-display fingerprint for a log entry so that entries that would
|
||||
/// render identically are collapsed to a single row.
|
||||
///
|
||||
/// For auto-save fields (filled_*), the actual text value changes every millisecond
|
||||
/// as the user types, creating hundreds of entries. By ignoring the text value and
|
||||
/// grouping strictly by "Task + Actor + Action + Visual Minute", all auto-save
|
||||
/// logs within the exact same minute collapse into a single display row.
|
||||
String _uiFingerprint(TaskActivityLog log) {
|
||||
const oncePerTask = {'created', 'started', 'completed'};
|
||||
if (oncePerTask.contains(log.actionType)) {
|
||||
return '${log.taskId}|${log.actionType}';
|
||||
}
|
||||
|
||||
// The UI displays time as "MMM dd, yyyy hh:mm AA". Grouping by year, month,
|
||||
// day, hour, and minute ensures we collapse duplicates that look identical on screen.
|
||||
final visualMinute =
|
||||
'${log.createdAt.year}-${log.createdAt.month}-${log.createdAt.day}-${log.createdAt.hour}-${log.createdAt.minute}';
|
||||
|
||||
if (log.actionType.startsWith('filled_')) {
|
||||
// DO NOT include the meta/value here! If the user types "A", "AB", "ABC" in the
|
||||
// same minute, including the value would make them mathematically "unique" and
|
||||
// bypass the deduplication. Grouping by time+action solves the auto-save spam.
|
||||
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$visualMinute';
|
||||
}
|
||||
|
||||
// For other actions, we also include the normalized meta to be safe.
|
||||
final metaStr = _normaliseMeta(log.meta);
|
||||
return '${log.taskId}|${log.actorId ?? ''}|${log.actionType}|$metaStr|$visualMinute';
|
||||
}
|
||||
|
||||
List<TaskActivityLog> _dedupeActivityLogs(List<TaskActivityLog> logs) {
|
||||
if (logs.length < 2) return logs;
|
||||
// Sort newest first so we always keep the most recent copy of a duplicate.
|
||||
final sorted = [...logs]..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
final seen = <String>{};
|
||||
final deduped = <TaskActivityLog>[];
|
||||
for (final log in sorted) {
|
||||
final fp = _uiFingerprint(log);
|
||||
if (seen.add(fp)) {
|
||||
deduped.add(log);
|
||||
}
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
/// Normalise a meta value for comparison: treat null and empty-map
|
||||
/// identically, and sort map keys for stability.
|
||||
String _normaliseMeta(dynamic meta) {
|
||||
if (meta == null) return '';
|
||||
final s = _stableJson(meta);
|
||||
return (s == 'null' || s == '{}') ? '' : s;
|
||||
}
|
||||
|
||||
// Helper to insert activity log rows while sanitizing nulls and
|
||||
// avoiding exceptions from malformed payloads. Accepts either a Map
|
||||
// or a List<Map>.
|
||||
@@ -38,7 +113,18 @@ Future<void> _insertActivityRows(dynamic client, dynamic rows) async {
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
if (sanitized.isEmpty) return;
|
||||
await client.from('task_activity_logs').insert(sanitized);
|
||||
|
||||
final seen = <String>{};
|
||||
final dedupedBatch = <Map<String, dynamic>>[];
|
||||
for (final row in sanitized) {
|
||||
final fingerprint = _activityFingerprint(row);
|
||||
if (seen.add(fingerprint)) {
|
||||
dedupedBatch.add(row);
|
||||
}
|
||||
}
|
||||
|
||||
if (dedupedBatch.isEmpty) return;
|
||||
await client.from('task_activity_logs').insert(dedupedBatch);
|
||||
} else if (rows is Map) {
|
||||
final m = Map<String, dynamic>.from(rows);
|
||||
m.removeWhere((k, v) => v == null);
|
||||
@@ -487,7 +573,7 @@ final taskActivityLogsProvider =
|
||||
);
|
||||
|
||||
ref.onDispose(wrapper.dispose);
|
||||
return wrapper.stream.map((result) => result.data);
|
||||
return wrapper.stream.map((result) => _dedupeActivityLogs(result.data));
|
||||
});
|
||||
|
||||
final taskAssignmentsControllerProvider = Provider<TaskAssignmentsController>((
|
||||
@@ -952,31 +1038,74 @@ class TasksController {
|
||||
required String status,
|
||||
String? reason,
|
||||
}) async {
|
||||
Map<String, dynamic>? taskRow;
|
||||
try {
|
||||
final row = await _client
|
||||
.from('tasks')
|
||||
.select('status, request_type, request_category, action_taken')
|
||||
.eq('id', taskId)
|
||||
.maybeSingle();
|
||||
if (row is! Map<String, dynamic>) {
|
||||
throw Exception('Task not found');
|
||||
}
|
||||
taskRow = row;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
|
||||
final currentStatus = (taskRow['status'] as String?)?.trim() ?? '';
|
||||
if (currentStatus == 'closed' ||
|
||||
currentStatus == 'cancelled' ||
|
||||
currentStatus == 'completed') {
|
||||
if (currentStatus != status) {
|
||||
throw Exception(
|
||||
'Status cannot be changed after a task is $currentStatus.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (status == 'cancelled') {
|
||||
if (reason == null || reason.trim().isEmpty) {
|
||||
throw Exception('Cancellation requires a reason.');
|
||||
}
|
||||
}
|
||||
if (status == 'completed') {
|
||||
// fetch current metadata to validate several required fields
|
||||
try {
|
||||
final row = await _client
|
||||
.from('tasks')
|
||||
// include all columns that must be non-null/empty before completing
|
||||
.select(
|
||||
// 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();
|
||||
if (row is! Map<String, dynamic>) {
|
||||
throw Exception('Task not found');
|
||||
}
|
||||
|
||||
final rt = row['request_type'];
|
||||
final rc = row['request_category'];
|
||||
final action = row['action_taken'];
|
||||
if (status == 'in_progress') {
|
||||
final assignmentRows = await _client
|
||||
.from('task_assignments')
|
||||
.select('user_id')
|
||||
.eq('task_id', taskId);
|
||||
final assignedUserIds = (assignmentRows as List)
|
||||
.map((row) => row['user_id']?.toString())
|
||||
.whereType<String>()
|
||||
.where((id) => id.isNotEmpty)
|
||||
.toSet()
|
||||
.toList();
|
||||
if (assignedUserIds.isEmpty) {
|
||||
throw Exception(
|
||||
'Assign at least one IT Staff before starting this task.',
|
||||
);
|
||||
}
|
||||
|
||||
final itStaffRows = await _client
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.inFilter('id', assignedUserIds)
|
||||
.eq('role', 'it_staff');
|
||||
final hasItStaff = (itStaffRows as List).isNotEmpty;
|
||||
if (!hasItStaff) {
|
||||
throw Exception(
|
||||
'Assign at least one IT Staff before starting this task.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (status == 'completed') {
|
||||
try {
|
||||
final rt = taskRow['request_type'];
|
||||
final rc = taskRow['request_category'];
|
||||
final action = taskRow['action_taken'];
|
||||
|
||||
final missing = <String>[];
|
||||
if (rt == null || (rt is String && rt.trim().isEmpty)) {
|
||||
|
||||
Reference in New Issue
Block a user