tasq/lib/screens/dashboard/dashboard_screen.dart

710 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../models/profile.dart';
import '../../models/task.dart';
import '../../models/task_assignment.dart';
import '../../models/ticket.dart';
import '../../models/ticket_message.dart';
import '../../providers/profile_provider.dart';
import '../../providers/tasks_provider.dart';
import '../../providers/tickets_provider.dart';
import '../../widgets/responsive_body.dart';
import '../../theme/app_surfaces.dart';
import '../../widgets/mono_text.dart';
import '../../widgets/status_pill.dart';
import '../../utils/app_time.dart';
class DashboardMetrics {
DashboardMetrics({
required this.newTicketsToday,
required this.closedToday,
required this.openTickets,
required this.avgResponse,
required this.avgTriage,
required this.longestResponse,
required this.tasksCreatedToday,
required this.tasksCompletedToday,
required this.openTasks,
required this.staffRows,
});
final int newTicketsToday;
final int closedToday;
final int openTickets;
final Duration? avgResponse;
final Duration? avgTriage;
final Duration? longestResponse;
final int tasksCreatedToday;
final int tasksCompletedToday;
final int openTasks;
final List<StaffRowMetrics> staffRows;
}
class StaffRowMetrics {
StaffRowMetrics({
required this.userId,
required this.name,
required this.status,
required this.ticketsRespondedToday,
required this.tasksClosedToday,
});
final String userId;
final String name;
final String status;
final int ticketsRespondedToday;
final int tasksClosedToday;
}
final dashboardMetricsProvider = Provider<AsyncValue<DashboardMetrics>>((ref) {
final ticketsAsync = ref.watch(ticketsProvider);
final tasksAsync = ref.watch(tasksProvider);
final profilesAsync = ref.watch(profilesProvider);
final assignmentsAsync = ref.watch(taskAssignmentsProvider);
final messagesAsync = ref.watch(ticketMessagesAllProvider);
final asyncValues = [
ticketsAsync,
tasksAsync,
profilesAsync,
assignmentsAsync,
messagesAsync,
];
// Debug: log dependency loading/error states to diagnose full-page refreshes.
debugPrint(
'[dashboardMetricsProvider] recompute: '
'tickets=${ticketsAsync.isLoading ? "loading" : "ready"} '
'tasks=${tasksAsync.isLoading ? "loading" : "ready"} '
'profiles=${profilesAsync.isLoading ? "loading" : "ready"} '
'assignments=${assignmentsAsync.isLoading ? "loading" : "ready"} '
'messages=${messagesAsync.isLoading ? "loading" : "ready"}',
);
if (asyncValues.any((value) => value.hasError)) {
final errorValue = asyncValues.firstWhere((value) => value.hasError);
final error = errorValue.error ?? 'Failed to load dashboard';
final stack = errorValue.stackTrace ?? StackTrace.current;
return AsyncError(error, stack);
}
// Avoid returning a loading state for the whole dashboard when *one*
// dependency is temporarily loading (this caused the top linear
// progress banner to appear during ticket→task promotion / task
// completion). Only treat the dashboard as loading when *none* of the
// dependencies have any data yet (initial load).
final anyHasData = asyncValues.any((v) => v.valueOrNull != null);
if (!anyHasData) {
debugPrint(
'[dashboardMetricsProvider] returning AsyncLoading (no dep data)',
);
return const AsyncLoading();
}
final tickets = ticketsAsync.valueOrNull ?? const <Ticket>[];
final tasks = tasksAsync.valueOrNull ?? const <Task>[];
final profiles = profilesAsync.valueOrNull ?? const <Profile>[];
final assignments = assignmentsAsync.valueOrNull ?? const <TaskAssignment>[];
final messages = messagesAsync.valueOrNull ?? const <TicketMessage>[];
final now = AppTime.now();
final startOfDay = DateTime(now.year, now.month, now.day);
final staffProfiles = profiles
.where((profile) => profile.role == 'it_staff')
.toList();
final staffIds = profiles
.where(
(profile) =>
profile.role == 'admin' ||
profile.role == 'dispatcher' ||
profile.role == 'it_staff',
)
.map((profile) => profile.id)
.toSet();
bool isToday(DateTime value) => !value.isBefore(startOfDay);
final firstStaffMessageByTicket = <String, DateTime>{};
final lastStaffMessageByUser = <String, DateTime>{};
final respondedTicketsByUser = <String, Set<String>>{};
for (final message in messages) {
final ticketId = message.ticketId;
final senderId = message.senderId;
if (ticketId != null && senderId != null && staffIds.contains(senderId)) {
final current = firstStaffMessageByTicket[ticketId];
if (current == null || message.createdAt.isBefore(current)) {
firstStaffMessageByTicket[ticketId] = message.createdAt;
}
final last = lastStaffMessageByUser[senderId];
if (last == null || message.createdAt.isAfter(last)) {
lastStaffMessageByUser[senderId] = message.createdAt;
}
if (isToday(message.createdAt)) {
respondedTicketsByUser
.putIfAbsent(senderId, () => <String>{})
.add(ticketId);
}
}
}
DateTime? respondedAtForTicket(Ticket ticket) {
final staffMessageAt = firstStaffMessageByTicket[ticket.id];
if (staffMessageAt != null) {
return staffMessageAt;
}
if (ticket.promotedAt != null) {
return ticket.promotedAt;
}
return null;
}
Duration? responseDuration(Ticket ticket) {
final respondedAt = respondedAtForTicket(ticket);
if (respondedAt == null) {
return null;
}
final duration = respondedAt.difference(ticket.createdAt);
return duration.isNegative ? Duration.zero : duration;
}
Duration? triageDuration(Ticket ticket) {
final respondedAt = respondedAtForTicket(ticket);
if (respondedAt == null) {
return null;
}
final triageEnd = _earliestDate(ticket.promotedAt, ticket.closedAt);
if (triageEnd == null) {
return null;
}
final duration = triageEnd.difference(respondedAt);
return duration.isNegative ? Duration.zero : duration;
}
final ticketsToday = tickets.where((ticket) => isToday(ticket.createdAt));
final closedToday = tickets.where(
(ticket) => ticket.closedAt != null && isToday(ticket.closedAt!),
);
final openTickets = tickets.where((ticket) => ticket.status != 'closed');
final responseDurationsToday = ticketsToday
.map(responseDuration)
.whereType<Duration>()
.toList();
final triageDurationsToday = ticketsToday
.map(triageDuration)
.whereType<Duration>()
.toList();
final avgResponse = _averageDuration(responseDurationsToday);
final avgTriage = _averageDuration(triageDurationsToday);
final longestResponse = responseDurationsToday.isEmpty
? null
: responseDurationsToday.reduce(
(a, b) => a.inSeconds >= b.inSeconds ? a : b,
);
final tasksCreatedToday = tasks.where((task) => isToday(task.createdAt));
final tasksCompletedToday = tasks.where(
(task) => task.completedAt != null && isToday(task.completedAt!),
);
final openTasks = tasks.where((task) => task.status != 'completed');
final taskById = {for (final task in tasks) task.id: task};
final staffOnTask = <String>{};
for (final assignment in assignments) {
final task = taskById[assignment.taskId];
if (task == null) {
continue;
}
if (task.status == 'in_progress') {
staffOnTask.add(assignment.userId);
}
}
final tasksClosedByUser = <String, Set<String>>{};
for (final assignment in assignments) {
final task = taskById[assignment.taskId];
if (task == null || task.completedAt == null) {
continue;
}
if (!isToday(task.completedAt!)) {
continue;
}
tasksClosedByUser
.putIfAbsent(assignment.userId, () => <String>{})
.add(task.id);
}
const triageWindow = Duration(minutes: 1);
final triageCutoff = now.subtract(triageWindow);
var staffRows = staffProfiles.map((staff) {
final lastMessage = lastStaffMessageByUser[staff.id];
final ticketsResponded = respondedTicketsByUser[staff.id]?.length ?? 0;
final tasksClosed = tasksClosedByUser[staff.id]?.length ?? 0;
final onTask = staffOnTask.contains(staff.id);
final inTriage = lastMessage != null && lastMessage.isAfter(triageCutoff);
final status = onTask
? 'On task'
: inTriage
? 'In triage'
: 'Vacant';
return StaffRowMetrics(
userId: staff.id,
name: staff.fullName.isNotEmpty ? staff.fullName : staff.id,
status: status,
ticketsRespondedToday: ticketsResponded,
tasksClosedToday: tasksClosed,
);
}).toList();
// Order IT staff by combined activity (tickets responded today + tasks closed today)
// descending so most-active staff appear first. Use name as a stable tiebreaker.
staffRows.sort((a, b) {
final aCount = a.ticketsRespondedToday + a.tasksClosedToday;
final bCount = b.ticketsRespondedToday + b.tasksClosedToday;
if (bCount != aCount) return bCount.compareTo(aCount);
return a.name.compareTo(b.name);
});
return AsyncData(
DashboardMetrics(
newTicketsToday: ticketsToday.length,
closedToday: closedToday.length,
openTickets: openTickets.length,
avgResponse: avgResponse,
avgTriage: avgTriage,
longestResponse: longestResponse,
tasksCreatedToday: tasksCreatedToday.length,
tasksCompletedToday: tasksCompletedToday.length,
openTasks: openTasks.length,
staffRows: staffRows,
),
);
});
class DashboardScreen extends StatelessWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context) {
return ResponsiveBody(
child: LayoutBuilder(
builder: (context, constraints) {
final sections = <Widget>[
const SizedBox(height: 16),
_sectionTitle(context, 'IT Staff Pulse'),
const _StaffTable(),
const SizedBox(height: 20),
_sectionTitle(context, 'Core Daily KPIs'),
_cardGrid(context, [
_MetricCard(
title: 'New tickets today',
valueBuilder: (metrics) => metrics.newTicketsToday.toString(),
),
_MetricCard(
title: 'Closed today',
valueBuilder: (metrics) => metrics.closedToday.toString(),
),
_MetricCard(
title: 'Open tickets',
valueBuilder: (metrics) => metrics.openTickets.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'Task Flow'),
_cardGrid(context, [
_MetricCard(
title: 'Tasks created',
valueBuilder: (metrics) => metrics.tasksCreatedToday.toString(),
),
_MetricCard(
title: 'Tasks completed',
valueBuilder: (metrics) =>
metrics.tasksCompletedToday.toString(),
),
_MetricCard(
title: 'Open tasks',
valueBuilder: (metrics) => metrics.openTasks.toString(),
),
]),
const SizedBox(height: 20),
_sectionTitle(context, 'TAT / Response'),
_cardGrid(context, [
_MetricCard(
title: 'Avg response',
valueBuilder: (metrics) => _formatDuration(metrics.avgResponse),
),
_MetricCard(
title: 'Avg triage',
valueBuilder: (metrics) => _formatDuration(metrics.avgTriage),
),
_MetricCard(
title: 'Longest response',
valueBuilder: (metrics) =>
_formatDuration(metrics.longestResponse),
),
]),
];
final content = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 16, bottom: 8),
child: Align(
alignment: Alignment.center,
child: Text(
'Dashboard',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
),
const _DashboardStatusBanner(),
...sections,
],
);
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: 24),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: content,
),
),
);
},
),
);
}
Widget _sectionTitle(BuildContext context, String title) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
title,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
);
}
Widget _cardGrid(BuildContext context, List<Widget> cards) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
if (width < 520) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < cards.length; i++) ...[
cards[i],
if (i < cards.length - 1) const SizedBox(height: 12),
],
],
);
}
final spacing = 12.0;
final minCardWidth = 220.0;
final totalWidth =
cards.length * minCardWidth + spacing * (cards.length - 1);
final fits = totalWidth <= width;
final cardWidth = fits
? (width - spacing * (cards.length - 1)) / cards.length
: minCardWidth;
final row = Row(
children: [
for (var i = 0; i < cards.length; i++) ...[
SizedBox(width: cardWidth, child: cards[i]),
if (i < cards.length - 1) const SizedBox(width: 12),
],
],
);
if (fits) {
return row;
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SizedBox(width: totalWidth, child: row),
);
},
);
}
}
class _DashboardStatusBanner extends ConsumerWidget {
const _DashboardStatusBanner();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Watch a small derived string state so only the banner rebuilds when
// its visibility/content actually changes.
final bannerState = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (_) => 'data',
loading: () => 'loading',
error: (e, _) => 'error:${e.toString()}',
),
),
);
if (bannerState == 'loading') {
return const Padding(
padding: EdgeInsets.only(bottom: 12),
child: LinearProgressIndicator(minHeight: 2),
);
}
if (bannerState.startsWith('error:')) {
final errorText = bannerState.substring(6);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Dashboard data error: $errorText',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
);
}
return const SizedBox.shrink();
}
}
class _MetricCard extends ConsumerWidget {
const _MetricCard({required this.title, required this.valueBuilder});
final String title;
final String Function(DashboardMetrics metrics) valueBuilder;
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only watch the single string value for this card so unrelated metric
// updates don't rebuild the whole card. This makes updates feel much
// smoother and avoids full-page refreshes.
final value = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (m) => valueBuilder(m),
loading: () => '',
error: (error, _) => 'Error',
),
),
);
return AnimatedContainer(
duration: const Duration(milliseconds: 220),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600),
),
const SizedBox(height: 10),
// Animate only the metric text (not the whole card) for a
// subtle, smooth update.
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, anim) =>
FadeTransition(opacity: anim, child: child),
child: MonoText(
value,
key: ValueKey(value),
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
),
),
],
),
);
}
}
class _StaffTable extends StatelessWidget {
const _StaffTable();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(AppSurfaces.of(context).cardRadius),
border: Border.all(color: Theme.of(context).colorScheme.outlineVariant),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
_StaffTableHeader(),
SizedBox(height: 8),
_StaffTableBody(),
],
),
);
}
}
class _StaffTableHeader extends StatelessWidget {
const _StaffTableHeader();
@override
Widget build(BuildContext context) {
final style = Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700);
return Row(
children: [
Expanded(flex: 3, child: Text('IT Staff', style: style)),
Expanded(flex: 2, child: Text('Status', style: style)),
Expanded(flex: 2, child: Text('Tickets', style: style)),
Expanded(flex: 2, child: Text('Tasks', style: style)),
],
);
}
}
class _StaffTableBody extends ConsumerWidget {
const _StaffTableBody();
@override
Widget build(BuildContext context, WidgetRef ref) {
// Only listen to the staff rows and the overall provider state to keep
// rebuilds scoped to this small area.
final providerState = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<String>(
data: (_) => 'data',
loading: () => 'loading',
error: (e, _) => 'error:${e.toString()}',
),
),
);
final staffRows = ref.watch(
dashboardMetricsProvider.select(
(av) => av.when<List<StaffRowMetrics>>(
data: (m) => m.staffRows,
loading: () => const <StaffRowMetrics>[],
error: (error, _) => const <StaffRowMetrics>[],
),
),
);
if (providerState == 'loading') {
return const Text('Loading staff...');
}
if (providerState.startsWith('error:')) {
final err = providerState.substring(6);
return Text('Failed to load staff: $err');
}
if (staffRows.isEmpty) {
return Text(
'No IT staff available.',
style: Theme.of(context).textTheme.bodySmall,
);
}
return Column(
children: staffRows.map((row) => _StaffRow(row: row)).toList(),
);
}
}
class _StaffRow extends StatelessWidget {
const _StaffRow({required this.row});
final StaffRowMetrics row;
@override
Widget build(BuildContext context) {
final valueStyle = Theme.of(context).textTheme.bodySmall;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Expanded(flex: 3, child: Text(row.name, style: valueStyle)),
Expanded(
flex: 2,
child: Align(
alignment: Alignment.centerLeft,
child: StatusPill(label: row.status),
),
),
Expanded(
flex: 2,
child: Text(
row.ticketsRespondedToday.toString(),
style: valueStyle,
),
),
Expanded(
flex: 2,
child: Text(row.tasksClosedToday.toString(), style: valueStyle),
),
],
),
);
}
}
Duration? _averageDuration(List<Duration> durations) {
if (durations.isEmpty) {
return null;
}
final totalSeconds = durations
.map((duration) => duration.inSeconds)
.reduce((a, b) => a + b);
return Duration(seconds: (totalSeconds / durations.length).round());
}
DateTime? _earliestDate(DateTime? first, DateTime? second) {
if (first == null) return second;
if (second == null) return first;
return first.isBefore(second) ? first : second;
}
String _formatDuration(Duration? duration) {
if (duration == null) {
return 'Pending';
}
if (duration.inSeconds < 60) {
return 'Less than a minute';
}
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
}
return '${minutes}m';
}