Files
tasq/lib/screens/reports/reports_screen.dart
T
redz1029 3cb980b629 QA hardening: security RLS fixes, Flutter 3.47.5 upgrade, UI/validation fixes
Security — enforce write authorization server-side (was UI/RPC-only):
- it_service_requests RLS: block cross-office read/edit + self-approve (QA-015)
- pass_slips RLS: owner can complete but not self-approve (QA-046)
- swap_requests RLS: scope select/update to participants + admin (QA-047)
- storage: tighten it_service_attachments + task_attachments write/delete (QA-027)
- admin_user_management edge function: allow programmers to manage users (QA-016)

Fixes:
- workforce generator "uncovered shifts" false alarms (QA-043/044)
- network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048)
- de-flake time-of-day-dependent dashboard metrics test (QA-045)

Toolchain:
- upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0,
  flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002)

analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/.

Note: also carries the in-progress Brick model cleanup already present in the
working tree. QA-001 (AI keys public in the build) is deferred by owner decision.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 11:52:16 +08:00

334 lines
11 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../theme/m3_motion.dart';
import '../../providers/reports_provider.dart';
import '../../utils/snackbar.dart';
import '../../widgets/app_page_header.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<ReportsScreen> createState() => _ReportsScreenState();
}
class _ReportsScreenState extends ConsumerState<ReportsScreen> {
// RepaintBoundary keys for PDF capture
final _keys = <ReportWidgetType, GlobalKey>{
for (final t in ReportWidgetType.values) t: GlobalKey(),
};
bool _exporting = false;
Future<void> _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<void>.delayed(const Duration(milliseconds: 200));
final captureKeys = <String, GlobalKey>{};
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) {
showErrorSnackBar(context, 'PDF export failed: $e');
}
} finally {
if (mounted) setState(() => _exporting = false);
}
}
@override
Widget build(BuildContext context) {
final enabled = ref.watch(reportWidgetToggleProvider);
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: [
AppPageHeader(
title: 'Reports',
subtitle: 'Analytics and performance insights',
actions: [
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<ReportWidgetType> 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;
final chartSections = <Widget>[
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],
),
],
),
if (enabled.contains(ReportWidgetType.conversionRate))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: ConversionRateCard(
repaintKey: _keys[ReportWidgetType.conversionRate],
),
),
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],
),
],
),
if (enabled.contains(ReportWidgetType.monthlyOverview))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: MonthlyOverviewChart(
repaintKey: _keys[ReportWidgetType.monthlyOverview],
),
),
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],
),
],
),
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],
),
],
),
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],
),
],
),
if (enabled.contains(ReportWidgetType.avgResolution))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: AvgResolutionChart(
repaintKey: _keys[ReportWidgetType.avgResolution],
),
),
if (enabled.contains(ReportWidgetType.staffWorkload))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: StaffWorkloadChart(
repaintKey: _keys[ReportWidgetType.staffWorkload],
),
),
];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (int i = 0; i < chartSections.length; i++)
M3FadeSlideIn(
delay: Duration(milliseconds: i.clamp(0, 4) * 80),
child: chartSections[i],
),
],
);
},
);
}
/// Build either a [Row] (desktop, >=600) or a [Column] (mobile) for a pair
/// of chart widgets.
Widget _responsiveRow({
required bool isWide,
required List<Widget> 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<ReportWidgetType> enabled,
List<ReportWidgetType> types,
) {
return types.any(enabled.contains);
}
}