Added Type, Category and Signatories on task

This commit is contained in:
2026-02-21 14:33:22 +08:00
parent d32449d096
commit 8d31a629ac
14 changed files with 1178 additions and 48 deletions
+23
View File
@@ -14,6 +14,13 @@ class Task {
required this.creatorId,
required this.startedAt,
required this.completedAt,
// new optional metadata fields
this.requestedBy,
this.notedBy,
this.receivedBy,
this.requestType,
this.requestTypeOther,
this.requestCategory,
});
final String id;
@@ -29,6 +36,16 @@ class Task {
final DateTime? startedAt;
final DateTime? completedAt;
// Optional client/user metadata
final String? requestedBy;
final String? notedBy;
final String? receivedBy;
/// Optional request metadata added later in lifecycle.
final String? requestType;
final String? requestTypeOther;
final String? requestCategory;
factory Task.fromMap(Map<String, dynamic> map) {
return Task(
id: map['id'] as String,
@@ -47,6 +64,12 @@ class Task {
completedAt: map['completed_at'] == null
? null
: AppTime.parse(map['completed_at'] as String),
requestType: map['request_type'] as String?,
requestTypeOther: map['request_type_other'] as String?,
requestCategory: map['request_category'] as String?,
requestedBy: map['requested_by'] as String?,
notedBy: map['noted_by'] as String?,
receivedBy: map['received_by'] as String?,
);
}
}
+93 -4
View File
@@ -209,21 +209,41 @@ final tasksControllerProvider = Provider<TasksController>((ref) {
class TasksController {
TasksController(this._client);
final SupabaseClient _client;
// _client is declared dynamic allowing test doubles that mimic only the
// subset of methods used by this class. In production it will be a
// SupabaseClient instance.
final dynamic _client;
Future<void> createTask({
required String title,
required String description,
String? officeId,
String? ticketId,
// optional request metadata when creating a task
String? requestType,
String? requestTypeOther,
String? requestCategory,
}) async {
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;
if (officeId != null) {
payload['office_id'] = officeId;
}
if (ticketId != null) {
payload['ticket_id'] = ticketId;
}
if (requestType != null) {
payload['request_type'] = requestType;
}
if (requestTypeOther != null) {
payload['request_type_other'] = requestTypeOther;
}
if (requestCategory != null) {
payload['request_category'] = requestCategory;
}
final data = await _client
.from('tasks')
@@ -291,7 +311,7 @@ class TasksController {
.from('profiles')
.select('id, role')
.inFilter('role', roles);
final rows = data as List<dynamic>;
final rows = data;
final ids = rows
.map((row) => row['id'] as String?)
.whereType<String>()
@@ -303,10 +323,36 @@ class TasksController {
}
}
/// Update only the status of a task.
///
/// Before marking a task as **completed** we enforce that the
/// request type/category metadata have been provided. This protects the
/// business rule that details must be specified before closing.
Future<void> updateTaskStatus({
required String taskId,
required String status,
}) async {
if (status == 'completed') {
// fetch current metadata to validate
try {
final row = await _client
.from('tasks')
.select('request_type, request_category')
.eq('id', taskId)
.maybeSingle();
final rt = row is Map ? row['request_type'] : null;
final rc = row is Map ? row['request_category'] : null;
if (rt == null || rc == null) {
throw Exception(
'Request type and category must be set before completing a task.',
);
}
} catch (e) {
// rethrow so callers can handle
rethrow;
}
}
await _client.from('tasks').update({'status': status}).eq('id', taskId);
// Log important status transitions
@@ -330,6 +376,49 @@ class TasksController {
}
}
/// Update arbitrary fields on a task row.
///
/// Primarily used to set request metadata after creation or during
/// status transitions.
Future<void> updateTask({
required String taskId,
String? requestType,
String? requestTypeOther,
String? requestCategory,
String? status,
String? requestedBy,
String? notedBy,
String? receivedBy,
}) async {
final payload = <String, dynamic>{};
if (requestType != null) {
payload['request_type'] = requestType;
}
if (requestTypeOther != null) {
payload['request_type_other'] = requestTypeOther;
}
if (requestCategory != null) {
payload['request_category'] = requestCategory;
}
if (requestedBy != null) {
payload['requested_by'] = requestedBy;
}
if (notedBy != null) {
payload['noted_by'] = notedBy;
}
// `performed_by` is derived from task assignments; we don't persist it here.
if (receivedBy != null) {
payload['received_by'] = receivedBy;
}
if (status != null) {
payload['status'] = status;
}
if (payload.isEmpty) {
return;
}
await _client.from('tasks').update(payload).eq('id', taskId);
}
// Auto-assignment logic executed once on creation.
Future<void> _autoAssignTask({
required String taskId,
+504 -4
View File
@@ -8,6 +8,9 @@ import '../../models/task_activity_log.dart';
import '../../models/ticket.dart';
import '../../models/ticket_message.dart';
import '../../providers/notifications_provider.dart';
import 'dart:async';
import '../../providers/supabase_provider.dart';
import 'package:flutter_typeahead/flutter_typeahead.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
@@ -21,6 +24,17 @@ import '../../theme/app_surfaces.dart';
import '../../widgets/task_assignment_section.dart';
import '../../widgets/typing_dots.dart';
// Local request metadata options (kept consistent with other screens)
const List<String> requestTypeOptions = [
'Install',
'Repair',
'Upgrade',
'Replace',
'Other',
];
const List<String> requestCategoryOptions = ['Software', 'Hardware', 'Network'];
class TaskDetailScreen extends ConsumerStatefulWidget {
const TaskDetailScreen({super.key, required this.taskId});
@@ -32,6 +46,13 @@ class TaskDetailScreen extends ConsumerStatefulWidget {
class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
final _messageController = TextEditingController();
// Controllers for editable signatories
final _requestedController = TextEditingController();
final _notedController = TextEditingController();
final _receivedController = TextEditingController();
Timer? _requestedDebounce;
Timer? _notedDebounce;
Timer? _receivedDebounce;
static const List<String> _statusOptions = [
'queued',
'in_progress',
@@ -54,6 +75,12 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
@override
void dispose() {
_messageController.dispose();
_requestedController.dispose();
_notedController.dispose();
_receivedController.dispose();
_requestedDebounce?.cancel();
_notedDebounce?.cancel();
_receivedDebounce?.cancel();
super.dispose();
}
@@ -149,10 +176,480 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
const SizedBox(height: 12),
Text(description),
],
const SizedBox(height: 12),
_buildTatSection(task),
const SizedBox(height: 16),
TaskAssignmentSection(taskId: task.id, canAssign: showAssign),
// Tabbed details: Assignees / Type & Category / Signatories
DefaultTabController(
length: 3,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TabBar(
labelColor: Theme.of(context).colorScheme.onSurface,
indicatorColor: Theme.of(context).colorScheme.primary,
tabs: const [
Tab(text: 'Assignees'),
Tab(text: 'Type & Category'),
Tab(text: 'Signatories'),
],
),
const SizedBox(height: 8),
SizedBox(
height: isWide ? 360 : 300,
child: TabBarView(
children: [
// Assignees
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TaskAssignmentSection(
taskId: task.id,
canAssign: showAssign,
),
const SizedBox(height: 12),
Align(
alignment: Alignment.bottomRight,
child: _buildTatSection(task),
),
],
),
),
),
// Type & Category
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!canUpdateStatus) ...[
_MetaBadge(
label: 'Type',
value: task.requestType ?? 'None',
),
const SizedBox(height: 8),
_MetaBadge(
label: 'Category',
value: task.requestCategory ?? 'None',
),
] else ...[
const Text('Type'),
const SizedBox(height: 6),
DropdownButtonFormField<String?>(
initialValue: task.requestType,
items: [
const DropdownMenuItem(
value: null,
child: Text('None'),
),
for (final t in requestTypeOptions)
DropdownMenuItem(
value: t,
child: Text(t),
),
],
onChanged: (v) => ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestType: v,
),
),
if (task.requestType == 'Other') ...[
const SizedBox(height: 8),
TextFormField(
initialValue: task.requestTypeOther,
decoration: const InputDecoration(
hintText: 'Details',
),
onChanged: (text) => ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestTypeOther: text.isEmpty
? null
: text,
),
),
],
const SizedBox(height: 8),
const Text('Category'),
const SizedBox(height: 6),
DropdownButtonFormField<String?>(
initialValue: task.requestCategory,
items: [
const DropdownMenuItem(
value: null,
child: Text('None'),
),
for (final c in requestCategoryOptions)
DropdownMenuItem(
value: c,
child: Text(c),
),
],
onChanged: (v) => ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestCategory: v,
),
),
],
const SizedBox(height: 12),
],
),
),
),
// Signatories (editable)
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Requested by',
style: Theme.of(
context,
).textTheme.bodySmall,
),
const SizedBox(height: 6),
TypeAheadFormField<String>(
textFieldConfiguration:
TextFieldConfiguration(
controller: _requestedController,
decoration: const InputDecoration(
hintText: 'Requester name or id',
),
onChanged: (v) {
_requestedDebounce?.cancel();
_requestedDebounce = Timer(
const Duration(milliseconds: 700),
() async {
final name = v.trim();
await ref
.read(
tasksControllerProvider,
)
.updateTask(
taskId: task.id,
requestedBy: name.isEmpty
? null
: name,
);
if (name.isNotEmpty) {
try {
await ref
.read(
supabaseClientProvider,
)
.from('clients')
.upsert({'name': name});
} catch (_) {}
}
},
);
},
),
suggestionsCallback: (pattern) async {
final profiles =
ref
.watch(profilesProvider)
.valueOrNull ??
[];
final fromProfiles = profiles
.map(
(p) => p.fullName.isEmpty
? p.id
: p.fullName,
)
.where(
(n) => n.toLowerCase().contains(
pattern.toLowerCase(),
),
)
.toList();
try {
final clientRows = await ref
.read(supabaseClientProvider)
.from('clients')
.select('name')
.ilike('name', '%$pattern%');
final clientNames =
(clientRows as List<dynamic>?)
?.map(
(r) => r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
final merged = {
...fromProfiles,
...clientNames,
}.toList();
return merged;
} catch (_) {
return fromProfiles;
}
},
itemBuilder: (context, suggestion) =>
ListTile(title: Text(suggestion)),
onSuggestionSelected: (suggestion) async {
_requestedDebounce?.cancel();
_requestedController.text = suggestion;
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
requestedBy: suggestion.isEmpty
? null
: suggestion,
);
try {
if (suggestion.isNotEmpty) {
await ref
.read(supabaseClientProvider)
.from('clients')
.upsert({'name': suggestion});
}
} catch (_) {}
},
),
const SizedBox(height: 12),
Text(
'Noted by (Supervisor/Senior)',
style: Theme.of(
context,
).textTheme.bodySmall,
),
const SizedBox(height: 6),
TypeAheadFormField<String>(
textFieldConfiguration:
TextFieldConfiguration(
controller: _notedController,
decoration: const InputDecoration(
hintText: 'Supervisor/Senior',
),
onChanged: (v) {
_notedDebounce?.cancel();
_notedDebounce = Timer(
const Duration(milliseconds: 700),
() async {
final name = v.trim();
await ref
.read(
tasksControllerProvider,
)
.updateTask(
taskId: task.id,
notedBy: name.isEmpty
? null
: name,
);
if (name.isNotEmpty) {
try {
await ref
.read(
supabaseClientProvider,
)
.from('clients')
.upsert({'name': name});
} catch (_) {}
}
},
);
},
),
suggestionsCallback: (pattern) async {
final profiles =
ref
.watch(profilesProvider)
.valueOrNull ??
[];
final fromProfiles = profiles
.map(
(p) => p.fullName.isEmpty
? p.id
: p.fullName,
)
.where(
(n) => n.toLowerCase().contains(
pattern.toLowerCase(),
),
)
.toList();
try {
final clientRows = await ref
.read(supabaseClientProvider)
.from('clients')
.select('name')
.ilike('name', '%$pattern%');
final clientNames =
(clientRows as List<dynamic>?)
?.map(
(r) => r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
final merged = {
...fromProfiles,
...clientNames,
}.toList();
return merged;
} catch (_) {
return fromProfiles;
}
},
itemBuilder: (context, suggestion) =>
ListTile(title: Text(suggestion)),
onSuggestionSelected: (suggestion) async {
_notedDebounce?.cancel();
_notedController.text = suggestion;
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
notedBy: suggestion.isEmpty
? null
: suggestion,
);
try {
if (suggestion.isNotEmpty) {
await ref
.read(supabaseClientProvider)
.from('clients')
.upsert({'name': suggestion});
}
} catch (_) {}
},
),
const SizedBox(height: 12),
Text(
'Received by',
style: Theme.of(
context,
).textTheme.bodySmall,
),
const SizedBox(height: 6),
TypeAheadFormField<String>(
textFieldConfiguration:
TextFieldConfiguration(
controller: _receivedController,
decoration: const InputDecoration(
hintText: 'Receiver name or id',
),
onChanged: (v) {
_receivedDebounce?.cancel();
_receivedDebounce = Timer(
const Duration(milliseconds: 700),
() async {
final name = v.trim();
await ref
.read(
tasksControllerProvider,
)
.updateTask(
taskId: task.id,
receivedBy: name.isEmpty
? null
: name,
);
if (name.isNotEmpty) {
try {
await ref
.read(
supabaseClientProvider,
)
.from('clients')
.upsert({'name': name});
} catch (_) {}
}
},
);
},
),
suggestionsCallback: (pattern) async {
final profiles =
ref
.watch(profilesProvider)
.valueOrNull ??
[];
final fromProfiles = profiles
.map(
(p) => p.fullName.isEmpty
? p.id
: p.fullName,
)
.where(
(n) => n.toLowerCase().contains(
pattern.toLowerCase(),
),
)
.toList();
try {
final clientRows = await ref
.read(supabaseClientProvider)
.from('clients')
.select('name')
.ilike('name', '%$pattern%');
final clientNames =
(clientRows as List<dynamic>?)
?.map(
(r) => r['name'] as String,
)
.whereType<String>()
.toList() ??
<String>[];
final merged = {
...fromProfiles,
...clientNames,
}.toList();
return merged;
} catch (_) {
return fromProfiles;
}
},
itemBuilder: (context, suggestion) =>
ListTile(title: Text(suggestion)),
onSuggestionSelected: (suggestion) async {
_receivedDebounce?.cancel();
_receivedController.text = suggestion;
await ref
.read(tasksControllerProvider)
.updateTask(
taskId: task.id,
receivedBy: suggestion.isEmpty
? null
: suggestion,
);
try {
if (suggestion.isNotEmpty) {
await ref
.read(supabaseClientProvider)
.from('clients')
.upsert({'name': suggestion});
}
} catch (_) {}
},
),
],
),
),
),
],
),
),
],
),
),
],
);
@@ -630,7 +1127,10 @@ class _TaskDetailScreenState extends ConsumerState<TaskDetailScreen> {
}
return StreamBuilder<int>(
stream: Stream.periodic(const Duration(seconds: 1), (tick) => tick),
stream: Stream.periodic(
const Duration(seconds: 1),
(tick) => tick,
).asBroadcastStream(),
builder: (context, snapshot) {
return _buildTatContent(task, AppTime.now());
},
+85 -14
View File
@@ -20,6 +20,21 @@ import '../../widgets/tasq_adaptive_list.dart';
import '../../widgets/typing_dots.dart';
import '../../theme/app_surfaces.dart';
// request metadata options used in task creation/editing dialogs
const List<String> _requestTypeOptions = [
'Install',
'Repair',
'Upgrade',
'Replace',
'Other',
];
const List<String> _requestCategoryOptions = [
'Software',
'Hardware',
'Network',
];
class TasksListScreen extends ConsumerStatefulWidget {
const TasksListScreen({super.key});
@@ -402,6 +417,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
final titleController = TextEditingController();
final descriptionController = TextEditingController();
String? selectedOfficeId;
String? selectedRequestType;
String? requestTypeOther;
String? selectedRequestCategory;
await showDialog<void>(
context: context,
@@ -438,21 +456,71 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
return const Text('No offices available.');
}
selectedOfficeId ??= offices.first.id;
return DropdownButtonFormField<String>(
initialValue: selectedOfficeId,
decoration: const InputDecoration(
labelText: 'Office',
),
items: offices
.map(
(office) => DropdownMenuItem(
value: office.id,
child: Text(office.name),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<String>(
initialValue: selectedOfficeId,
decoration: const InputDecoration(
labelText: 'Office',
),
items: offices
.map(
(office) => DropdownMenuItem(
value: office.id,
child: Text(office.name),
),
)
.toList(),
onChanged: (value) =>
setState(() => selectedOfficeId = value),
),
const SizedBox(height: 12),
// optional request metadata inputs
DropdownButtonFormField<String>(
initialValue: selectedRequestType,
decoration: const InputDecoration(
labelText: 'Request type (optional)',
),
items: _requestTypeOptions
.map(
(t) => DropdownMenuItem(
value: t,
child: Text(t),
),
)
.toList(),
onChanged: (value) =>
setState(() => selectedRequestType = value),
),
if (selectedRequestType == 'Other') ...[
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Please specify',
),
)
.toList(),
onChanged: (value) =>
setState(() => selectedOfficeId = value),
onChanged: (v) => requestTypeOther = v,
),
],
const SizedBox(height: 12),
DropdownButtonFormField<String>(
initialValue: selectedRequestCategory,
decoration: const InputDecoration(
labelText: 'Request category (optional)',
),
items: _requestCategoryOptions
.map(
(t) => DropdownMenuItem(
value: t,
child: Text(t),
),
)
.toList(),
onChanged: (value) => setState(
() => selectedRequestCategory = value,
),
),
],
);
},
loading: () => const Align(
@@ -484,6 +552,9 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
title: title,
description: description,
officeId: officeId,
requestType: selectedRequestType,
requestTypeOther: requestTypeOther,
requestCategory: selectedRequestCategory,
);
if (context.mounted) {
Navigator.of(dialogContext).pop();
+11 -8
View File
@@ -477,7 +477,7 @@ class _ScheduleTile extends ConsumerWidget {
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
value: selectedId,
initialValue: selectedId,
items: [
for (final profile in staff)
DropdownMenuItem(
@@ -515,7 +515,7 @@ class _ScheduleTile extends ConsumerWidget {
),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: selectedTargetShiftId,
initialValue: selectedTargetShiftId,
items: [
for (final s in recipientShifts)
DropdownMenuItem(
@@ -553,11 +553,14 @@ class _ScheduleTile extends ConsumerWidget {
},
);
if (!context.mounted) return;
if (!context.mounted) {
return;
}
if (confirmed != true ||
selectedId == null ||
selectedTargetShiftId == null)
selectedTargetShiftId == null) {
return;
}
try {
await ref
@@ -1976,7 +1979,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
return;
}
Profile? _choice = eligible.first;
Profile? choice = eligible.first;
final selected = await showDialog<Profile?>(
context: context,
builder: (context) {
@@ -1984,13 +1987,13 @@ class _SwapRequestsPanel extends ConsumerWidget {
title: const Text('Change recipient'),
content: StatefulBuilder(
builder: (context, setState) => DropdownButtonFormField<Profile>(
value: _choice,
initialValue: choice,
items: eligible
.map(
(p) => DropdownMenuItem(value: p, child: Text(p.fullName)),
)
.toList(),
onChanged: (v) => setState(() => _choice = v),
onChanged: (v) => setState(() => choice = v),
),
),
actions: [
@@ -1999,7 +2002,7 @@ class _SwapRequestsPanel extends ConsumerWidget {
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_choice),
onPressed: () => Navigator.of(context).pop(choice),
child: const Text('Save'),
),
],
+5 -2
View File
@@ -14,7 +14,8 @@ String? extractSupabaseError(dynamic res) {
return err is Map ? (err['message'] ?? err.toString()) : err.toString();
}
if (res['status'] != null && res['status'] is int && res['status'] >= 400) {
return res['message']?.toString() ?? 'Request failed with status ${res['status']}';
return res['message']?.toString() ??
'Request failed with status ${res['status']}';
}
return null;
}
@@ -22,7 +23,9 @@ String? extractSupabaseError(dynamic res) {
// Try PostgrestResponse-like fields via dynamic access (safe within try/catch).
try {
final err = (res as dynamic).error;
if (err != null) return err is Map ? (err['message'] ?? err.toString()) : err.toString();
if (err != null) {
return err is Map ? (err['message'] ?? err.toString()) : err.toString();
}
} catch (_) {}
try {
final status = (res as dynamic).status;