From 5a74299a1c6ab63e4a4aa29343a7472fefb5c206 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Sat, 28 Feb 2026 16:45:17 +0800 Subject: [PATCH] Added Task Number filter --- lib/main.dart | 15 +- lib/providers/tasks_provider.dart | 173 ++++++++++++++--------- lib/providers/tickets_provider.dart | 2 +- lib/screens/tasks/tasks_list_screen.dart | 46 ++++-- 4 files changed, 152 insertions(+), 84 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index ceea2377..9299803c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -140,9 +140,9 @@ Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { message.data['actorId'] ?? message.data['actor']) ?.toString(); - if (taskNumber != null && taskNumber.isNotEmpty) + if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); - else if (taskId != null && taskId.isNotEmpty) + } else if (taskId != null && taskId.isNotEmpty) sb.write('task:$taskId'); if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); @@ -318,14 +318,15 @@ Future main() async { .maybeSingle(); if (res != null) { String? name; - if (res['full_name'] != null) + if (res['full_name'] != null) { name = res['full_name'].toString(); - else if (res['display_name'] != null) + } else if (res['display_name'] != null) name = res['display_name'].toString(); else if (res['name'] != null) name = res['name'].toString(); - if (name != null && name.isNotEmpty) + if (name != null && name.isNotEmpty) { dataForFormatting['actor_name'] = name; + } } } catch (_) { // ignore lookup failures and fall back to data payload @@ -359,9 +360,9 @@ Future main() async { message.data['actorId'] ?? message.data['actor']) ?.toString(); - if (taskNumber != null && taskNumber.isNotEmpty) + if (taskNumber != null && taskNumber.isNotEmpty) { sb.write('tasknum:$taskNumber'); - else if (taskId != null && taskId.isNotEmpty) + } else if (taskId != null && taskId.isNotEmpty) sb.write('task:$taskId'); if (ticketId != null && ticketId.isNotEmpty) { if (sb.isNotEmpty) sb.write('|'); diff --git a/lib/providers/tasks_provider.dart b/lib/providers/tasks_provider.dart index 09c9058e..e20b4012 100644 --- a/lib/providers/tasks_provider.dart +++ b/lib/providers/tasks_provider.dart @@ -58,7 +58,7 @@ class TaskQuery { this.searchQuery = '', this.officeId, this.status, - this.assigneeId, + this.taskNumber, this.dateRange, }); @@ -77,10 +77,9 @@ class TaskQuery { /// Filter by status. final String? status; - /// Filter by assignee ID. - final String? assigneeId; + /// Filter by task number (partial match, case-insensitive). + final String? taskNumber; - /// Filter by date range. /// Filter by date range. final DateTimeRange? dateRange; @@ -90,7 +89,7 @@ class TaskQuery { String? searchQuery, String? officeId, String? status, - String? assigneeId, + String? taskNumber, DateTimeRange? dateRange, }) { return TaskQuery( @@ -99,7 +98,7 @@ class TaskQuery { searchQuery: searchQuery ?? this.searchQuery, officeId: officeId ?? this.officeId, status: status ?? this.status, - assigneeId: assigneeId ?? this.assigneeId, + taskNumber: taskNumber ?? this.taskNumber, dateRange: dateRange ?? this.dateRange, ); } @@ -191,6 +190,12 @@ final tasksProvider = StreamProvider>((ref) { ) .toList(); } + if (query.taskNumber != null && query.taskNumber!.trim().isNotEmpty) { + final tn = query.taskNumber!.toLowerCase(); + list = list + .where((t) => (t.taskNumber ?? '').toLowerCase().contains(tn)) + .toList(); + } // Sort by status groups then within-group ordering: // 1. queued – order by priority (desc), then queue_order (asc), then created_at @@ -354,16 +359,7 @@ class TasksController { String? assignedNumber; try { - final rpcParams = { - 'p_title': title, - 'p_description': description, - 'p_office_id': officeId, - 'p_ticket_id': ticketId, - 'p_request_type': requestType, - 'p_request_type_other': requestTypeOther, - 'p_request_category': requestCategory, - 'p_creator_id': actorId, - }; + final rpcParams = Map.from(payload); // Retry RPC on duplicate-key (23505) errors which may occur // transiently due to concurrent inserts; prefer RPC always. const int rpcMaxAttempts = 3; @@ -598,52 +594,63 @@ class TasksController { } catch (_) {} } - // fetch task_number and office_id for nicer message and deep-linking + // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; + String? officeName; try { final t = await _client .from('tasks') - .select('task_number, office_id') + .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { - if (t['task_number'] != null) + if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); + } if (t['office_id'] != null) officeId = t['office_id'].toString(); + final dynOffices = t['offices']; + if (dynOffices != null) { + if (dynOffices is List && + dynOffices.isNotEmpty && + dynOffices.first['name'] != null) { + officeName = dynOffices.first['name'].toString(); + } else if (dynOffices is Map && dynOffices['name'] != null) { + officeName = dynOffices['name'].toString(); + } + } } } catch (_) {} - // resolve office name when available - String? officeName; - if (officeId != null && officeId.isNotEmpty) { + if ((officeName == null || officeName.isEmpty) && + officeId != null && + officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); - if (o != null && o['name'] != null) + if (o != null && o['name'] != null) { officeName = o['name'].toString(); + } } catch (_) {} } - final title = officeName != null - ? '$actorName created a new task in $officeName' - : '$actorName created a new task'; + final title = 'New task'; final body = taskNumber != null ? (officeName != null - ? '$actorName created task #$taskNumber in $officeName' - : '$actorName created task #$taskNumber') + ? '$actorName created task #$taskNumber in $officeName.' + : '$actorName created task #$taskNumber.') : (officeName != null - ? '$actorName created a new task in $officeName' - : '$actorName created a new task'); + ? '$actorName created a new task in $officeName.' + : '$actorName created a new task.'); final dataPayload = { 'type': 'created', - if (taskNumber != null) 'task_number': taskNumber, - if (officeId != null) 'office_id': officeId, - if (officeName != null) 'office_name': officeName, + 'task_number': ?taskNumber, + 'office_id': ?officeId, + 'office_name': ?officeName, }; await _client.functions.invoke( @@ -833,7 +840,6 @@ class TasksController { if (officeId != null) payload['office_id'] = officeId; if (ticketId != null) payload['ticket_id'] = ticketId; if (payload.isEmpty) return; - await _client.from('tasks').update(payload).eq('id', taskId); // record an activity log for edit operations (best-effort) @@ -1000,44 +1006,65 @@ class TasksController { // send push for auto-assignment try { final actorName = 'Dispatcher'; - final title = '$actorName assigned you a task'; - final body = '$actorName assigned you a task'; // fetch task_number and office for nicer deep-linking when available String? taskNumber; String? officeId; + String? officeName; try { final t = await _client .from('tasks') - .select('task_number, office_id') + .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { - if (t['task_number'] != null) + if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); + } if (t['office_id'] != null) officeId = t['office_id'].toString(); + final dynOffices = t['offices']; + if (dynOffices != null) { + if (dynOffices is List && + dynOffices.isNotEmpty && + dynOffices.first['name'] != null) { + officeName = dynOffices.first['name'].toString(); + } else if (dynOffices is Map && dynOffices['name'] != null) { + officeName = dynOffices['name'].toString(); + } + } } } catch (_) {} - String? officeName; - if (officeId != null && officeId.isNotEmpty) { + if ((officeName == null || officeName.isEmpty) && + officeId != null && + officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); - if (o != null && o['name'] != null) + if (o != null && o['name'] != null) { officeName = o['name'].toString(); + } } catch (_) {} } final dataPayload = { 'type': 'assignment', - if (taskNumber != null) 'task_number': taskNumber, - if (officeId != null) 'office_id': officeId, - if (officeName != null) 'office_name': officeName, + 'task_number': ?taskNumber, + 'office_id': ?officeId, + 'office_name': ?officeName, }; + final title = 'Task assigned'; + final body = taskNumber != null + ? (officeName != null + ? '$actorName assigned you task #$taskNumber in $officeName.' + : '$actorName assigned you task #$taskNumber.') + : (officeName != null + ? '$actorName assigned you a task in $officeName.' + : '$actorName assigned you a task.'); + await _client.functions.invoke( 'send_fcm', body: { @@ -1201,9 +1228,9 @@ class TaskAssignmentsController { .eq('id', actorId) .maybeSingle(); if (p != null) { - if (p['full_name'] != null) + if (p['full_name'] != null) { actorName = p['full_name'].toString(); - else if (p['display_name'] != null) + } else if (p['display_name'] != null) actorName = p['display_name'].toString(); else if (p['name'] != null) actorName = p['name'].toString(); @@ -1211,58 +1238,72 @@ class TaskAssignmentsController { } catch (_) {} } - final title = '$actorName assigned you to a task'; - final body = '$actorName has assigned you to a task'; - - // fetch task_number and office for nicer deep-linking when available + // fetch task_number and office (try embedding office.name); fallback to offices lookup String? taskNumber; String? officeId; + String? officeName; try { final t = await _client .from('tasks') - .select('task_number, office_id') + .select('task_number, office_id, offices(name)') .eq('id', taskId) .maybeSingle(); if (t != null) { - if (t['task_number'] != null) + if (t['task_number'] != null) { taskNumber = t['task_number'].toString(); + } if (t['office_id'] != null) officeId = t['office_id'].toString(); + final dynOffices = t['offices']; + if (dynOffices != null) { + if (dynOffices is List && + dynOffices.isNotEmpty && + dynOffices.first['name'] != null) { + officeName = dynOffices.first['name'].toString(); + } else if (dynOffices is Map && dynOffices['name'] != null) { + officeName = dynOffices['name'].toString(); + } + } } } catch (_) {} - String? officeName; - if (officeId != null && officeId.isNotEmpty) { + if ((officeName == null || officeName.isEmpty) && + officeId != null && + officeId.isNotEmpty) { try { final o = await _client .from('offices') .select('name') .eq('id', officeId) .maybeSingle(); - if (o != null && o['name'] != null) + if (o != null && o['name'] != null) { officeName = o['name'].toString(); + } } catch (_) {} } final dataPayload = { 'type': 'assignment', - if (taskNumber != null) 'task_number': taskNumber, - if (ticketId != null) 'ticket_id': ticketId, - if (officeId != null) 'office_id': officeId, - if (officeName != null) 'office_name': officeName, + 'task_number': ?taskNumber, + 'ticket_id': ?ticketId, + 'office_id': ?officeId, + 'office_name': ?officeName, }; - // include office name in title/body when possible - final displayTitle = officeName != null - ? '$title in $officeName' - : title; - final displayBody = officeName != null ? '$body in $officeName' : body; + final title = 'Task assigned'; + final body = taskNumber != null + ? (officeName != null + ? '$actorName assigned you task #$taskNumber in $officeName.' + : '$actorName assigned you task #$taskNumber.') + : (officeName != null + ? '$actorName assigned you a task in $officeName.' + : '$actorName assigned you a task.'); await _client.functions.invoke( 'send_fcm', body: { 'user_ids': userIds, - 'title': displayTitle, - 'body': displayBody, + 'title': title, + 'body': body, 'data': dataPayload, }, ); diff --git a/lib/providers/tickets_provider.dart b/lib/providers/tickets_provider.dart index dfc45254..e2a7386e 100644 --- a/lib/providers/tickets_provider.dart +++ b/lib/providers/tickets_provider.dart @@ -315,7 +315,7 @@ class TicketsController { 'body': body, 'data': { 'ticket_id': ticketId, - if (ticketNumber != null) 'ticket_number': ticketNumber, + 'ticket_number': ?ticketNumber, 'type': 'created', }, }, diff --git a/lib/screens/tasks/tasks_list_screen.dart b/lib/screens/tasks/tasks_list_screen.dart index 310de03b..0e686e9b 100644 --- a/lib/screens/tasks/tasks_list_screen.dart +++ b/lib/screens/tasks/tasks_list_screen.dart @@ -46,6 +46,7 @@ class TasksListScreen extends ConsumerStatefulWidget { class _TasksListScreenState extends ConsumerState with SingleTickerProviderStateMixin { final TextEditingController _subjectController = TextEditingController(); + final TextEditingController _taskNumberController = TextEditingController(); String? _selectedOfficeId; String? _selectedStatus; String? _selectedAssigneeId; @@ -55,6 +56,7 @@ class _TasksListScreenState extends ConsumerState @override void dispose() { _subjectController.dispose(); + _taskNumberController.dispose(); _tabController.dispose(); super.dispose(); } @@ -63,13 +65,18 @@ class _TasksListScreenState extends ConsumerState void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); + _tabController.addListener(() { + // rebuild when tab changes so filters shown/hidden update + setState(() {}); + }); } bool get _hasTaskFilters { return _subjectController.text.trim().isNotEmpty || + _taskNumberController.text.trim().isNotEmpty || _selectedOfficeId != null || _selectedStatus != null || - _selectedAssigneeId != null || + (_tabController.index == 1 && _selectedAssigneeId != null) || _selectedDateRange != null; } @@ -155,6 +162,7 @@ class _TasksListScreenState extends ConsumerState tasks, ticketById: ticketById, subjectQuery: _subjectController.text, + taskNumber: _taskNumberController.text, officeId: _selectedOfficeId, status: _selectedStatus, assigneeId: _selectedAssigneeId, @@ -191,19 +199,31 @@ class _TasksListScreenState extends ConsumerState ), ), SizedBox( - width: 220, - child: DropdownButtonFormField( - isExpanded: true, - key: ValueKey(_selectedAssigneeId), - initialValue: _selectedAssigneeId, - items: staffOptions, - onChanged: (value) => - setState(() => _selectedAssigneeId = value), + width: 160, + child: TextField( + controller: _taskNumberController, + onChanged: (_) => setState(() {}), decoration: const InputDecoration( - labelText: 'Assigned staff', + labelText: 'Task #', + prefixIcon: Icon(Icons.filter_alt), ), ), ), + if (_tabController.index == 1) + SizedBox( + width: 220, + child: DropdownButtonFormField( + isExpanded: true, + key: ValueKey(_selectedAssigneeId), + initialValue: _selectedAssigneeId, + items: staffOptions, + onChanged: (value) => + setState(() => _selectedAssigneeId = value), + decoration: const InputDecoration( + labelText: 'Assigned staff', + ), + ), + ), SizedBox( width: 180, child: DropdownButtonFormField( @@ -760,6 +780,7 @@ List _applyTaskFilters( List tasks, { required Map ticketById, required String subjectQuery, + required String taskNumber, required String? officeId, required String? status, required String? assigneeId, @@ -767,7 +788,12 @@ List _applyTaskFilters( required Map latestAssigneeByTaskId, }) { final query = subjectQuery.trim().toLowerCase(); + final tnQuery = taskNumber.trim().toLowerCase(); return tasks.where((task) { + if (tnQuery.isNotEmpty && + !(task.taskNumber?.toLowerCase().contains(tnQuery) ?? false)) { + return false; + } final ticket = task.ticketId == null ? null : ticketById[task.ticketId]; final subject = task.title.isNotEmpty ? task.title