Reports
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user