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 '../../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 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>((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, ]; 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); } if (asyncValues.any((value) => value.isLoading)) { return const AsyncLoading(); } final tickets = ticketsAsync.valueOrNull ?? const []; final tasks = tasksAsync.valueOrNull ?? const []; final profiles = profilesAsync.valueOrNull ?? const []; final assignments = assignmentsAsync.valueOrNull ?? const []; final messages = messagesAsync.valueOrNull ?? const []; 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 = {}; final lastStaffMessageByUser = {}; final respondedTicketsByUser = >{}; 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, () => {}) .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() .toList(); final triageDurationsToday = ticketsToday .map(triageDuration) .whereType() .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 = {}; 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 = >{}; 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, () => {}) .add(task.id); } const triageWindow = Duration(minutes: 1); final triageCutoff = now.subtract(triageWindow); final 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(); 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 = [ 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 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) { final metricsAsync = ref.watch(dashboardMetricsProvider); return metricsAsync.when( data: (_) => const SizedBox.shrink(), loading: () => const Padding( padding: EdgeInsets.only(bottom: 12), child: LinearProgressIndicator(minHeight: 2), ), error: (error, _) => Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( 'Dashboard data error: $error', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ), ); } } 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) { final metricsAsync = ref.watch(dashboardMetricsProvider); final value = metricsAsync.when( data: (metrics) => valueBuilder(metrics), 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), MonoText( 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(16), 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) { final metricsAsync = ref.watch(dashboardMetricsProvider); return metricsAsync.when( data: (metrics) { if (metrics.staffRows.isEmpty) { return Text( 'No IT staff available.', style: Theme.of(context).textTheme.bodySmall, ); } return Column( children: metrics.staffRows .map((row) => _StaffRow(row: row)) .toList(), ); }, loading: () => const Text('Loading staff...'), error: (error, _) => Text('Failed to load staff: $error'), ); } } 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 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'; }