1052 lines
40 KiB
Dart
1052 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:tasq/utils/app_time.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../models/notification_item.dart';
|
|
import '../../models/office.dart';
|
|
import '../../models/profile.dart';
|
|
import '../../models/task.dart';
|
|
import '../../models/task_assignment.dart';
|
|
import '../../models/ticket.dart';
|
|
import '../../providers/notifications_provider.dart';
|
|
import '../../providers/profile_provider.dart';
|
|
import '../../providers/tasks_provider.dart';
|
|
import '../../providers/tickets_provider.dart';
|
|
import '../../providers/realtime_controller.dart';
|
|
import '../../providers/typing_provider.dart';
|
|
import '../../widgets/mono_text.dart';
|
|
import '../../widgets/reconnect_overlay.dart';
|
|
import 'package:skeletonizer/skeletonizer.dart';
|
|
import '../../widgets/responsive_body.dart';
|
|
import '../../widgets/tasq_adaptive_list.dart';
|
|
import '../../widgets/typing_dots.dart';
|
|
import '../../theme/app_surfaces.dart';
|
|
import '../../utils/snackbar.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});
|
|
|
|
@override
|
|
ConsumerState<TasksListScreen> createState() => _TasksListScreenState();
|
|
}
|
|
|
|
class _TasksListScreenState extends ConsumerState<TasksListScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
final TextEditingController _subjectController = TextEditingController();
|
|
final TextEditingController _taskNumberController = TextEditingController();
|
|
String? _selectedOfficeId;
|
|
String? _selectedStatus;
|
|
String? _selectedAssigneeId;
|
|
DateTimeRange? _selectedDateRange;
|
|
late final TabController _tabController;
|
|
bool _isSwitchingTab = false;
|
|
|
|
@override
|
|
void dispose() {
|
|
_subjectController.dispose();
|
|
_taskNumberController.dispose();
|
|
_tabController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_tabController = TabController(length: 2, vsync: this);
|
|
_tabController.addListener(() {
|
|
// briefly show a skeleton when switching tabs so the UI can
|
|
// navigate ahead and avoid a janky synchronous rebuild.
|
|
if (!_isSwitchingTab) {
|
|
setState(() => _isSwitchingTab = true);
|
|
Future.delayed(const Duration(milliseconds: 150), () {
|
|
if (!mounted) return;
|
|
setState(() => _isSwitchingTab = false);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
bool get _hasTaskFilters {
|
|
return _subjectController.text.trim().isNotEmpty ||
|
|
_taskNumberController.text.trim().isNotEmpty ||
|
|
_selectedOfficeId != null ||
|
|
_selectedStatus != null ||
|
|
(_tabController.index == 1 && _selectedAssigneeId != null) ||
|
|
_selectedDateRange != null;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tasksAsync = ref.watch(tasksProvider);
|
|
ref.watch(tasksQueryProvider);
|
|
final ticketsAsync = ref.watch(ticketsProvider);
|
|
final officesAsync = ref.watch(officesProvider);
|
|
final profileAsync = ref.watch(currentProfileProvider);
|
|
final notificationsAsync = ref.watch(notificationsProvider);
|
|
final profilesAsync = ref.watch(profilesProvider);
|
|
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
|
|
final realtime = ref.watch(realtimeControllerProvider);
|
|
|
|
final showSkeleton =
|
|
realtime.isAnyStreamRecovering ||
|
|
tasksAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
ticketsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
officesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
profilesAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
assignmentsAsync.maybeWhen(loading: () => true, orElse: () => false) ||
|
|
profileAsync.maybeWhen(loading: () => true, orElse: () => false);
|
|
final effectiveShowSkeleton = showSkeleton || _isSwitchingTab;
|
|
|
|
final canCreate = profileAsync.maybeWhen(
|
|
data: (profile) =>
|
|
profile != null &&
|
|
(profile.role == 'admin' ||
|
|
profile.role == 'dispatcher' ||
|
|
profile.role == 'it_staff'),
|
|
orElse: () => false,
|
|
);
|
|
|
|
final ticketById = <String, Ticket>{
|
|
for (final ticket in ticketsAsync.valueOrNull ?? <Ticket>[])
|
|
ticket.id: ticket,
|
|
};
|
|
final officeById = <String, Office>{
|
|
for (final office in officesAsync.valueOrNull ?? <Office>[])
|
|
office.id: office,
|
|
};
|
|
final profileById = <String, Profile>{
|
|
for (final profile in profilesAsync.valueOrNull ?? <Profile>[])
|
|
profile.id: profile,
|
|
};
|
|
|
|
return Stack(
|
|
children: [
|
|
ResponsiveBody(
|
|
maxWidth: double.infinity,
|
|
child: Skeletonizer(
|
|
enabled: effectiveShowSkeleton,
|
|
child: tasksAsync.when(
|
|
data: (tasks) {
|
|
if (tasks.isEmpty) {
|
|
return const Center(child: Text('No tasks yet.'));
|
|
}
|
|
final offices = officesAsync.valueOrNull ?? <Office>[];
|
|
final officesSorted = List<Office>.from(offices)
|
|
..sort(
|
|
(a, b) =>
|
|
a.name.toLowerCase().compareTo(b.name.toLowerCase()),
|
|
);
|
|
final officeOptions = <DropdownMenuItem<String?>>[
|
|
const DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text('All offices'),
|
|
),
|
|
...officesSorted.map(
|
|
(office) => DropdownMenuItem<String?>(
|
|
value: office.id,
|
|
child: Text(office.name),
|
|
),
|
|
),
|
|
];
|
|
final staffOptions = _staffOptions(profilesAsync.valueOrNull);
|
|
final statusOptions = _taskStatusOptions(tasks);
|
|
|
|
// derive latest assignee per task from task assignments stream
|
|
final assignments =
|
|
assignmentsAsync.valueOrNull ?? <TaskAssignment>[];
|
|
final assignmentsByTask = <String, TaskAssignment>{};
|
|
for (final a in assignments) {
|
|
final current = assignmentsByTask[a.taskId];
|
|
if (current == null ||
|
|
a.createdAt.isAfter(current.createdAt)) {
|
|
assignmentsByTask[a.taskId] = a;
|
|
}
|
|
}
|
|
final latestAssigneeByTaskId = <String, String?>{};
|
|
for (final entry in assignmentsByTask.entries) {
|
|
latestAssigneeByTaskId[entry.key] = entry.value.userId;
|
|
}
|
|
|
|
// Track ALL assigned users per task (not just latest) for "My Tasks" filtering
|
|
final assignedUsersByTaskId = <String, Set<String>>{};
|
|
for (final a in assignments) {
|
|
assignedUsersByTaskId
|
|
.putIfAbsent(a.taskId, () => <String>{})
|
|
.add(a.userId);
|
|
}
|
|
|
|
final filteredTasks = _applyTaskFilters(
|
|
tasks,
|
|
ticketById: ticketById,
|
|
subjectQuery: _subjectController.text,
|
|
taskNumber: _taskNumberController.text,
|
|
officeId: _selectedOfficeId,
|
|
status: _selectedStatus,
|
|
assigneeId: _selectedAssigneeId,
|
|
dateRange: _selectedDateRange,
|
|
latestAssigneeByTaskId: latestAssigneeByTaskId,
|
|
);
|
|
|
|
final filterHeader = Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
crossAxisAlignment: WrapCrossAlignment.center,
|
|
children: [
|
|
SizedBox(
|
|
width: 220,
|
|
child: TextField(
|
|
controller: _subjectController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Subject',
|
|
prefixIcon: Icon(Icons.search),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 200,
|
|
child: DropdownButtonFormField<String?>(
|
|
isExpanded: true,
|
|
key: ValueKey(_selectedOfficeId),
|
|
initialValue: _selectedOfficeId,
|
|
items: officeOptions,
|
|
onChanged: (value) =>
|
|
setState(() => _selectedOfficeId = value),
|
|
decoration: const InputDecoration(labelText: 'Office'),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 160,
|
|
child: TextField(
|
|
controller: _taskNumberController,
|
|
onChanged: (_) => setState(() {}),
|
|
decoration: const InputDecoration(
|
|
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(
|
|
width: 180,
|
|
child: DropdownButtonFormField<String?>(
|
|
isExpanded: true,
|
|
key: ValueKey(_selectedStatus),
|
|
initialValue: _selectedStatus,
|
|
items: statusOptions,
|
|
onChanged: (value) =>
|
|
setState(() => _selectedStatus = value),
|
|
decoration: const InputDecoration(labelText: 'Status'),
|
|
),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () async {
|
|
final next = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2020),
|
|
lastDate: AppTime.now().add(
|
|
const Duration(days: 365),
|
|
),
|
|
currentDate: AppTime.now(),
|
|
initialDateRange: _selectedDateRange,
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _selectedDateRange = next);
|
|
},
|
|
icon: const Icon(Icons.date_range),
|
|
label: Text(
|
|
_selectedDateRange == null
|
|
? 'Date range'
|
|
: AppTime.formatDateRange(_selectedDateRange!),
|
|
),
|
|
),
|
|
if (_hasTaskFilters)
|
|
TextButton.icon(
|
|
onPressed: () => setState(() {
|
|
_subjectController.clear();
|
|
_selectedOfficeId = null;
|
|
_selectedStatus = null;
|
|
_selectedAssigneeId = null;
|
|
_selectedDateRange = null;
|
|
}),
|
|
icon: const Icon(Icons.close),
|
|
label: const Text('Clear'),
|
|
),
|
|
],
|
|
);
|
|
|
|
// reusable helper for rendering a list given a subset of tasks
|
|
Widget makeList(List<Task> tasksList) {
|
|
final summary = _StatusSummaryRow(
|
|
counts: _taskStatusCounts(tasksList),
|
|
);
|
|
return TasQAdaptiveList<Task>(
|
|
items: tasksList,
|
|
onRowTap: (task) => context.go('/tasks/${task.id}'),
|
|
summaryDashboard: summary,
|
|
filterHeader: filterHeader,
|
|
skeletonMode: effectiveShowSkeleton,
|
|
onRequestRefresh: () {
|
|
// For server-side pagination, update the query provider
|
|
ref.read(tasksQueryProvider.notifier).state =
|
|
const TaskQuery(offset: 0, limit: 50);
|
|
},
|
|
onPageChanged: null,
|
|
isLoading: false,
|
|
columns: [
|
|
TasQColumn<Task>(
|
|
header: 'Task #',
|
|
technical: true,
|
|
cellBuilder: (context, task) =>
|
|
Text(task.taskNumber ?? task.id),
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Subject',
|
|
cellBuilder: (context, task) {
|
|
final ticket = task.ticketId == null
|
|
? null
|
|
: ticketById[task.ticketId];
|
|
return Text(
|
|
task.title.isNotEmpty
|
|
? task.title
|
|
: (ticket?.subject ?? 'Task ${task.id}'),
|
|
);
|
|
},
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Office',
|
|
cellBuilder: (context, task) {
|
|
final ticket = task.ticketId == null
|
|
? null
|
|
: ticketById[task.ticketId];
|
|
final officeId = ticket?.officeId ?? task.officeId;
|
|
return Text(
|
|
officeId == null
|
|
? 'Unassigned office'
|
|
: (officeById[officeId]?.name ?? officeId),
|
|
);
|
|
},
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Assigned Agent',
|
|
cellBuilder: (context, task) {
|
|
final assigneeId = latestAssigneeByTaskId[task.id];
|
|
return Text(_assignedAgent(profileById, assigneeId));
|
|
},
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Status',
|
|
cellBuilder: (context, task) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_StatusBadge(status: task.status),
|
|
if (task.status == 'completed' &&
|
|
task.hasIncompleteDetails) ...[
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 16,
|
|
color: Colors.orange,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
TasQColumn<Task>(
|
|
header: 'Timestamp',
|
|
technical: true,
|
|
cellBuilder: (context, task) =>
|
|
Text(_formatTimestamp(task.createdAt)),
|
|
),
|
|
],
|
|
mobileTileBuilder: (context, task, actions) {
|
|
final ticketId = task.ticketId;
|
|
final ticket = ticketId == null
|
|
? null
|
|
: ticketById[ticketId];
|
|
final officeId = ticket?.officeId ?? task.officeId;
|
|
final officeName = officeId == null
|
|
? 'Unassigned office'
|
|
: (officeById[officeId]?.name ?? officeId);
|
|
final assigned = _assignedAgent(
|
|
profileById,
|
|
latestAssigneeByTaskId[task.id],
|
|
);
|
|
final subtitle = _buildSubtitle(officeName, task.status);
|
|
final hasMention = _hasTaskMention(
|
|
notificationsAsync,
|
|
task,
|
|
);
|
|
final typingState = ref.watch(
|
|
typingIndicatorProvider(task.id),
|
|
);
|
|
final showTyping = typingState.userIds.isNotEmpty;
|
|
|
|
return Card(
|
|
child: ListTile(
|
|
leading: _buildQueueBadge(context, task),
|
|
dense: true,
|
|
visualDensity: VisualDensity.compact,
|
|
title: Text(
|
|
task.title.isNotEmpty
|
|
? task.title
|
|
: (ticket?.subject ??
|
|
'Task ${task.taskNumber ?? task.id}'),
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(subtitle),
|
|
const SizedBox(height: 2),
|
|
Text('Assigned: $assigned'),
|
|
const SizedBox(height: 4),
|
|
MonoText('ID ${task.taskNumber ?? task.id}'),
|
|
const SizedBox(height: 2),
|
|
Text(_formatTimestamp(task.createdAt)),
|
|
],
|
|
),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_StatusBadge(status: task.status),
|
|
if (task.status == 'completed' &&
|
|
task.hasIncompleteDetails) ...[
|
|
const SizedBox(width: 4),
|
|
const Icon(
|
|
Icons.warning_amber_rounded,
|
|
size: 16,
|
|
color: Colors.orange,
|
|
),
|
|
],
|
|
if (showTyping) ...[
|
|
const SizedBox(width: 6),
|
|
TypingDots(
|
|
size: 6,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
],
|
|
if (hasMention)
|
|
const Padding(
|
|
padding: EdgeInsets.only(left: 8),
|
|
child: Icon(
|
|
Icons.circle,
|
|
size: 10,
|
|
color: Colors.red,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: () => context.go('/tasks/${task.id}'),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
final currentUserId = profileAsync.valueOrNull?.id;
|
|
final myTasks = currentUserId == null
|
|
? <Task>[]
|
|
: filteredTasks
|
|
.where(
|
|
(t) =>
|
|
assignedUsersByTaskId[t.id]?.contains(
|
|
currentUserId,
|
|
) ??
|
|
false,
|
|
)
|
|
.toList();
|
|
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.max,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 16, bottom: 8),
|
|
child: Align(
|
|
alignment: Alignment.center,
|
|
child: Text(
|
|
'Tasks',
|
|
textAlign: TextAlign.center,
|
|
style: Theme.of(context).textTheme.titleLarge
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
TabBar(
|
|
controller: _tabController,
|
|
tabs: const [
|
|
Tab(text: 'My Tasks'),
|
|
Tab(text: 'All Tasks'),
|
|
],
|
|
),
|
|
Expanded(
|
|
child: TabBarView(
|
|
controller: _tabController,
|
|
children: [
|
|
makeList(myTasks),
|
|
makeList(filteredTasks),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
loading: () => const SizedBox.shrink(),
|
|
error: (error, _) =>
|
|
Center(child: Text('Failed to load tasks: $error')),
|
|
),
|
|
),
|
|
),
|
|
if (canCreate)
|
|
Positioned(
|
|
right: 16,
|
|
bottom: 16,
|
|
child: SafeArea(
|
|
child: FloatingActionButton.extended(
|
|
onPressed: () => _showCreateTaskDialog(context, ref),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('New Task'),
|
|
),
|
|
),
|
|
),
|
|
const ReconnectIndicator(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Future<void> _showCreateTaskDialog(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
) async {
|
|
final titleController = TextEditingController();
|
|
final descriptionController = TextEditingController();
|
|
String? selectedOfficeId;
|
|
String? selectedRequestType;
|
|
String? requestTypeOther;
|
|
String? selectedRequestCategory;
|
|
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
bool saving = false;
|
|
final officesAsync = ref.watch(officesProvider);
|
|
return StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
shape: AppSurfaces.of(context).dialogShape,
|
|
title: const Text('Create Task'),
|
|
content: SizedBox(
|
|
width: 360,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: titleController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Task title',
|
|
),
|
|
enabled: !saving,
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
),
|
|
maxLines: 3,
|
|
enabled: !saving,
|
|
),
|
|
const SizedBox(height: 12),
|
|
officesAsync.when(
|
|
data: (offices) {
|
|
if (offices.isEmpty) {
|
|
return const Text('No offices available.');
|
|
}
|
|
final officesSorted = List<Office>.from(offices)
|
|
..sort(
|
|
(a, b) => a.name.toLowerCase().compareTo(
|
|
b.name.toLowerCase(),
|
|
),
|
|
);
|
|
selectedOfficeId ??= officesSorted.first.id;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
initialValue: selectedOfficeId,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Office',
|
|
),
|
|
items: officesSorted
|
|
.map(
|
|
(office) => DropdownMenuItem(
|
|
value: office.id,
|
|
child: Text(office.name),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: saving
|
|
? null
|
|
: (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: saving
|
|
? null
|
|
: (value) => setState(
|
|
() => selectedRequestType = value,
|
|
),
|
|
),
|
|
if (selectedRequestType == 'Other') ...[
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Please specify',
|
|
),
|
|
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: saving
|
|
? null
|
|
: (value) => setState(
|
|
() => selectedRequestCategory = value,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
loading: () => const Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
error: (error, _) =>
|
|
Text('Failed to load offices: $error'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: saving
|
|
? null
|
|
: () => Navigator.of(dialogContext).pop(),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: saving
|
|
? null
|
|
: () async {
|
|
final title = titleController.text.trim();
|
|
final description = descriptionController.text.trim();
|
|
final officeId = selectedOfficeId;
|
|
if (title.isEmpty || officeId == null) {
|
|
return;
|
|
}
|
|
setState(() => saving = true);
|
|
await ref
|
|
.read(tasksControllerProvider)
|
|
.createTask(
|
|
title: title,
|
|
description: description,
|
|
officeId: officeId,
|
|
requestType: selectedRequestType,
|
|
requestTypeOther: requestTypeOther,
|
|
requestCategory: selectedRequestCategory,
|
|
);
|
|
if (context.mounted) {
|
|
Navigator.of(dialogContext).pop();
|
|
showSuccessSnackBar(
|
|
context,
|
|
'Task "$title" has been created successfully.',
|
|
);
|
|
}
|
|
},
|
|
child: saving
|
|
? const SizedBox(
|
|
height: 18,
|
|
width: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Create'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
bool _hasTaskMention(
|
|
AsyncValue<List<NotificationItem>> notificationsAsync,
|
|
Task task,
|
|
) {
|
|
return notificationsAsync.maybeWhen(
|
|
data: (items) => items.any(
|
|
(item) =>
|
|
item.isUnread &&
|
|
(item.taskId == task.id || item.ticketId == task.ticketId),
|
|
),
|
|
orElse: () => false,
|
|
);
|
|
}
|
|
|
|
Widget _buildQueueBadge(BuildContext context, Task task) {
|
|
final queueOrder = task.queueOrder;
|
|
final isQueued = task.status == 'queued';
|
|
if (!isQueued || queueOrder == null) {
|
|
return const Icon(Icons.fact_check_outlined);
|
|
}
|
|
return Container(
|
|
width: 40,
|
|
height: 40,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(
|
|
AppSurfaces.of(context).compactCardRadius,
|
|
),
|
|
),
|
|
child: Text(
|
|
'#$queueOrder',
|
|
style: Theme.of(context).textTheme.labelMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _buildSubtitle(String officeName, String status) {
|
|
final statusLabel = status.toUpperCase();
|
|
return '$officeName · $statusLabel';
|
|
}
|
|
}
|
|
|
|
List<DropdownMenuItem<String?>> _staffOptions(List<Profile>? profiles) {
|
|
final items = profiles ?? const <Profile>[];
|
|
final sorted = [...items]
|
|
..sort((a, b) => _profileLabel(a).compareTo(_profileLabel(b)));
|
|
return [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('All staff')),
|
|
...sorted.map(
|
|
(profile) => DropdownMenuItem<String?>(
|
|
value: profile.id,
|
|
child: Text(_profileLabel(profile)),
|
|
),
|
|
),
|
|
];
|
|
}
|
|
|
|
String _profileLabel(Profile profile) {
|
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
|
}
|
|
|
|
List<DropdownMenuItem<String?>> _taskStatusOptions(List<Task> tasks) {
|
|
final statuses = tasks.map((task) => task.status).toSet().toList()..sort();
|
|
return [
|
|
const DropdownMenuItem<String?>(value: null, child: Text('All statuses')),
|
|
...statuses.map(
|
|
(status) => DropdownMenuItem<String?>(value: status, child: Text(status)),
|
|
),
|
|
];
|
|
}
|
|
|
|
List<Task> _applyTaskFilters(
|
|
List<Task> tasks, {
|
|
required Map<String, Ticket> ticketById,
|
|
required String subjectQuery,
|
|
required String taskNumber,
|
|
required String? officeId,
|
|
required String? status,
|
|
required String? assigneeId,
|
|
required DateTimeRange? dateRange,
|
|
required Map<String, String?> 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
|
|
: (ticket?.subject ?? 'Task ${task.id}');
|
|
if (query.isNotEmpty &&
|
|
!subject.toLowerCase().contains(query) &&
|
|
!(task.taskNumber?.toLowerCase().contains(query) ?? false) &&
|
|
!task.id.toLowerCase().contains(query)) {
|
|
return false;
|
|
}
|
|
final resolvedOfficeId = ticket?.officeId ?? task.officeId;
|
|
if (officeId != null && resolvedOfficeId != officeId) {
|
|
return false;
|
|
}
|
|
if (status != null && task.status != status) {
|
|
return false;
|
|
}
|
|
final currentAssignee = latestAssigneeByTaskId[task.id];
|
|
if (assigneeId != null && currentAssignee != assigneeId) {
|
|
return false;
|
|
}
|
|
if (dateRange != null) {
|
|
final start = DateTime(
|
|
dateRange.start.year,
|
|
dateRange.start.month,
|
|
dateRange.start.day,
|
|
);
|
|
final end = DateTime(
|
|
dateRange.end.year,
|
|
dateRange.end.month,
|
|
dateRange.end.day,
|
|
23,
|
|
59,
|
|
59,
|
|
);
|
|
if (task.createdAt.isBefore(start) || task.createdAt.isAfter(end)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}).toList();
|
|
}
|
|
|
|
Map<String, int> _taskStatusCounts(List<Task> tasks) {
|
|
final counts = <String, int>{};
|
|
for (final task in tasks) {
|
|
// Do not include cancelled tasks in dashboard summaries
|
|
if (task.status == 'cancelled') continue;
|
|
counts.update(task.status, (value) => value + 1, ifAbsent: () => 1);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
class _StatusSummaryRow extends StatelessWidget {
|
|
const _StatusSummaryRow({required this.counts});
|
|
|
|
final Map<String, int> counts;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (counts.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final entries = counts.entries.toList()
|
|
..sort((a, b) => a.key.compareTo(b.key));
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth = constraints.maxWidth;
|
|
final maxPerRow = maxWidth >= 1000
|
|
? 4
|
|
: maxWidth >= 720
|
|
? 3
|
|
: maxWidth >= 480
|
|
? 2
|
|
: entries.length;
|
|
final perRow = entries.length < maxPerRow ? entries.length : maxPerRow;
|
|
final spacing = maxWidth < 480 ? 8.0 : 12.0;
|
|
final itemWidth = perRow == 0
|
|
? maxWidth
|
|
: (maxWidth - spacing * (perRow - 1)) / perRow;
|
|
|
|
return Wrap(
|
|
spacing: spacing,
|
|
runSpacing: spacing,
|
|
children: [
|
|
for (final entry in entries)
|
|
SizedBox(
|
|
width: itemWidth,
|
|
child: _StatusSummaryCard(
|
|
status: entry.key,
|
|
count: entry.value,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatusSummaryCard extends StatelessWidget {
|
|
const _StatusSummaryCard({required this.status, required this.count});
|
|
|
|
final String status;
|
|
final int count;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final background = switch (status) {
|
|
'critical' => scheme.errorContainer,
|
|
'queued' => scheme.surfaceContainerHighest,
|
|
'in_progress' => scheme.secondaryContainer,
|
|
'completed' => scheme.primaryContainer,
|
|
_ => scheme.surfaceContainerHigh,
|
|
};
|
|
final foreground = switch (status) {
|
|
'critical' => scheme.onErrorContainer,
|
|
'queued' => scheme.onSurfaceVariant,
|
|
'in_progress' => scheme.onSecondaryContainer,
|
|
'completed' => scheme.onPrimaryContainer,
|
|
_ => scheme.onSurfaceVariant,
|
|
};
|
|
|
|
return Card(
|
|
color: background,
|
|
// summary cards are compact — use compact token for consistent density
|
|
shape: AppSurfaces.of(context).compactShape,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
status.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.4,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
count.toString(),
|
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _assignedAgent(Map<String, Profile> profileById, String? userId) {
|
|
if (userId == null || userId.isEmpty) {
|
|
return 'Unassigned';
|
|
}
|
|
final profile = profileById[userId];
|
|
if (profile == null) {
|
|
return userId;
|
|
}
|
|
return profile.fullName.isNotEmpty ? profile.fullName : profile.id;
|
|
}
|
|
|
|
String _formatTimestamp(DateTime value) {
|
|
final year = value.year.toString().padLeft(4, '0');
|
|
final month = value.month.toString().padLeft(2, '0');
|
|
final day = value.day.toString().padLeft(2, '0');
|
|
final hour = value.hour.toString().padLeft(2, '0');
|
|
final minute = value.minute.toString().padLeft(2, '0');
|
|
return '$year-$month-$day $hour:$minute';
|
|
}
|
|
|
|
class _StatusBadge extends StatelessWidget {
|
|
const _StatusBadge({required this.status});
|
|
|
|
final String status;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final background = switch (status) {
|
|
'critical' => scheme.errorContainer,
|
|
'queued' => scheme.surfaceContainerHighest,
|
|
'in_progress' => scheme.secondaryContainer,
|
|
'completed' => scheme.primaryContainer,
|
|
_ => scheme.surfaceContainerHighest,
|
|
};
|
|
final foreground = switch (status) {
|
|
'critical' => scheme.onErrorContainer,
|
|
'queued' => scheme.onSurfaceVariant,
|
|
'in_progress' => scheme.onSecondaryContainer,
|
|
'completed' => scheme.onPrimaryContainer,
|
|
_ => scheme.onSurfaceVariant,
|
|
};
|
|
|
|
return Badge(
|
|
backgroundColor: background,
|
|
label: Text(
|
|
status.toUpperCase(),
|
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
|
color: foreground,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: 0.3,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|