413 lines
13 KiB
Dart
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;
|
|
}
|