tasq/lib/providers/reports_provider.dart
2026-03-03 07:38:40 +08:00

413 lines
13 KiB
Dart

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<ReportDateRange>((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<Set<ReportWidgetType>>(
(ref) => ReportWidgetType.values.toSet(),
);
// ──────────────────────────────────────────────
// Helper: build RPC params from the current date range
// ──────────────────────────────────────────────
Map<String, dynamic> _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<List<T>> _callRpc<T>(
Ref ref,
String rpcName,
Map<String, dynamic> params,
T Function(Map<String, dynamic>) mapper,
) async {
final client = ref.read(supabaseClientProvider);
final response = await client.rpc(rpcName, params: params);
final list = response as List<dynamic>;
return list.map((e) => mapper(Map<String, dynamic>.from(e as Map))).toList();
}
// --- 1. Tickets by status ---
final ticketsByStatusReportProvider =
FutureProvider.autoDispose<List<StatusCount>>((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<List<StatusCount>>((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<List<HourCount>>((
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<List<HourCount>>(
(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<List<NamedCount>>((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<List<NamedCount>>((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<List<NamedCount>>((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<List<NamedCount>>((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<List<MonthlyOverview>>((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<List<NamedCount>>((
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<List<NamedCount>>((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<List<OfficeResolution>>((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<List<StaffWorkload>>((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<ConversionRate>((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<dynamic>;
if (list.isEmpty) {
return const ConversionRate(
totalTickets: 0,
promotedTickets: 0,
conversionRate: 0,
);
}
final m = Map<String, dynamic>.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;
}