From d9270b3edf7d3fa446a7bc38fdc9c4d59958f749 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Tue, 3 Mar 2026 07:38:40 +0800 Subject: [PATCH] Reports --- lib/providers/reports_provider.dart | 412 ++++++++++++++++ lib/routing/app_router.dart | 20 +- lib/screens/reports/report_date_filter.dart | 459 ++++++++++++++++++ lib/screens/reports/report_pdf_export.dart | 252 ++++++++++ .../reports/report_widget_selector.dart | 90 ++++ lib/screens/reports/reports_screen.dart | 348 +++++++++++++ .../reports/widgets/avg_resolution_chart.dart | 134 +++++ .../reports/widgets/conversion_rate_card.dart | 127 +++++ .../reports/widgets/hourly_charts.dart | 271 +++++++++++ .../widgets/monthly_overview_chart.dart | 220 +++++++++ .../reports/widgets/report_card_wrapper.dart | 103 ++++ .../widgets/request_distribution_charts.dart | 325 +++++++++++++ .../reports/widgets/staff_workload_chart.dart | 192 ++++++++ .../reports/widgets/status_charts.dart | 334 +++++++++++++ .../reports/widgets/top_offices_charts.dart | 165 +++++++ .../reports/widgets/top_subjects_charts.dart | 177 +++++++ pubspec.lock | 16 + pubspec.yaml | 1 + 18 files changed, 3638 insertions(+), 8 deletions(-) create mode 100644 lib/providers/reports_provider.dart create mode 100644 lib/screens/reports/report_date_filter.dart create mode 100644 lib/screens/reports/report_pdf_export.dart create mode 100644 lib/screens/reports/report_widget_selector.dart create mode 100644 lib/screens/reports/reports_screen.dart create mode 100644 lib/screens/reports/widgets/avg_resolution_chart.dart create mode 100644 lib/screens/reports/widgets/conversion_rate_card.dart create mode 100644 lib/screens/reports/widgets/hourly_charts.dart create mode 100644 lib/screens/reports/widgets/monthly_overview_chart.dart create mode 100644 lib/screens/reports/widgets/report_card_wrapper.dart create mode 100644 lib/screens/reports/widgets/request_distribution_charts.dart create mode 100644 lib/screens/reports/widgets/staff_workload_chart.dart create mode 100644 lib/screens/reports/widgets/status_charts.dart create mode 100644 lib/screens/reports/widgets/top_offices_charts.dart create mode 100644 lib/screens/reports/widgets/top_subjects_charts.dart diff --git a/lib/providers/reports_provider.dart b/lib/providers/reports_provider.dart new file mode 100644 index 00000000..84fc6625 --- /dev/null +++ b/lib/providers/reports_provider.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../utils/app_time.dart'; +import 'supabase_provider.dart'; + +// ────────────────────────────────────────────── +// Report date-range model +// ────────────────────────────────────────────── + +/// Describes the selected date range along with a human-readable label +/// (e.g. "Last 30 Days", "This Month"). +@immutable +class ReportDateRange { + const ReportDateRange({ + required this.start, + required this.end, + required this.label, + }); + + final DateTime start; + final DateTime end; + final String label; + + DateTimeRange get dateTimeRange => DateTimeRange(start: start, end: end); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ReportDateRange && + runtimeType == other.runtimeType && + start == other.start && + end == other.end && + label == other.label; + + @override + int get hashCode => Object.hash(start, end, label); +} + +/// Builds a default date range of "Last 30 Days" in the Asia/Manila timezone. +ReportDateRange _defaultDateRange() { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + final start = today.subtract(const Duration(days: 30)); + final end = today.add(const Duration(days: 1)); + return ReportDateRange(start: start, end: end, label: 'Last 30 Days'); +} + +final reportDateRangeProvider = StateProvider((ref) { + return _defaultDateRange(); +}); + +// ────────────────────────────────────────────── +// Report widget toggle (which charts to show) +// ────────────────────────────────────────────── + +/// Each report widget that can be toggled on/off. +enum ReportWidgetType { + ticketsByStatus('Tickets by Status', ReportSection.counts), + tasksByStatus('Tasks by Status', ReportSection.counts), + conversionRate('Ticket-to-Task Conversion', ReportSection.counts), + requestType('Request Type Distribution', ReportSection.counts), + requestCategory('Request Category Distribution', ReportSection.counts), + tasksByHour('Tasks Created by Hour', ReportSection.trends), + ticketsByHour('Tickets Created by Hour', ReportSection.trends), + monthlyOverview('Monthly Overview', ReportSection.trends), + topOfficesTickets('Top Offices by Tickets', ReportSection.rankings), + topOfficesTasks('Top Offices by Tasks', ReportSection.rankings), + topTicketSubjects('Top Ticket Subjects', ReportSection.rankings), + topTaskSubjects('Top Task Subjects', ReportSection.rankings), + avgResolution('Avg Resolution Time by Office', ReportSection.performance), + staffWorkload('IT Staff Workload', ReportSection.performance); + + const ReportWidgetType(this.label, this.section); + final String label; + final ReportSection section; +} + +enum ReportSection { + counts('Counts'), + trends('Trends'), + rankings('Rankings'), + performance('Performance'); + + const ReportSection(this.label); + final String label; +} + +/// Which widgets to include on the reports screen (and in PDF export). +/// Defaults to all enabled. +final reportWidgetToggleProvider = StateProvider>( + (ref) => ReportWidgetType.values.toSet(), +); + +// ────────────────────────────────────────────── +// Helper: build RPC params from the current date range +// ────────────────────────────────────────────── + +Map _rangeParams(ReportDateRange range) => { + 'p_start': range.start.toUtc().toIso8601String(), + 'p_end': range.end.toUtc().toIso8601String(), +}; + +// ────────────────────────────────────────────── +// FutureProviders — one per report RPC +// ────────────────────────────────────────────── + +/// Generic helper to call an RPC and parse each row with [mapper]. +Future> _callRpc( + Ref ref, + String rpcName, + Map params, + T Function(Map) mapper, +) async { + final client = ref.read(supabaseClientProvider); + final response = await client.rpc(rpcName, params: params); + final list = response as List; + return list.map((e) => mapper(Map.from(e as Map))).toList(); +} + +// --- 1. Tickets by status --- +final ticketsByStatusReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_tickets_by_status', + _rangeParams(range), + (m) => StatusCount( + status: m['status'] as String? ?? 'unknown', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 2. Tasks by status --- +final tasksByStatusReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_tasks_by_status', + _rangeParams(range), + (m) => StatusCount( + status: m['status'] as String? ?? 'unknown', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 3. Tasks by hour --- +final tasksByHourReportProvider = FutureProvider.autoDispose>(( + ref, +) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_tasks_by_hour', + _rangeParams(range), + (m) => HourCount( + hour: (m['hour'] as num?)?.toInt() ?? 0, + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); +}); + +// --- 4. Tickets by hour --- +final ticketsByHourReportProvider = FutureProvider.autoDispose>( + (ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_tickets_by_hour', + _rangeParams(range), + (m) => HourCount( + hour: (m['hour'] as num?)?.toInt() ?? 0, + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }, +); + +// --- 5. Top offices by tickets --- +final topOfficesTicketsReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_top_offices_tickets', + {..._rangeParams(range), 'p_limit': 10}, + (m) => NamedCount( + name: m['office_name'] as String? ?? '', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 6. Top offices by tasks --- +final topOfficesTasksReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_top_offices_tasks', + {..._rangeParams(range), 'p_limit': 10}, + (m) => NamedCount( + name: m['office_name'] as String? ?? '', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 7. Top ticket subjects --- +final topTicketSubjectsReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_top_ticket_subjects', + {..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4}, + (m) => NamedCount( + name: m['subject_group'] as String? ?? '', + count: (m['ticket_count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 8. Top task subjects --- +final topTaskSubjectsReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_top_task_subjects', + {..._rangeParams(range), 'p_limit': 10, 'p_threshold': 0.4}, + (m) => NamedCount( + name: m['subject_group'] as String? ?? '', + count: (m['task_count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 9. Monthly overview --- +final monthlyOverviewReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_monthly_overview', + _rangeParams(range), + (m) => MonthlyOverview( + month: m['month'] as String? ?? '', + ticketCount: (m['ticket_count'] as num?)?.toInt() ?? 0, + taskCount: (m['task_count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 10. Request type distribution --- +final requestTypeReportProvider = FutureProvider.autoDispose>(( + ref, +) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_request_type_distribution', + _rangeParams(range), + (m) => NamedCount( + name: m['request_type'] as String? ?? 'Unspecified', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); +}); + +// --- 11. Request category distribution --- +final requestCategoryReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_request_category_distribution', + _rangeParams(range), + (m) => NamedCount( + name: m['request_category'] as String? ?? 'Unspecified', + count: (m['count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 12. Avg resolution time by office --- +final avgResolutionReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_avg_resolution_by_office', + _rangeParams(range), + (m) => OfficeResolution( + officeName: m['office_name'] as String? ?? '', + avgHours: (m['avg_hours'] as num?)?.toDouble() ?? 0, + ), + ); + }); + +// --- 13. Staff workload --- +final staffWorkloadReportProvider = + FutureProvider.autoDispose>((ref) { + final range = ref.watch(reportDateRangeProvider); + return _callRpc( + ref, + 'report_staff_workload', + _rangeParams(range), + (m) => StaffWorkload( + staffName: m['staff_name'] as String? ?? '', + assignedCount: (m['assigned_count'] as num?)?.toInt() ?? 0, + completedCount: (m['completed_count'] as num?)?.toInt() ?? 0, + ), + ); + }); + +// --- 14. Ticket-to-task conversion rate --- +final ticketToTaskRateReportProvider = + FutureProvider.autoDispose((ref) async { + final range = ref.watch(reportDateRangeProvider); + final client = ref.read(supabaseClientProvider); + final response = await client.rpc( + 'report_ticket_to_task_rate', + params: _rangeParams(range), + ); + final list = response as List; + if (list.isEmpty) { + return const ConversionRate( + totalTickets: 0, + promotedTickets: 0, + conversionRate: 0, + ); + } + final m = Map.from(list.first as Map); + return ConversionRate( + totalTickets: (m['total_tickets'] as num?)?.toInt() ?? 0, + promotedTickets: (m['promoted_tickets'] as num?)?.toInt() ?? 0, + conversionRate: (m['conversion_rate'] as num?)?.toDouble() ?? 0, + ); + }); + +// ────────────────────────────────────────────── +// Data models for report results +// ────────────────────────────────────────────── + +@immutable +class StatusCount { + const StatusCount({required this.status, required this.count}); + final String status; + final int count; +} + +@immutable +class HourCount { + const HourCount({required this.hour, required this.count}); + final int hour; + final int count; +} + +@immutable +class NamedCount { + const NamedCount({required this.name, required this.count}); + final String name; + final int count; +} + +@immutable +class MonthlyOverview { + const MonthlyOverview({ + required this.month, + required this.ticketCount, + required this.taskCount, + }); + final String month; + final int ticketCount; + final int taskCount; +} + +@immutable +class OfficeResolution { + const OfficeResolution({required this.officeName, required this.avgHours}); + final String officeName; + final double avgHours; +} + +@immutable +class StaffWorkload { + const StaffWorkload({ + required this.staffName, + required this.assignedCount, + required this.completedCount, + }); + final String staffName; + final int assignedCount; + final int completedCount; +} + +@immutable +class ConversionRate { + const ConversionRate({ + required this.totalTickets, + required this.promotedTickets, + required this.conversionRate, + }); + final int totalTickets; + final int promotedTickets; + final double conversionRate; +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index b4f6ca3a..c4c19a56 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -16,6 +16,7 @@ import '../screens/notifications/notifications_screen.dart'; import '../screens/profile/profile_screen.dart'; import '../screens/shared/under_development_screen.dart'; import '../screens/shared/permissions_screen.dart'; +import '../screens/reports/reports_screen.dart'; import '../screens/tasks/task_detail_screen.dart'; import '../screens/tasks/tasks_list_screen.dart'; import '../screens/tickets/ticket_detail_screen.dart'; @@ -45,9 +46,13 @@ final appRouterProvider = Provider((ref) { final isSignedIn = session != null; final profileAsync = ref.read(currentProfileProvider); final isAdminRoute = state.matchedLocation.startsWith('/settings'); - final isAdmin = profileAsync is AsyncData - ? (profileAsync.value)?.role == 'admin' - : false; + final role = profileAsync is AsyncData + ? (profileAsync.value)?.role + : null; + final isAdmin = role == 'admin'; + final isReportsRoute = state.matchedLocation == '/reports'; + final hasReportsAccess = + role == 'admin' || role == 'dispatcher' || role == 'it_staff'; if (!isSignedIn && !isAuthRoute) { return '/login'; @@ -58,6 +63,9 @@ final appRouterProvider = Provider((ref) { if (isAdminRoute && !isAdmin) { return '/tickets'; } + if (isReportsRoute && !hasReportsAccess) { + return '/tickets'; + } return null; }, routes: [ @@ -122,11 +130,7 @@ final appRouterProvider = Provider((ref) { ), GoRoute( path: '/reports', - builder: (context, state) => const UnderDevelopmentScreen( - title: 'Reports', - subtitle: 'Reporting automation is under development.', - icon: Icons.analytics, - ), + builder: (context, state) => const ReportsScreen(), ), GoRoute( path: '/settings/users', diff --git a/lib/screens/reports/report_date_filter.dart b/lib/screens/reports/report_date_filter.dart new file mode 100644 index 00000000..4042fe6d --- /dev/null +++ b/lib/screens/reports/report_date_filter.dart @@ -0,0 +1,459 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/reports_provider.dart'; +import '../../utils/app_time.dart'; + +/// A Metabase-inspired date filter with quick presets, relative offsets, +/// and a custom date-range picker. +class ReportDateFilter extends ConsumerWidget { + const ReportDateFilter({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final range = ref.watch(reportDateRangeProvider); + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = theme.textTheme; + + return Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon(Icons.calendar_today, size: 18, color: colors.primary), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(range.label, style: text.labelLarge), + Text( + AppTime.formatDateRange(range.dateTimeRange), + style: text.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + FilledButton.tonalIcon( + onPressed: () => _showDateFilterDialog(context, ref), + icon: const Icon(Icons.tune, size: 18), + label: const Text('Change'), + ), + ], + ), + ), + ); + } + + void _showDateFilterDialog(BuildContext context, WidgetRef ref) { + showDialog( + context: context, + builder: (ctx) => _DateFilterDialog( + current: ref.read(reportDateRangeProvider), + onApply: (newRange) { + ref.read(reportDateRangeProvider.notifier).state = newRange; + }, + ), + ); + } +} + +// ────────────────────────────────────────────── +// Date filter dialog with three tabs +// ────────────────────────────────────────────── + +class _DateFilterDialog extends StatefulWidget { + const _DateFilterDialog({required this.current, required this.onApply}); + + final ReportDateRange current; + final ValueChanged onApply; + + @override + State<_DateFilterDialog> createState() => _DateFilterDialogState(); +} + +class _DateFilterDialogState extends State<_DateFilterDialog> + with SingleTickerProviderStateMixin { + late TabController _tabController; + + // Relative tab state + int _relativeAmount = 30; + String _relativeUnit = 'days'; + + // Custom tab state + DateTime? _customStart; + DateTime? _customEnd; + + static const _units = [ + 'minutes', + 'hours', + 'days', + 'weeks', + 'months', + 'quarters', + 'years', + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _customStart = widget.current.start; + _customEnd = widget.current.end; + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = theme.textTheme; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 440, maxHeight: 520), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), + child: Row( + children: [ + Icon(Icons.date_range, color: colors.primary), + const SizedBox(width: 8), + Text('Filter Date Range', style: text.titleMedium), + const Spacer(), + IconButton( + icon: const Icon(Icons.close, size: 20), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ), + TabBar( + controller: _tabController, + labelStyle: text.labelMedium, + tabs: const [ + Tab(text: 'Presets'), + Tab(text: 'Relative'), + Tab(text: 'Custom'), + ], + ), + Flexible( + child: TabBarView( + controller: _tabController, + children: [ + _buildPresetsTab(context), + _buildRelativeTab(context), + _buildCustomTab(context), + ], + ), + ), + ], + ), + ), + ); + } + + // ─── Presets Tab ─── + + Widget _buildPresetsTab(BuildContext context) { + final now = AppTime.now(); + final today = DateTime(now.year, now.month, now.day); + + final presets = <_Preset>[ + _Preset('Today', today, today.add(const Duration(days: 1))), + _Preset('Yesterday', today.subtract(const Duration(days: 1)), today), + _Preset( + 'Last 7 Days', + today.subtract(const Duration(days: 7)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'Last 30 Days', + today.subtract(const Duration(days: 30)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'Last 90 Days', + today.subtract(const Duration(days: 90)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'This Week', + today.subtract(Duration(days: today.weekday - 1)), + today.add(const Duration(days: 1)), + ), + _Preset( + 'This Month', + DateTime(now.year, now.month, 1), + DateTime(now.year, now.month + 1, 1), + ), + _Preset( + 'This Quarter', + DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1), + DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 4, 1), + ), + _Preset( + 'This Year', + DateTime(now.year, 1, 1), + DateTime(now.year + 1, 1, 1), + ), + _Preset( + 'Last Week', + today.subtract(Duration(days: today.weekday + 6)), + today.subtract(Duration(days: today.weekday - 1)), + ), + _Preset( + 'Last Month', + DateTime(now.year, now.month - 1, 1), + DateTime(now.year, now.month, 1), + ), + _Preset( + 'Last Quarter', + DateTime(now.year, ((now.month - 1) ~/ 3) * 3 - 2, 1), + DateTime(now.year, ((now.month - 1) ~/ 3) * 3 + 1, 1), + ), + _Preset( + 'Last Year', + DateTime(now.year - 1, 1, 1), + DateTime(now.year, 1, 1), + ), + ]; + + return SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: presets.map((p) { + final isSelected = widget.current.label == p.label; + return ChoiceChip( + label: Text(p.label), + selected: isSelected, + onSelected: (_) { + widget.onApply( + ReportDateRange(start: p.start, end: p.end, label: p.label), + ); + Navigator.pop(context); + }, + ); + }).toList(), + ), + ); + } + + // ─── Relative Tab ─── + + Widget _buildRelativeTab(BuildContext context) { + final theme = Theme.of(context); + final text = theme.textTheme; + + // Compute preview + final previewRange = _computeRelativeRange(_relativeAmount, _relativeUnit); + final previewText = AppTime.formatDateRange(previewRange.dateTimeRange); + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Previous', style: text.labelLarge), + const SizedBox(height: 12), + Row( + children: [ + SizedBox( + width: 80, + child: TextFormField( + initialValue: _relativeAmount.toString(), + keyboardType: TextInputType.number, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + onChanged: (v) { + setState(() { + _relativeAmount = int.tryParse(v) ?? _relativeAmount; + }); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + initialValue: _relativeUnit, + decoration: const InputDecoration( + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + ), + items: _units + .map((u) => DropdownMenuItem(value: u, child: Text(u))) + .toList(), + onChanged: (v) { + if (v != null) setState(() => _relativeUnit = v); + }, + ), + ), + const SizedBox(width: 12), + Text('ago', style: text.bodyLarge), + ], + ), + const SizedBox(height: 16), + Text( + previewText, + style: text.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: () { + widget.onApply(previewRange); + Navigator.pop(context); + }, + child: const Text('Apply'), + ), + ), + ], + ), + ); + } + + ReportDateRange _computeRelativeRange(int amount, String unit) { + final now = AppTime.now(); + final end = now; + DateTime start; + + switch (unit) { + case 'minutes': + start = now.subtract(Duration(minutes: amount)); + case 'hours': + start = now.subtract(Duration(hours: amount)); + case 'days': + start = now.subtract(Duration(days: amount)); + case 'weeks': + start = now.subtract(Duration(days: amount * 7)); + case 'months': + start = DateTime(now.year, now.month - amount, now.day); + case 'quarters': + start = DateTime(now.year, now.month - (amount * 3), now.day); + case 'years': + start = DateTime(now.year - amount, now.month, now.day); + default: + start = now.subtract(Duration(days: amount)); + } + + return ReportDateRange(start: start, end: end, label: 'Last $amount $unit'); + } + + // ─── Custom Tab ─── + + Widget _buildCustomTab(BuildContext context) { + final theme = Theme.of(context); + final text = theme.textTheme; + final colors = theme.colorScheme; + + return Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Start Date', style: text.labelLarge), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + _customStart != null + ? AppTime.formatDate(_customStart!) + : 'Select start date', + ), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _customStart ?? AppTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) setState(() => _customStart = picked); + }, + ), + const SizedBox(height: 16), + Text('End Date', style: text.labelLarge), + const SizedBox(height: 8), + OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 16), + label: Text( + _customEnd != null + ? AppTime.formatDate(_customEnd!) + : 'Select end date', + ), + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: _customEnd ?? AppTime.now(), + firstDate: DateTime(2020), + lastDate: DateTime(2030), + ); + if (picked != null) setState(() => _customEnd = picked); + }, + ), + if (_customStart != null && _customEnd != null) ...[ + const SizedBox(height: 12), + Text( + AppTime.formatDateRange( + DateTimeRange(start: _customStart!, end: _customEnd!), + ), + style: text.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + ], + const SizedBox(height: 20), + Align( + alignment: Alignment.centerRight, + child: FilledButton( + onPressed: (_customStart != null && _customEnd != null) + ? () { + widget.onApply( + ReportDateRange( + start: _customStart!, + end: _customEnd!.add(const Duration(days: 1)), + label: 'Custom Range', + ), + ); + Navigator.pop(context); + } + : null, + child: const Text('Apply'), + ), + ), + ], + ), + ); + } +} + +class _Preset { + const _Preset(this.label, this.start, this.end); + final String label; + final DateTime start; + final DateTime end; +} diff --git a/lib/screens/reports/report_pdf_export.dart b/lib/screens/reports/report_pdf_export.dart new file mode 100644 index 00000000..b09d0a2d --- /dev/null +++ b/lib/screens/reports/report_pdf_export.dart @@ -0,0 +1,252 @@ +import 'dart:isolate'; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart' show rootBundle; +import 'package:pdf/pdf.dart' as pdf; +import 'package:pdf/widgets.dart' as pw; +import 'package:printing/printing.dart'; + +import '../../providers/reports_provider.dart'; +import '../../utils/app_time.dart'; + +/// Captures all visible report charts via their [RepaintBoundary] keys +/// and generates a PDF document for export / printing. +class ReportPdfExport { + ReportPdfExport._(); + + /// Capture a [RepaintBoundary] as a PNG [Uint8List]. + /// + /// Yields to the event loop after each capture so the UI stays responsive. + static Future _captureWidget(GlobalKey key) async { + final boundary = + key.currentContext?.findRenderObject() as RenderRepaintBoundary?; + if (boundary == null) return null; + + final image = await boundary.toImage(pixelRatio: 1.5); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + // Yield to the event loop so the UI thread doesn't freeze + await Future.delayed(Duration.zero); + return byteData?.buffer.asUint8List(); + } + + /// Generates the full report PDF from the given [captureKeys] map. + /// + /// [captureKeys] maps a human-readable chart title to the [GlobalKey] + /// attached to that chart's [RepaintBoundary]. + /// + /// [dateRange] is for display in the header. + /// [enabledWidgets] filters which charts to include. + static Future generatePdf({ + required Map captureKeys, + required ReportDateRange dateRange, + required Set enabledWidgets, + }) async { + // Load fonts + final regularFontData = await rootBundle.load( + 'assets/fonts/Roboto-Regular.ttf', + ); + final boldFontData = await rootBundle.load('assets/fonts/Roboto-Bold.ttf'); + + // Try to load logo (may not exist in all builds) + Uint8List? logoBytes; + try { + final logoData = await rootBundle.load('assets/crmc_logo.png'); + logoBytes = logoData.buffer.asUint8List(); + } catch (_) { + // Logo not available — skip + } + + // ── Capture chart images on the main thread (needs render objects) ── + final chartImages = {}; + for (final entry in captureKeys.entries) { + final bytes = await _captureWidget(entry.value); + if (bytes != null) { + chartImages[entry.key] = bytes; + } + } + + final dateRangeText = AppTime.formatDateRange(dateRange.dateTimeRange); + final generated = AppTime.formatDate(AppTime.now()); + final dateLabel = dateRange.label; + + // ── Build the PDF on a background isolate so it doesn't freeze the UI ── + return Isolate.run(() { + return _buildPdfBytes( + chartImages: chartImages, + dateRangeText: dateRangeText, + dateLabel: dateLabel, + generated: generated, + regularFontData: regularFontData.buffer.asUint8List(), + boldFontData: boldFontData.buffer.asUint8List(), + logoBytes: logoBytes, + ); + }); + } + + /// Pure function that assembles the PDF document from raw data. + /// Designed to run inside [Isolate.run]. + static Future _buildPdfBytes({ + required Map chartImages, + required String dateRangeText, + required String dateLabel, + required String generated, + required Uint8List regularFontData, + required Uint8List boldFontData, + Uint8List? logoBytes, + }) { + final regularFont = pw.Font.ttf(regularFontData.buffer.asByteData()); + final boldFont = pw.Font.ttf(boldFontData.buffer.asByteData()); + pw.MemoryImage? logoImage; + if (logoBytes != null) { + logoImage = pw.MemoryImage(logoBytes); + } + + final doc = pw.Document(); + + // One page per chart to avoid TooManyPagesException and keep layout simple + // First chart gets the full header; subsequent ones get a compact header. + var isFirst = true; + for (final entry in chartImages.entries) { + doc.addPage( + pw.Page( + pageFormat: pdf.PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont), + build: (pw.Context ctx) { + final header = isFirst + ? pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + if (logoImage != null) + pw.Container( + width: 50, + height: 50, + child: pw.Image(logoImage), + ), + if (logoImage != null) pw.SizedBox(width: 12), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'TasQ Report', + style: pw.TextStyle( + fontSize: 20, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.Text( + '$dateLabel — $dateRangeText', + style: pw.TextStyle( + fontSize: 11, + color: pdf.PdfColors.grey700, + ), + ), + ], + ), + pw.Spacer(), + pw.Text( + 'Generated: $generated', + style: pw.TextStyle( + fontSize: 9, + color: pdf.PdfColors.grey500, + ), + ), + ], + ), + pw.Divider(thickness: 0.5), + pw.SizedBox(height: 8), + ], + ) + : pw.Container( + margin: const pw.EdgeInsets.only(bottom: 8), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + 'TasQ Report', + style: pw.TextStyle( + fontSize: 10, + color: pdf.PdfColors.grey600, + ), + ), + pw.Text( + dateRangeText, + style: pw.TextStyle( + fontSize: 10, + color: pdf.PdfColors.grey600, + ), + ), + ], + ), + ); + + isFirst = false; + + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + header, + pw.Text( + entry.key, + style: pw.TextStyle( + fontSize: 13, + fontWeight: pw.FontWeight.bold, + ), + ), + pw.SizedBox(height: 6), + pw.Expanded( + child: pw.Center( + child: pw.Image( + pw.MemoryImage(entry.value), + fit: pw.BoxFit.contain, + ), + ), + ), + pw.Container( + alignment: pw.Alignment.centerRight, + child: pw.Text( + 'Page ${ctx.pageNumber} • Generated by TasQ', + style: pw.TextStyle(fontSize: 9, color: pdf.PdfColors.grey), + ), + ), + ], + ); + }, + ), + ); + } + + if (chartImages.isEmpty) { + doc.addPage( + pw.Page( + pageFormat: pdf.PdfPageFormat.a4.landscape, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont(base: regularFont, bold: boldFont), + build: (pw.Context ctx) => pw.Center( + child: pw.Text( + 'No charts selected for export.', + style: pw.TextStyle(fontSize: 14), + ), + ), + ), + ); + } + + return doc.save(); + } + + /// Show a print / share dialog for the generated PDF. + static Future sharePdf(Uint8List pdfBytes) async { + await Printing.layoutPdf( + onLayout: (_) => pdfBytes, + name: + 'TasQ_Report_${AppTime.formatDate(AppTime.now()).replaceAll(' ', '_')}.pdf', + ); + } +} diff --git a/lib/screens/reports/report_widget_selector.dart b/lib/screens/reports/report_widget_selector.dart new file mode 100644 index 00000000..dd42e107 --- /dev/null +++ b/lib/screens/reports/report_widget_selector.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/reports_provider.dart'; + +/// Collapsible panel for toggling which report widgets to include +/// on the screen and in the exported PDF. +class ReportWidgetSelector extends ConsumerWidget { + const ReportWidgetSelector({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final enabled = ref.watch(reportWidgetToggleProvider); + final theme = Theme.of(context); + final text = theme.textTheme; + + return Card( + child: ExpansionTile( + leading: const Icon(Icons.widgets_outlined, size: 20), + title: Text('Widgets to Include', style: text.titleSmall), + subtitle: Text( + '${enabled.length} of ${ReportWidgetType.values.length} selected', + style: text.bodySmall, + ), + childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + children: [ + // Quick actions + Row( + children: [ + TextButton.icon( + onPressed: () { + ref.read(reportWidgetToggleProvider.notifier).state = + ReportWidgetType.values.toSet(); + }, + icon: const Icon(Icons.select_all, size: 16), + label: const Text('Select All'), + ), + const SizedBox(width: 8), + TextButton.icon( + onPressed: () { + ref.read(reportWidgetToggleProvider.notifier).state = {}; + }, + icon: const Icon(Icons.deselect, size: 16), + label: const Text('Deselect All'), + ), + ], + ), + const SizedBox(height: 4), + // Group by section + for (final section in ReportSection.values) ...[ + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 4), + child: Text( + section.label, + style: text.labelMedium?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w600, + ), + ), + ), + Wrap( + spacing: 8, + runSpacing: 4, + children: ReportWidgetType.values + .where((w) => w.section == section) + .map((w) { + final isSelected = enabled.contains(w); + return FilterChip( + label: Text(w.label), + selected: isSelected, + onSelected: (selected) { + final current = Set.from(enabled); + if (selected) { + current.add(w); + } else { + current.remove(w); + } + ref.read(reportWidgetToggleProvider.notifier).state = + current; + }, + ); + }) + .toList(), + ), + ], + ], + ), + ); + } +} diff --git a/lib/screens/reports/reports_screen.dart b/lib/screens/reports/reports_screen.dart new file mode 100644 index 00000000..51aa64a1 --- /dev/null +++ b/lib/screens/reports/reports_screen.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../providers/reports_provider.dart'; +import 'report_date_filter.dart'; +import 'report_widget_selector.dart'; +import 'report_pdf_export.dart'; +import 'widgets/status_charts.dart'; +import 'widgets/hourly_charts.dart'; +import 'widgets/top_offices_charts.dart'; +import 'widgets/top_subjects_charts.dart'; +import 'widgets/monthly_overview_chart.dart'; +import 'widgets/request_distribution_charts.dart'; +import 'widgets/avg_resolution_chart.dart'; +import 'widgets/staff_workload_chart.dart'; +import 'widgets/conversion_rate_card.dart'; + +/// Main reports screen — scrollable collection of chart widgets, +/// filterable by date range and toggleable per-widget, exportable to PDF. +class ReportsScreen extends ConsumerStatefulWidget { + const ReportsScreen({super.key}); + + @override + ConsumerState createState() => _ReportsScreenState(); +} + +class _ReportsScreenState extends ConsumerState { + // RepaintBoundary keys for PDF capture + final _keys = { + for (final t in ReportWidgetType.values) t: GlobalKey(), + }; + + bool _exporting = false; + + Future _exportPdf() async { + setState(() => _exporting = true); + try { + final enabled = ref.read(reportWidgetToggleProvider); + final dateRange = ref.read(reportDateRangeProvider); + + // Wait a frame so charts are rendered before capture + await Future.delayed(const Duration(milliseconds: 200)); + + final captureKeys = {}; + for (final type in ReportWidgetType.values) { + if (enabled.contains(type)) { + captureKeys[type.label] = _keys[type]!; + } + } + + final pdfBytes = await ReportPdfExport.generatePdf( + captureKeys: captureKeys, + dateRange: dateRange, + enabledWidgets: enabled, + ); + + await ReportPdfExport.sharePdf(pdfBytes); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('PDF export failed: $e'))); + } + } finally { + if (mounted) setState(() => _exporting = false); + } + } + + @override + Widget build(BuildContext context) { + final enabled = ref.watch(reportWidgetToggleProvider); + final theme = Theme.of(context); + + return Scaffold( + body: Column( + children: [ + // Sticky top section + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: Column( + children: [ + // Title row + Row( + children: [ + Expanded( + child: Text( + 'Reports', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + FilledButton.icon( + onPressed: _exporting ? null : _exportPdf, + icon: _exporting + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.picture_as_pdf, size: 18), + label: Text(_exporting ? 'Exporting…' : 'Export PDF'), + ), + ], + ), + const SizedBox(height: 8), + const ReportDateFilter(), + const SizedBox(height: 4), + const ReportWidgetSelector(), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + // Scrollable chart area + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 1200), + child: _buildCharts(context, enabled), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildCharts(BuildContext context, Set enabled) { + if (enabled.isEmpty) { + return SizedBox( + height: 300, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.analytics_outlined, + size: 48, + color: Theme.of(context).colorScheme.outlineVariant, + ), + const SizedBox(height: 12), + Text( + 'No widgets selected', + style: Theme.of(context).textTheme.bodyLarge, + ), + const SizedBox(height: 4), + Text( + 'Use the "Widgets to Include" panel to enable reports.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ); + } + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= 600; + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Status donuts (paired on desktop) ── + if (_anyEnabled(enabled, [ + ReportWidgetType.ticketsByStatus, + ReportWidgetType.tasksByStatus, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.ticketsByStatus)) + TicketsByStatusChart( + repaintKey: _keys[ReportWidgetType.ticketsByStatus], + ), + if (enabled.contains(ReportWidgetType.tasksByStatus)) + TasksByStatusChart( + repaintKey: _keys[ReportWidgetType.tasksByStatus], + ), + ], + ), + + // ── Conversion rate KPI ── + if (enabled.contains(ReportWidgetType.conversionRate)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: ConversionRateCard( + repaintKey: _keys[ReportWidgetType.conversionRate], + ), + ), + + // ── Request type + category donuts ── + if (_anyEnabled(enabled, [ + ReportWidgetType.requestType, + ReportWidgetType.requestCategory, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.requestType)) + RequestTypeChart( + repaintKey: _keys[ReportWidgetType.requestType], + ), + if (enabled.contains(ReportWidgetType.requestCategory)) + RequestCategoryChart( + repaintKey: _keys[ReportWidgetType.requestCategory], + ), + ], + ), + + // ── Monthly overview (full width) ── + if (enabled.contains(ReportWidgetType.monthlyOverview)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: MonthlyOverviewChart( + repaintKey: _keys[ReportWidgetType.monthlyOverview], + ), + ), + + // ── Hourly charts (paired) ── + if (_anyEnabled(enabled, [ + ReportWidgetType.tasksByHour, + ReportWidgetType.ticketsByHour, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.tasksByHour)) + TasksByHourChart( + repaintKey: _keys[ReportWidgetType.tasksByHour], + ), + if (enabled.contains(ReportWidgetType.ticketsByHour)) + TicketsByHourChart( + repaintKey: _keys[ReportWidgetType.ticketsByHour], + ), + ], + ), + + // ── Top offices + subjects for Tickets (paired) ── + if (_anyEnabled(enabled, [ + ReportWidgetType.topOfficesTickets, + ReportWidgetType.topTicketSubjects, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.topOfficesTickets)) + TopOfficesTicketsChart( + repaintKey: _keys[ReportWidgetType.topOfficesTickets], + ), + if (enabled.contains(ReportWidgetType.topTicketSubjects)) + TopTicketSubjectsChart( + repaintKey: _keys[ReportWidgetType.topTicketSubjects], + ), + ], + ), + + // ── Top offices + subjects for Tasks (paired) ── + if (_anyEnabled(enabled, [ + ReportWidgetType.topOfficesTasks, + ReportWidgetType.topTaskSubjects, + ])) + _responsiveRow( + isWide: isWide, + children: [ + if (enabled.contains(ReportWidgetType.topOfficesTasks)) + TopOfficesTasksChart( + repaintKey: _keys[ReportWidgetType.topOfficesTasks], + ), + if (enabled.contains(ReportWidgetType.topTaskSubjects)) + TopTaskSubjectsChart( + repaintKey: _keys[ReportWidgetType.topTaskSubjects], + ), + ], + ), + + // ── Avg resolution (full width) ── + if (enabled.contains(ReportWidgetType.avgResolution)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: AvgResolutionChart( + repaintKey: _keys[ReportWidgetType.avgResolution], + ), + ), + + // ── Staff workload (full width) ── + if (enabled.contains(ReportWidgetType.staffWorkload)) + Padding( + padding: const EdgeInsets.only(bottom: 12), + child: StaffWorkloadChart( + repaintKey: _keys[ReportWidgetType.staffWorkload], + ), + ), + ], + ); + }, + ); + } + + /// Build either a [Row] (desktop, >=600) or a [Column] (mobile) for a pair + /// of chart widgets. + Widget _responsiveRow({ + required bool isWide, + required List children, + }) { + if (children.isEmpty) return const SizedBox.shrink(); + + if (isWide && children.length > 1) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: + children + .expand( + (w) => [Expanded(child: w), const SizedBox(width: 12)], + ) + .toList() + ..removeLast(), // remove trailing SizedBox + ), + ); + } + + // Single column layout + return Column( + children: children.map((w) { + return Padding(padding: const EdgeInsets.only(bottom: 12), child: w); + }).toList(), + ); + } + + bool _anyEnabled( + Set enabled, + List types, + ) { + return types.any(enabled.contains); + } +} diff --git a/lib/screens/reports/widgets/avg_resolution_chart.dart b/lib/screens/reports/widgets/avg_resolution_chart.dart new file mode 100644 index 00000000..10371fdd --- /dev/null +++ b/lib/screens/reports/widgets/avg_resolution_chart.dart @@ -0,0 +1,134 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Formats hours into a human-readable string. +String _formatHours(double h) { + if (h < 1) { + return '${(h * 60).round()}m'; + } + if (h >= 24) { + final days = (h / 24).floor(); + final rem = (h % 24).round(); + return rem > 0 ? '${days}d ${rem}h' : '${days}d'; + } + return '${h.toStringAsFixed(1)}h'; +} + +/// Horizontal bar chart — average task resolution time (hours) per office. +/// Limited to the top 10 slowest offices. Uses custom Flutter widgets so +/// labels sit genuinely inside each bar. +class AvgResolutionChart extends ConsumerWidget { + const AvgResolutionChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(avgResolutionReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Avg Resolution Time by Office', + isLoading: true, + height: 320, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Avg Resolution Time by Office', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List rawData) { + final data = rawData.length > 10 ? rawData.sublist(0, 10) : rawData; + + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Avg Resolution Time by Office', + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final maxHours = data.fold( + 0, + (m, e) => e.avgHours > m ? e.avgHours : m, + ); + final height = (data.length * 34.0).clamp(160.0, 420.0); + final colors = Theme.of(context).colorScheme; + final barColor = colors.error.withValues(alpha: 0.75); + final onBarColor = barColor.computeLuminance() > 0.5 + ? Colors.black87 + : Colors.white; + + return ReportCardWrapper( + title: 'Avg Resolution Time by Office', + repaintBoundaryKey: repaintKey, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: onBarColor, + fontWeight: FontWeight.w600, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: data.map((item) { + final fraction = maxHours > 0 ? item.avgHours / maxHours : 0.0; + final barWidth = (fraction * availableWidth).clamp( + 120.0, + availableWidth, + ); + final timeLabel = _formatHours(item.avgHours); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Tooltip( + message: '${item.officeName}: $timeLabel', + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: barWidth, + height: 28, + decoration: BoxDecoration( + color: barColor, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(6), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: Text( + item.officeName, + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Text(timeLabel, style: labelStyle), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ); + } +} diff --git a/lib/screens/reports/widgets/conversion_rate_card.dart b/lib/screens/reports/widgets/conversion_rate_card.dart new file mode 100644 index 00000000..2b1c2626 --- /dev/null +++ b/lib/screens/reports/widgets/conversion_rate_card.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// KPI card showing ticket-to-task conversion rate, with a visual gauge arc. +class ConversionRateCard extends ConsumerWidget { + const ConversionRateCard({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(ticketToTaskRateReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Ticket-to-Task Conversion', + isLoading: true, + height: 160, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Ticket-to-Task Conversion', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, ConversionRate data) { + final colors = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + + return ReportCardWrapper( + title: 'Ticket-to-Task Conversion', + repaintBoundaryKey: repaintKey, + child: Row( + children: [ + // Gauge + SizedBox( + width: 100, + height: 100, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 90, + height: 90, + child: CircularProgressIndicator( + value: data.conversionRate / 100, + strokeWidth: 8, + backgroundColor: colors.surfaceContainerHighest, + color: colors.primary, + strokeCap: StrokeCap.round, + ), + ), + Text( + '${data.conversionRate.toStringAsFixed(1)}%', + style: text.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: colors.primary, + ), + ), + ], + ), + ), + const SizedBox(width: 24), + // Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _MetricRow( + label: 'Total Tickets', + value: data.totalTickets.toString(), + text: text, + ), + const SizedBox(height: 8), + _MetricRow( + label: 'Promoted to Task', + value: data.promotedTickets.toString(), + text: text, + ), + const SizedBox(height: 8), + _MetricRow( + label: 'Not Promoted', + value: (data.totalTickets - data.promotedTickets).toString(), + text: text, + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MetricRow extends StatelessWidget { + const _MetricRow({ + required this.label, + required this.value, + required this.text, + }); + final String label; + final String value; + final TextTheme text; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: Text(label, style: text.bodySmall)), + Text( + value, + style: text.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + ], + ); + } +} diff --git a/lib/screens/reports/widgets/hourly_charts.dart b/lib/screens/reports/widgets/hourly_charts.dart new file mode 100644 index 00000000..479e14a2 --- /dev/null +++ b/lib/screens/reports/widgets/hourly_charts.dart @@ -0,0 +1,271 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Converts 24h hour int to AM/PM string. +String _hourAmPm(int h) { + if (h == 0) return '12AM'; + if (h < 12) return '${h}AM'; + if (h == 12) return '12PM'; + return '${h - 12}PM'; +} + +/// Vertical bar chart showing tasks created per hour of day (0–23). +class TasksByHourChart extends ConsumerWidget { + const TasksByHourChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(tasksByHourReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Tasks Created by Hour', + isLoading: true, + height: 260, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Tasks Created by Hour', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Tasks Created by Hour', + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + // Fill all 24 hours + final hourMap = {for (final h in data) h.hour: h.count}; + final maxCount = data + .fold(0, (m, e) => e.count > m ? e.count : m) + .toDouble(); + + final colors = Theme.of(context).colorScheme; + + return ReportCardWrapper( + title: 'Tasks Created by Hour', + repaintBoundaryKey: repaintKey, + height: 260, + child: BarChart( + BarChartData( + maxY: maxCount * 1.15, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIdx, rod, rodIdx) { + return BarTooltipItem( + '${_hourAmPm(group.x)} — ${rod.toY.toInt()}', + Theme.of(context).textTheme.bodySmall!.copyWith( + color: colors.onInverseSurface, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + value.toInt().toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final h = value.toInt(); + // Show every 3rd hour label to avoid crowding + if (h % 3 != 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _hourAmPm(h), + style: Theme.of(context).textTheme.labelSmall, + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: maxCount > 0 + ? (maxCount / 4).ceilToDouble() + : 1, + ), + borderData: FlBorderData(show: false), + barGroups: List.generate(24, (i) { + final count = (hourMap[i] ?? 0).toDouble(); + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: count, + width: 10, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: colors.primary, + ), + ], + ); + }), + ), + ), + ); + } +} + +/// Vertical bar chart showing tickets created per hour of day (0–23). +class TicketsByHourChart extends ConsumerWidget { + const TicketsByHourChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(ticketsByHourReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Tickets Created by Hour', + isLoading: true, + height: 260, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Tickets Created by Hour', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Tickets Created by Hour', + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final hourMap = {for (final h in data) h.hour: h.count}; + final maxCount = data + .fold(0, (m, e) => e.count > m ? e.count : m) + .toDouble(); + final colors = Theme.of(context).colorScheme; + + return ReportCardWrapper( + title: 'Tickets Created by Hour', + repaintBoundaryKey: repaintKey, + height: 260, + child: BarChart( + BarChartData( + maxY: maxCount * 1.15, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIdx, rod, rodIdx) { + return BarTooltipItem( + '${_hourAmPm(group.x)} — ${rod.toY.toInt()}', + Theme.of(context).textTheme.bodySmall!.copyWith( + color: colors.onInverseSurface, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + value.toInt().toString(), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final h = value.toInt(); + if (h % 3 != 0) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + _hourAmPm(h), + style: Theme.of(context).textTheme.labelSmall, + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: maxCount > 0 + ? (maxCount / 4).ceilToDouble() + : 1, + ), + borderData: FlBorderData(show: false), + barGroups: List.generate(24, (i) { + final count = (hourMap[i] ?? 0).toDouble(); + return BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: count, + width: 10, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: colors.tertiary, + ), + ], + ); + }), + ), + ), + ); + } +} diff --git a/lib/screens/reports/widgets/monthly_overview_chart.dart b/lib/screens/reports/widgets/monthly_overview_chart.dart new file mode 100644 index 00000000..5b92571a --- /dev/null +++ b/lib/screens/reports/widgets/monthly_overview_chart.dart @@ -0,0 +1,220 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Dual-series line chart showing tickets and tasks per month. +class MonthlyOverviewChart extends ConsumerWidget { + const MonthlyOverviewChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(monthlyOverviewReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Monthly Overview', + isLoading: true, + height: 280, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Monthly Overview', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Monthly Overview', + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final colors = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + + final allCounts = [ + ...data.map((e) => e.ticketCount), + ...data.map((e) => e.taskCount), + ]; + final maxY = allCounts.fold(0, (m, e) => e > m ? e : m).toDouble(); + + return ReportCardWrapper( + title: 'Monthly Overview', + repaintBoundaryKey: repaintKey, + height: 300, + child: Column( + children: [ + // Legend row + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LegendDot(color: colors.primary, label: 'Tickets'), + const SizedBox(width: 16), + _LegendDot(color: colors.secondary, label: 'Tasks'), + ], + ), + const SizedBox(height: 12), + Expanded( + child: LineChart( + LineChartData( + maxY: maxY * 1.15, + minY: 0, + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipItems: (spots) { + return spots.map((spot) { + final label = spot.barIndex == 0 ? 'Tickets' : 'Tasks'; + return LineTooltipItem( + '$label: ${spot.y.toInt()}', + text.bodySmall!.copyWith( + color: colors.onInverseSurface, + ), + ); + }).toList(); + }, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + value.toInt().toString(), + style: text.labelSmall, + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + interval: 1, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= data.length) { + return const SizedBox.shrink(); + } + final month = data[idx].month; + // Show abbreviated month: "2026-03" → "Mar" + final shortMonth = _shortMonth(month); + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text(shortMonth, style: text.labelSmall), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: maxY > 0 ? (maxY / 4).ceilToDouble() : 1, + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + // Tickets line + LineChartBarData( + spots: List.generate( + data.length, + (i) => + FlSpot(i.toDouble(), data[i].ticketCount.toDouble()), + ), + isCurved: true, + preventCurveOverShooting: true, + color: colors.primary, + barWidth: 3, + dotData: FlDotData(show: data.length <= 12), + belowBarData: BarAreaData( + show: true, + color: colors.primary.withValues(alpha: 0.08), + ), + ), + // Tasks line + LineChartBarData( + spots: List.generate( + data.length, + (i) => FlSpot(i.toDouble(), data[i].taskCount.toDouble()), + ), + isCurved: true, + preventCurveOverShooting: true, + color: colors.secondary, + barWidth: 3, + dotData: FlDotData(show: data.length <= 12), + belowBarData: BarAreaData( + show: true, + color: colors.secondary.withValues(alpha: 0.08), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + String _shortMonth(String yyyyMm) { + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final parts = yyyyMm.split('-'); + if (parts.length < 2) return yyyyMm; + final monthIndex = int.tryParse(parts[1]); + if (monthIndex == null || monthIndex < 1 || monthIndex > 12) return yyyyMm; + return months[monthIndex - 1]; + } +} + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.label}); + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} diff --git a/lib/screens/reports/widgets/report_card_wrapper.dart b/lib/screens/reports/widgets/report_card_wrapper.dart new file mode 100644 index 00000000..48baa81c --- /dev/null +++ b/lib/screens/reports/widgets/report_card_wrapper.dart @@ -0,0 +1,103 @@ +import 'package:flutter/material.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +/// Wraps each report chart widget in a themed Card with a title, loading +/// skeleton and error state handling. Exposes a [GlobalKey] on the inner +/// [RepaintBoundary] so the PDF exporter can capture the rendered chart +/// as a raster image. +class ReportCardWrapper extends StatelessWidget { + const ReportCardWrapper({ + super.key, + required this.title, + required this.child, + this.isLoading = false, + this.error, + this.height, + this.repaintBoundaryKey, + }); + + /// Title displayed in the card header. + final String title; + + /// The chart widget to render inside the card. + final Widget child; + + /// Whether to display a loading skeleton. + final bool isLoading; + + /// An error message to display instead of the chart. + final String? error; + + /// Optional fixed height for the chart area. + final double? height; + + /// Key attached to the inner [RepaintBoundary] used for PDF image capture. + final GlobalKey? repaintBoundaryKey; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colors = theme.colorScheme; + final text = theme.textTheme; + + Widget body; + if (error != null) { + body = Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, color: colors.error, size: 32), + const SizedBox(height: 8), + Text( + error!, + style: text.bodyMedium?.copyWith(color: colors.error), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } else if (isLoading) { + body = Skeletonizer( + enabled: true, + child: Container( + height: height ?? 200, + decoration: BoxDecoration( + color: colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + ), + ); + } else { + body = child; + } + + final cardContent = Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text(title, style: text.titleSmall), + ), + const Divider(height: 1), + Padding( + padding: const EdgeInsets.all(16), + child: height != null ? SizedBox(height: height, child: body) : body, + ), + ], + ); + + final card = Card( + // Rely on CardTheme for elevation (M2 exception in hybrid system). + child: cardContent, + ); + + if (repaintBoundaryKey != null) { + return RepaintBoundary(key: repaintBoundaryKey, child: card); + } + return card; + } +} diff --git a/lib/screens/reports/widgets/request_distribution_charts.dart b/lib/screens/reports/widgets/request_distribution_charts.dart new file mode 100644 index 00000000..73f7ffde --- /dev/null +++ b/lib/screens/reports/widgets/request_distribution_charts.dart @@ -0,0 +1,325 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Donut chart distribution of tasks by request type with hover animation. +class RequestTypeChart extends ConsumerStatefulWidget { + const RequestTypeChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + static const _typeColors = { + 'Install': Color(0xFF4CAF50), + 'Repair': Color(0xFFFF9800), + 'Upgrade': Color(0xFF2196F3), + 'Replace': Color(0xFF9C27B0), + 'Other': Color(0xFF607D8B), + 'Unspecified': Color(0xFFBDBDBD), + }; + + @override + ConsumerState createState() => _RequestTypeChartState(); +} + +class _RequestTypeChartState extends ConsumerState { + int _touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final asyncData = ref.watch(requestTypeReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Request Type Distribution', + isLoading: true, + height: 220, + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Request Type Distribution', + error: e.toString(), + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Request Type Distribution', + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final total = data.fold(0, (s, e) => s + e.count); + final colorScheme = Theme.of(context).colorScheme; + final fallbackColors = [ + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + colorScheme.error, + colorScheme.outline, + ]; + + Color colorFor(int idx, String name) => + RequestTypeChart._typeColors[name] ?? + fallbackColors[idx % fallbackColors.length]; + + return ReportCardWrapper( + title: 'Request Type Distribution', + repaintBoundaryKey: widget.repaintKey, + height: 220, + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: + (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = pieTouchResponse + .touchedSection! + .touchedSectionIndex; + }); + }, + ), + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return PieChartSectionData( + value: e.count.toDouble(), + title: '', + radius: isTouched ? 60 : 50, + color: colorFor(i, e.name), + borderSide: isTouched + ? const BorderSide(color: Colors.white, width: 2) + : BorderSide.none, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return _HoverLegendItem( + color: colorFor(i, e.name), + label: e.name, + value: + '${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)', + isTouched: isTouched, + ); + }).toList(), + ), + ], + ), + ); + } +} + +/// Donut chart distribution of tasks by request category with hover animation. +class RequestCategoryChart extends ConsumerStatefulWidget { + const RequestCategoryChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + static const _catColors = { + 'Software': Color(0xFF42A5F5), + 'Hardware': Color(0xFFEF5350), + 'Network': Color(0xFF66BB6A), + 'Unspecified': Color(0xFFBDBDBD), + }; + + @override + ConsumerState createState() => + _RequestCategoryChartState(); +} + +class _RequestCategoryChartState extends ConsumerState { + int _touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final asyncData = ref.watch(requestCategoryReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Request Category Distribution', + isLoading: true, + height: 220, + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Request Category Distribution', + error: e.toString(), + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Request Category Distribution', + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final total = data.fold(0, (s, e) => s + e.count); + final colorScheme = Theme.of(context).colorScheme; + final fallbackColors = [ + colorScheme.primary, + colorScheme.secondary, + colorScheme.tertiary, + ]; + + Color colorFor(int idx, String name) => + RequestCategoryChart._catColors[name] ?? + fallbackColors[idx % fallbackColors.length]; + + return ReportCardWrapper( + title: 'Request Category Distribution', + repaintBoundaryKey: widget.repaintKey, + height: 220, + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: + (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = pieTouchResponse + .touchedSection! + .touchedSectionIndex; + }); + }, + ), + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return PieChartSectionData( + value: e.count.toDouble(), + title: '', + radius: isTouched ? 60 : 50, + color: colorFor(i, e.name), + borderSide: isTouched + ? const BorderSide(color: Colors.white, width: 2) + : BorderSide.none, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return _HoverLegendItem( + color: colorFor(i, e.name), + label: e.name, + value: + '${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)', + isTouched: isTouched, + ); + }).toList(), + ), + ], + ), + ); + } +} + +// Shared helpers + +class _HoverLegendItem extends StatelessWidget { + const _HoverLegendItem({ + required this.color, + required this.label, + required this.value, + this.isTouched = false, + }); + final Color color; + final String label; + final String value; + final bool isTouched; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isTouched ? 14 : 10, + height: isTouched ? 14 : 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: (text.bodySmall ?? const TextStyle()).copyWith( + fontWeight: isTouched ? FontWeight.bold : FontWeight.normal, + ), + child: Text(label), + ), + const SizedBox(width: 4), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: (text.labelSmall ?? const TextStyle()).copyWith( + fontWeight: isTouched ? FontWeight.bold : FontWeight.normal, + ), + child: Text(value), + ), + ], + ), + ); + } +} diff --git a/lib/screens/reports/widgets/staff_workload_chart.dart b/lib/screens/reports/widgets/staff_workload_chart.dart new file mode 100644 index 00000000..0965751a --- /dev/null +++ b/lib/screens/reports/widgets/staff_workload_chart.dart @@ -0,0 +1,192 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Grouped vertical bar chart — assigned vs completed tasks per IT staff member. +class StaffWorkloadChart extends ConsumerWidget { + const StaffWorkloadChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(staffWorkloadReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'IT Staff Workload', + isLoading: true, + height: 300, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'IT Staff Workload', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _build(context, data), + ); + } + + Widget _build(BuildContext context, List data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'IT Staff Workload', + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final colors = Theme.of(context).colorScheme; + final text = Theme.of(context).textTheme; + + final maxY = data.fold( + 0, + (m, e) => [ + m, + e.assignedCount, + e.completedCount, + ].reduce((a, b) => a > b ? a : b), + ); + + return ReportCardWrapper( + title: 'IT Staff Workload', + repaintBoundaryKey: repaintKey, + height: 320, + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _LegendDot(color: colors.primary, label: 'Assigned'), + const SizedBox(width: 16), + _LegendDot(color: colors.tertiary, label: 'Completed'), + ], + ), + const SizedBox(height: 12), + Expanded( + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: maxY * 1.2, + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + getTooltipItem: (group, groupIdx, rod, rodIdx) { + final item = data[group.x]; + final label = rodIdx == 0 ? 'Assigned' : 'Completed'; + return BarTooltipItem( + '${item.staffName}\n$label: ${rod.toY.toInt()}', + text.bodySmall!.copyWith( + color: colors.onInverseSurface, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + getTitlesWidget: (value, meta) => Text( + value.toInt().toString(), + style: text.labelSmall, + ), + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final idx = value.toInt(); + if (idx < 0 || idx >= data.length) { + return const SizedBox.shrink(); + } + final name = data[idx].staffName; + // Show first name only to save space + final short = name.split(' ').first; + return Padding( + padding: const EdgeInsets.only(top: 6), + child: Text( + short.length > 10 + ? '${short.substring(0, 8)}…' + : short, + style: text.labelSmall, + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + drawVerticalLine: false, + horizontalInterval: maxY > 0 ? (maxY / 4).ceilToDouble() : 1, + ), + borderData: FlBorderData(show: false), + barGroups: List.generate(data.length, (i) { + return BarChartGroupData( + x: i, + barsSpace: 4, + barRods: [ + BarChartRodData( + toY: data[i].assignedCount.toDouble(), + width: 14, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: colors.primary, + ), + BarChartRodData( + toY: data[i].completedCount.toDouble(), + width: 14, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + color: colors.tertiary, + ), + ], + ); + }), + ), + ), + ), + ], + ), + ); + } +} + +class _LegendDot extends StatelessWidget { + const _LegendDot({required this.color, required this.label}); + final Color color; + final String label; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 4), + Text(label, style: Theme.of(context).textTheme.bodySmall), + ], + ); + } +} diff --git a/lib/screens/reports/widgets/status_charts.dart b/lib/screens/reports/widgets/status_charts.dart new file mode 100644 index 00000000..07e17aaf --- /dev/null +++ b/lib/screens/reports/widgets/status_charts.dart @@ -0,0 +1,334 @@ +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Donut chart ticket counts per status with hover animation. +class TicketsByStatusChart extends ConsumerStatefulWidget { + const TicketsByStatusChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + ConsumerState createState() => + _TicketsByStatusChartState(); +} + +class _TicketsByStatusChartState extends ConsumerState { + int _touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final asyncData = ref.watch(ticketsByStatusReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Tickets by Status', + isLoading: true, + height: 220, + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Tickets by Status', + error: e.toString(), + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Tickets by Status', + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + final total = data.fold(0, (s, e) => s + e.count); + return ReportCardWrapper( + title: 'Tickets by Status', + repaintBoundaryKey: widget.repaintKey, + height: 220, + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: + (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = pieTouchResponse + .touchedSection! + .touchedSectionIndex; + }); + }, + ), + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return PieChartSectionData( + value: e.count.toDouble(), + title: '', + radius: isTouched ? 60 : 50, + color: _ticketStatusColor(context, e.status), + borderSide: isTouched + ? const BorderSide( + color: Colors.white, + width: 2, + ) + : BorderSide.none, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return _LegendItem( + color: _ticketStatusColor(context, e.status), + label: _capitalize(e.status), + value: + '${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)', + isTouched: isTouched, + ); + }).toList(), + ), + ], + ), + ); + }, + ); + } + + Color _ticketStatusColor(BuildContext context, String status) { + final colors = Theme.of(context).colorScheme; + switch (status) { + case 'pending': + return colors.tertiary; + case 'promoted': + return colors.secondary; + case 'closed': + return colors.primary; + default: + return colors.outlineVariant; + } + } +} + +/// Donut chart task counts per status with hover animation. +class TasksByStatusChart extends ConsumerStatefulWidget { + const TasksByStatusChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + ConsumerState createState() => + _TasksByStatusChartState(); +} + +class _TasksByStatusChartState extends ConsumerState { + int _touchedIndex = -1; + + @override + Widget build(BuildContext context) { + final asyncData = ref.watch(tasksByStatusReportProvider); + + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Tasks by Status', + isLoading: true, + height: 220, + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Tasks by Status', + error: e.toString(), + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) { + if (data.isEmpty) { + return ReportCardWrapper( + title: 'Tasks by Status', + repaintBoundaryKey: widget.repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + final total = data.fold(0, (s, e) => s + e.count); + return ReportCardWrapper( + title: 'Tasks by Status', + repaintBoundaryKey: widget.repaintKey, + height: 220, + child: Row( + children: [ + Expanded( + child: PieChart( + PieChartData( + pieTouchData: PieTouchData( + touchCallback: + (FlTouchEvent event, pieTouchResponse) { + setState(() { + if (!event.isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse.touchedSection == null) { + _touchedIndex = -1; + return; + } + _touchedIndex = pieTouchResponse + .touchedSection! + .touchedSectionIndex; + }); + }, + ), + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return PieChartSectionData( + value: e.count.toDouble(), + title: '', + radius: isTouched ? 60 : 50, + color: _taskStatusColor(context, e.status), + borderSide: isTouched + ? const BorderSide( + color: Colors.white, + width: 2, + ) + : BorderSide.none, + ); + }).toList(), + ), + ), + ), + const SizedBox(width: 12), + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: data.asMap().entries.map((entry) { + final i = entry.key; + final e = entry.value; + final isTouched = i == _touchedIndex; + return _LegendItem( + color: _taskStatusColor(context, e.status), + label: _formatTaskStatus(e.status), + value: + '${e.count} (${(e.count / total * 100).toStringAsFixed(0)}%)', + isTouched: isTouched, + ); + }).toList(), + ), + ], + ), + ); + }, + ); + } + + Color _taskStatusColor(BuildContext context, String status) { + final colors = Theme.of(context).colorScheme; + switch (status) { + case 'queued': + return colors.surfaceContainerHighest; + case 'in_progress': + return colors.secondary; + case 'completed': + return colors.primary; + case 'cancelled': + return colors.error; + default: + return colors.outlineVariant; + } + } + + String _formatTaskStatus(String status) { + switch (status) { + case 'in_progress': + return 'In Progress'; + case 'queued': + return 'Queued'; + case 'completed': + return 'Completed'; + case 'cancelled': + return 'Cancelled'; + default: + return _capitalize(status); + } + } +} + +// Shared helpers + +class _LegendItem extends StatelessWidget { + const _LegendItem({ + required this.color, + required this.label, + required this.value, + this.isTouched = false, + }); + final Color color; + final String label; + final String value; + final bool isTouched; + + @override + Widget build(BuildContext context) { + final text = Theme.of(context).textTheme; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: isTouched ? 14 : 10, + height: isTouched ? 14 : 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle), + ), + const SizedBox(width: 6), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: (text.bodySmall ?? const TextStyle()).copyWith( + fontWeight: isTouched ? FontWeight.bold : FontWeight.normal, + ), + child: Text(label), + ), + const SizedBox(width: 4), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: (text.labelSmall ?? const TextStyle()).copyWith( + fontWeight: isTouched ? FontWeight.bold : FontWeight.normal, + ), + child: Text(value), + ), + ], + ), + ); + } +} + +String _capitalize(String s) => + s.isEmpty ? s : '${s[0].toUpperCase()}${s.substring(1)}'; diff --git a/lib/screens/reports/widgets/top_offices_charts.dart b/lib/screens/reports/widgets/top_offices_charts.dart new file mode 100644 index 00000000..41d0e49b --- /dev/null +++ b/lib/screens/reports/widgets/top_offices_charts.dart @@ -0,0 +1,165 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Horizontal bar chart — top 10 offices by ticket count. +/// Uses custom Flutter widgets so labels sit genuinely inside each bar. +class TopOfficesTicketsChart extends ConsumerWidget { + const TopOfficesTicketsChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(topOfficesTicketsReportProvider); + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Top Offices by Tickets', + isLoading: true, + height: 320, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Top Offices by Tickets', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _HorizontalBarBody( + data: data, + title: 'Top Offices by Tickets', + barColor: Theme.of(context).colorScheme.primary, + repaintKey: repaintKey, + ), + ); + } +} + +/// Horizontal bar chart — top 10 offices by task count. +class TopOfficesTasksChart extends ConsumerWidget { + const TopOfficesTasksChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(topOfficesTasksReportProvider); + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Top Offices by Tasks', + isLoading: true, + height: 320, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Top Offices by Tasks', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _HorizontalBarBody( + data: data, + title: 'Top Offices by Tasks', + barColor: Theme.of(context).colorScheme.secondary, + repaintKey: repaintKey, + ), + ); + } +} + +// ─── Shared horizontal-bar body (pure Flutter widgets) ─── + +class _HorizontalBarBody extends StatelessWidget { + const _HorizontalBarBody({ + required this.data, + required this.title, + required this.barColor, + this.repaintKey, + }); + + final List data; + final String title; + final Color barColor; + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context) { + if (data.isEmpty) { + return ReportCardWrapper( + title: title, + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final maxCount = data.fold(0, (m, e) => e.count > m ? e.count : m); + final height = (data.length * 34.0).clamp(160.0, 420.0); + final onBarColor = barColor.computeLuminance() > 0.5 + ? Colors.black87 + : Colors.white; + + return ReportCardWrapper( + title: title, + repaintBoundaryKey: repaintKey, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: onBarColor, + fontWeight: FontWeight.w600, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: data.map((item) { + final fraction = maxCount > 0 ? item.count / maxCount : 0.0; + final barWidth = (fraction * availableWidth).clamp( + 120.0, + availableWidth, + ); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Tooltip( + message: '${item.name}: ${item.count}', + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: barWidth, + height: 28, + decoration: BoxDecoration( + color: barColor, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(6), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: Text( + item.name, + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Text('${item.count}', style: labelStyle), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ); + } +} diff --git a/lib/screens/reports/widgets/top_subjects_charts.dart b/lib/screens/reports/widgets/top_subjects_charts.dart new file mode 100644 index 00000000..09db4dd5 --- /dev/null +++ b/lib/screens/reports/widgets/top_subjects_charts.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../providers/reports_provider.dart'; +import 'report_card_wrapper.dart'; + +/// Horizontal bar chart — top 10 ticket subjects (pg_trgm clustered). +/// Uses custom Flutter widgets so labels sit genuinely inside each bar. +class TopTicketSubjectsChart extends ConsumerWidget { + const TopTicketSubjectsChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(topTicketSubjectsReportProvider); + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Top Ticket Subjects', + isLoading: true, + height: 320, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Top Ticket Subjects', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _SubjectBarBody( + data: data, + title: 'Top Ticket Subjects', + barColor: Theme.of(context).colorScheme.tertiary, + repaintKey: repaintKey, + ), + ); + } +} + +/// Horizontal bar chart — top 10 task subjects (pg_trgm clustered). +class TopTaskSubjectsChart extends ConsumerWidget { + const TopTaskSubjectsChart({super.key, this.repaintKey}); + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final asyncData = ref.watch(topTaskSubjectsReportProvider); + return asyncData.when( + loading: () => ReportCardWrapper( + title: 'Top Task Subjects', + isLoading: true, + height: 320, + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + error: (e, _) => ReportCardWrapper( + title: 'Top Task Subjects', + error: e.toString(), + repaintBoundaryKey: repaintKey, + child: const SizedBox.shrink(), + ), + data: (data) => _SubjectBarBody( + data: data, + title: 'Top Task Subjects', + barColor: Theme.of(context).colorScheme.secondary, + repaintKey: repaintKey, + ), + ); + } +} + +// ─── Shared horizontal-bar body (pure Flutter widgets) ─── + +class _SubjectBarBody extends StatelessWidget { + const _SubjectBarBody({ + required this.data, + required this.title, + required this.barColor, + this.repaintKey, + }); + + final List data; + final String title; + final Color barColor; + final GlobalKey? repaintKey; + + @override + Widget build(BuildContext context) { + if (data.isEmpty) { + return ReportCardWrapper( + title: title, + repaintBoundaryKey: repaintKey, + child: const SizedBox( + height: 200, + child: Center(child: Text('No data for selected period')), + ), + ); + } + + final maxCount = data.fold(0, (m, e) => e.count > m ? e.count : m); + final height = (data.length * 34.0).clamp(160.0, 420.0); + final onBarColor = barColor.computeLuminance() > 0.5 + ? Colors.black87 + : Colors.white; + + return ReportCardWrapper( + title: title, + repaintBoundaryKey: repaintKey, + height: height, + child: LayoutBuilder( + builder: (context, constraints) { + final availableWidth = constraints.maxWidth; + final labelStyle = Theme.of(context).textTheme.labelSmall?.copyWith( + color: onBarColor, + fontWeight: FontWeight.w600, + ); + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: data.map((item) { + final fraction = maxCount > 0 ? item.count / maxCount : 0.0; + final barWidth = (fraction * availableWidth).clamp( + 120.0, + availableWidth, + ); + final label = _titleCase(item.name); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Tooltip( + message: '$label: ${item.count}', + child: Align( + alignment: Alignment.centerLeft, + child: Container( + width: barWidth, + height: 28, + decoration: BoxDecoration( + color: barColor, + borderRadius: const BorderRadius.horizontal( + right: Radius.circular(6), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + Expanded( + child: Text( + label, + style: labelStyle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 6), + Text('${item.count}', style: labelStyle), + ], + ), + ), + ), + ), + ); + }).toList(), + ); + }, + ), + ); + } +} + +String _titleCase(String s) { + if (s.isEmpty) return s; + return s + .split(' ') + .map((w) { + if (w.isEmpty) return w; + return '${w[0].toUpperCase()}${w.substring(1)}'; + }) + .join(' '); +} diff --git a/pubspec.lock b/pubspec.lock index 9cb155c2..8e3ad6bb 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -305,6 +305,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + equatable: + dependency: transitive + description: + name: equatable + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" + url: "https://pub.dev" + source: hosted + version: "2.0.8" fake_async: dependency: transitive description: @@ -409,6 +417,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "5276944c6ffc975ae796569a826c38a62d2abcf264e26b88fa6f482e107f4237" + url: "https://pub.dev" + source: hosted + version: "0.70.2" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 5567b9e4..6d7bdd45 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -35,6 +35,7 @@ dependencies: shared_preferences: ^2.2.0 uuid: ^4.1.0 skeletonizer: ^2.1.3 + fl_chart: ^0.70.2 dev_dependencies: flutter_test: