Added Task Number filter

This commit is contained in:
Marc Rejohn Castillano 2026-02-28 16:45:17 +08:00
parent 0a8e388757
commit 5a74299a1c
4 changed files with 152 additions and 84 deletions

View File

@ -140,9 +140,9 @@ Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
message.data['actorId'] ?? message.data['actorId'] ??
message.data['actor']) message.data['actor'])
?.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('|');
@ -318,14 +318,15 @@ Future<void> main() async {
.maybeSingle(); .maybeSingle();
if (res != null) { if (res != null) {
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;
}
} }
} catch (_) { } catch (_) {
// ignore lookup failures and fall back to data payload // ignore lookup failures and fall back to data payload
@ -359,9 +360,9 @@ Future<void> main() async {
message.data['actorId'] ?? message.data['actorId'] ??
message.data['actor']) message.data['actor'])
?.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('|');

View File

@ -58,7 +58,7 @@ class TaskQuery {
this.searchQuery = '', this.searchQuery = '',
this.officeId, this.officeId,
this.status, this.status,
this.assigneeId, this.taskNumber,
this.dateRange, this.dateRange,
}); });
@ -77,10 +77,9 @@ class TaskQuery {
/// Filter by status. /// Filter by status.
final String? status; final String? status;
/// Filter by assignee ID. /// Filter by task number (partial match, case-insensitive).
final String? assigneeId; final String? taskNumber;
/// Filter by date range.
/// Filter by date range. /// Filter by date range.
final DateTimeRange? dateRange; final DateTimeRange? dateRange;
@ -90,7 +89,7 @@ class TaskQuery {
String? searchQuery, String? searchQuery,
String? officeId, String? officeId,
String? status, String? status,
String? assigneeId, String? taskNumber,
DateTimeRange? dateRange, DateTimeRange? dateRange,
}) { }) {
return TaskQuery( return TaskQuery(
@ -99,7 +98,7 @@ class TaskQuery {
searchQuery: searchQuery ?? this.searchQuery, searchQuery: searchQuery ?? this.searchQuery,
officeId: officeId ?? this.officeId, officeId: officeId ?? this.officeId,
status: status ?? this.status, status: status ?? this.status,
assigneeId: assigneeId ?? this.assigneeId, taskNumber: taskNumber ?? this.taskNumber,
dateRange: dateRange ?? this.dateRange, dateRange: dateRange ?? this.dateRange,
); );
} }
@ -191,6 +190,12 @@ final tasksProvider = StreamProvider<List<Task>>((ref) {
) )
.toList(); .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: // Sort by status groups then within-group ordering:
// 1. queued order by priority (desc), then queue_order (asc), then created_at // 1. queued order by priority (desc), then queue_order (asc), then created_at
@ -354,16 +359,7 @@ class TasksController {
String? assignedNumber; String? assignedNumber;
try { try {
final rpcParams = { final rpcParams = Map<String, dynamic>.from(payload);
'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,
};
// Retry RPC on duplicate-key (23505) errors which may occur // Retry RPC on duplicate-key (23505) errors which may occur
// transiently due to concurrent inserts; prefer RPC always. // transiently due to concurrent inserts; prefer RPC always.
const int rpcMaxAttempts = 3; const int rpcMaxAttempts = 3;
@ -598,52 +594,63 @@ class TasksController {
} catch (_) {} } 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? taskNumber;
String? officeId; String? officeId;
String? officeName;
try { try {
final t = await _client final t = await _client
.from('tasks') .from('tasks')
.select('task_number, office_id') .select('task_number, office_id, offices(name)')
.eq('id', taskId) .eq('id', taskId)
.maybeSingle(); .maybeSingle();
if (t != null) { if (t != null) {
if (t['task_number'] != null) if (t['task_number'] != null) {
taskNumber = t['task_number'].toString(); taskNumber = t['task_number'].toString();
}
if (t['office_id'] != null) officeId = t['office_id'].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 (_) {} } catch (_) {}
// resolve office name when available if ((officeName == null || officeName.isEmpty) &&
String? officeName; officeId != null &&
if (officeId != null && officeId.isNotEmpty) { officeId.isNotEmpty) {
try { try {
final o = await _client final o = await _client
.from('offices') .from('offices')
.select('name') .select('name')
.eq('id', officeId) .eq('id', officeId)
.maybeSingle(); .maybeSingle();
if (o != null && o['name'] != null) if (o != null && o['name'] != null) {
officeName = o['name'].toString(); officeName = o['name'].toString();
}
} catch (_) {} } catch (_) {}
} }
final title = officeName != null final title = 'New task';
? '$actorName created a new task in $officeName'
: '$actorName created a new task';
final body = taskNumber != null final body = taskNumber != null
? (officeName != null ? (officeName != null
? '$actorName created task #$taskNumber in $officeName' ? '$actorName created task #$taskNumber in $officeName.'
: '$actorName created task #$taskNumber') : '$actorName created task #$taskNumber.')
: (officeName != null : (officeName != null
? '$actorName created a new task in $officeName' ? '$actorName created a new task in $officeName.'
: '$actorName created a new task'); : '$actorName created a new task.');
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'created', 'type': 'created',
if (taskNumber != null) 'task_number': taskNumber, 'task_number': ?taskNumber,
if (officeId != null) 'office_id': officeId, 'office_id': ?officeId,
if (officeName != null) 'office_name': officeName, 'office_name': ?officeName,
}; };
await _client.functions.invoke( await _client.functions.invoke(
@ -833,7 +840,6 @@ class TasksController {
if (officeId != null) payload['office_id'] = officeId; if (officeId != null) payload['office_id'] = officeId;
if (ticketId != null) payload['ticket_id'] = ticketId; if (ticketId != null) payload['ticket_id'] = ticketId;
if (payload.isEmpty) return; if (payload.isEmpty) return;
await _client.from('tasks').update(payload).eq('id', taskId); await _client.from('tasks').update(payload).eq('id', taskId);
// record an activity log for edit operations (best-effort) // record an activity log for edit operations (best-effort)
@ -1000,44 +1006,65 @@ class TasksController {
// send push for auto-assignment // send push for auto-assignment
try { try {
final actorName = 'Dispatcher'; 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 // fetch task_number and office for nicer deep-linking when available
String? taskNumber; String? taskNumber;
String? officeId; String? officeId;
String? officeName;
try { try {
final t = await _client final t = await _client
.from('tasks') .from('tasks')
.select('task_number, office_id') .select('task_number, office_id, offices(name)')
.eq('id', taskId) .eq('id', taskId)
.maybeSingle(); .maybeSingle();
if (t != null) { if (t != null) {
if (t['task_number'] != null) if (t['task_number'] != null) {
taskNumber = t['task_number'].toString(); taskNumber = t['task_number'].toString();
}
if (t['office_id'] != null) officeId = t['office_id'].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 (_) {} } catch (_) {}
String? officeName; if ((officeName == null || officeName.isEmpty) &&
if (officeId != null && officeId.isNotEmpty) { officeId != null &&
officeId.isNotEmpty) {
try { try {
final o = await _client final o = await _client
.from('offices') .from('offices')
.select('name') .select('name')
.eq('id', officeId) .eq('id', officeId)
.maybeSingle(); .maybeSingle();
if (o != null && o['name'] != null) if (o != null && o['name'] != null) {
officeName = o['name'].toString(); officeName = o['name'].toString();
}
} catch (_) {} } catch (_) {}
} }
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'assignment', 'type': 'assignment',
if (taskNumber != null) 'task_number': taskNumber, 'task_number': ?taskNumber,
if (officeId != null) 'office_id': officeId, 'office_id': ?officeId,
if (officeName != null) 'office_name': officeName, '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( await _client.functions.invoke(
'send_fcm', 'send_fcm',
body: { body: {
@ -1201,9 +1228,9 @@ class TaskAssignmentsController {
.eq('id', actorId) .eq('id', actorId)
.maybeSingle(); .maybeSingle();
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();
@ -1211,58 +1238,72 @@ class TaskAssignmentsController {
} catch (_) {} } catch (_) {}
} }
final title = '$actorName assigned you to a task'; // fetch task_number and office (try embedding office.name); fallback to offices lookup
final body = '$actorName has assigned you to a task';
// fetch task_number and office for nicer deep-linking when available
String? taskNumber; String? taskNumber;
String? officeId; String? officeId;
String? officeName;
try { try {
final t = await _client final t = await _client
.from('tasks') .from('tasks')
.select('task_number, office_id') .select('task_number, office_id, offices(name)')
.eq('id', taskId) .eq('id', taskId)
.maybeSingle(); .maybeSingle();
if (t != null) { if (t != null) {
if (t['task_number'] != null) if (t['task_number'] != null) {
taskNumber = t['task_number'].toString(); taskNumber = t['task_number'].toString();
}
if (t['office_id'] != null) officeId = t['office_id'].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 (_) {} } catch (_) {}
String? officeName; if ((officeName == null || officeName.isEmpty) &&
if (officeId != null && officeId.isNotEmpty) { officeId != null &&
officeId.isNotEmpty) {
try { try {
final o = await _client final o = await _client
.from('offices') .from('offices')
.select('name') .select('name')
.eq('id', officeId) .eq('id', officeId)
.maybeSingle(); .maybeSingle();
if (o != null && o['name'] != null) if (o != null && o['name'] != null) {
officeName = o['name'].toString(); officeName = o['name'].toString();
}
} catch (_) {} } catch (_) {}
} }
final dataPayload = <String, dynamic>{ final dataPayload = <String, dynamic>{
'type': 'assignment', 'type': 'assignment',
if (taskNumber != null) 'task_number': taskNumber, 'task_number': ?taskNumber,
if (ticketId != null) 'ticket_id': ticketId, 'ticket_id': ?ticketId,
if (officeId != null) 'office_id': officeId, 'office_id': ?officeId,
if (officeName != null) 'office_name': officeName, 'office_name': ?officeName,
}; };
// include office name in title/body when possible final title = 'Task assigned';
final displayTitle = officeName != null final body = taskNumber != null
? '$title in $officeName' ? (officeName != null
: title; ? '$actorName assigned you task #$taskNumber in $officeName.'
final displayBody = officeName != null ? '$body in $officeName' : body; : '$actorName assigned you task #$taskNumber.')
: (officeName != null
? '$actorName assigned you a task in $officeName.'
: '$actorName assigned you a task.');
await _client.functions.invoke( await _client.functions.invoke(
'send_fcm', 'send_fcm',
body: { body: {
'user_ids': userIds, 'user_ids': userIds,
'title': displayTitle, 'title': title,
'body': displayBody, 'body': body,
'data': dataPayload, 'data': dataPayload,
}, },
); );

View File

@ -315,7 +315,7 @@ class TicketsController {
'body': body, 'body': body,
'data': { 'data': {
'ticket_id': ticketId, 'ticket_id': ticketId,
if (ticketNumber != null) 'ticket_number': ticketNumber, 'ticket_number': ?ticketNumber,
'type': 'created', 'type': 'created',
}, },
}, },

View File

@ -46,6 +46,7 @@ class TasksListScreen extends ConsumerStatefulWidget {
class _TasksListScreenState extends ConsumerState<TasksListScreen> class _TasksListScreenState extends ConsumerState<TasksListScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final TextEditingController _subjectController = TextEditingController(); final TextEditingController _subjectController = TextEditingController();
final TextEditingController _taskNumberController = TextEditingController();
String? _selectedOfficeId; String? _selectedOfficeId;
String? _selectedStatus; String? _selectedStatus;
String? _selectedAssigneeId; String? _selectedAssigneeId;
@ -55,6 +56,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
@override @override
void dispose() { void dispose() {
_subjectController.dispose(); _subjectController.dispose();
_taskNumberController.dispose();
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
} }
@ -63,13 +65,18 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
void initState() { void initState() {
super.initState(); super.initState();
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
_tabController.addListener(() {
// rebuild when tab changes so filters shown/hidden update
setState(() {});
});
} }
bool get _hasTaskFilters { bool get _hasTaskFilters {
return _subjectController.text.trim().isNotEmpty || return _subjectController.text.trim().isNotEmpty ||
_taskNumberController.text.trim().isNotEmpty ||
_selectedOfficeId != null || _selectedOfficeId != null ||
_selectedStatus != null || _selectedStatus != null ||
_selectedAssigneeId != null || (_tabController.index == 1 && _selectedAssigneeId != null) ||
_selectedDateRange != null; _selectedDateRange != null;
} }
@ -155,6 +162,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
tasks, tasks,
ticketById: ticketById, ticketById: ticketById,
subjectQuery: _subjectController.text, subjectQuery: _subjectController.text,
taskNumber: _taskNumberController.text,
officeId: _selectedOfficeId, officeId: _selectedOfficeId,
status: _selectedStatus, status: _selectedStatus,
assigneeId: _selectedAssigneeId, assigneeId: _selectedAssigneeId,
@ -191,19 +199,31 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen>
), ),
), ),
SizedBox( SizedBox(
width: 220, width: 160,
child: DropdownButtonFormField<String?>( child: TextField(
isExpanded: true, controller: _taskNumberController,
key: ValueKey(_selectedAssigneeId), onChanged: (_) => setState(() {}),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Assigned staff', labelText: 'Task #',
prefixIcon: Icon(Icons.filter_alt),
), ),
), ),
), ),
if (_tabController.index == 1)
SizedBox(
width: 220,
child: DropdownButtonFormField<String?>(
isExpanded: true,
key: ValueKey(_selectedAssigneeId),
initialValue: _selectedAssigneeId,
items: staffOptions,
onChanged: (value) =>
setState(() => _selectedAssigneeId = value),
decoration: const InputDecoration(
labelText: 'Assigned staff',
),
),
),
SizedBox( SizedBox(
width: 180, width: 180,
child: DropdownButtonFormField<String?>( child: DropdownButtonFormField<String?>(
@ -760,6 +780,7 @@ List<Task> _applyTaskFilters(
List<Task> tasks, { List<Task> tasks, {
required Map<String, Ticket> ticketById, required Map<String, Ticket> ticketById,
required String subjectQuery, required String subjectQuery,
required String taskNumber,
required String? officeId, required String? officeId,
required String? status, required String? status,
required String? assigneeId, required String? assigneeId,
@ -767,7 +788,12 @@ List<Task> _applyTaskFilters(
required Map<String, String?> latestAssigneeByTaskId, required Map<String, String?> latestAssigneeByTaskId,
}) { }) {
final query = subjectQuery.trim().toLowerCase(); final query = subjectQuery.trim().toLowerCase();
final tnQuery = taskNumber.trim().toLowerCase();
return tasks.where((task) { 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 ticket = task.ticketId == null ? null : ticketById[task.ticketId];
final subject = task.title.isNotEmpty final subject = task.title.isNotEmpty
? task.title ? task.title