From 1101fb0a970c237982f222b78766b078e65d4c47 Mon Sep 17 00:00:00 2001 From: Marc Rejohn Castillano Date: Mon, 28 Sep 2026 19:09:09 +0800 Subject: [PATCH] feat(report): accomplishment report dialog + entry points Add AccomplishmentReportDialog with programmer/admin modes, date-range filter (default this month), and include-unapproved toggle. Wire into DaySheetsTab app-bar action, ApprovalsTab app-bar action, and ProgrammerDrillDownScreen app-bar button. Co-Authored-By: claude-flow --- .../day_sheets/approvals_tab.dart | 11 + .../day_sheets/day_sheets_tab.dart | 11 + .../programmer_drill_down_screen.dart | 16 +- .../report/accomplishment_report_dialog.dart | 282 ++++++++++++++++++ test/accomplishment_report_dialog_test.dart | 170 +++++++++++ 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart create mode 100644 test/accomplishment_report_dialog_test.dart diff --git a/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart b/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart index 34f6ccb6..185e6ad2 100644 --- a/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart +++ b/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart @@ -15,6 +15,7 @@ import '../../../widgets/app_status_summary.dart'; import '../../../widgets/tech_chip.dart'; import '../../reports/report_date_filter.dart'; import 'day_sheet_snapshot_builder.dart'; +import '../report/accomplishment_report_dialog.dart'; /// Admin tab for reviewing and bulk-approving programmer day sheets. /// @@ -212,6 +213,16 @@ class _ApprovalsTabState extends ConsumerState { }), ), ), + const SizedBox(width: 4), + IconButton( + icon: const Icon(Icons.description_outlined), + tooltip: 'Accomplishment Report', + onPressed: () => showAccomplishmentReportDialog( + context, + ref, + programmerId: _programmerFilter, + ), + ), ], ), ), diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart b/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart index d6ed2a2f..0bf2b4e9 100644 --- a/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart +++ b/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart @@ -13,6 +13,7 @@ import '../../../utils/programmer_task_ledger.dart'; import '../../../widgets/app_state_view.dart'; import '../../../widgets/app_status_summary.dart'; import '../../../widgets/status_pill.dart'; +import '../report/accomplishment_report_dialog.dart'; /// Programmer-facing tab for managing personal day sheets. /// @@ -140,6 +141,16 @@ class _DaySheetsTabState extends ConsumerState { return ListView( padding: const EdgeInsets.all(16), children: [ + // ── Report action ────────────────────────────────────────────────── + Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.description_outlined), + tooltip: 'Accomplishment Report', + onPressed: () => showAccomplishmentReportDialog(context, ref), + ), + ), + // ── Today card ───────────────────────────────────────────────────── _TodayCard( total: todayTotal, diff --git a/lib/screens/programmer_tasks/programmer_drill_down_screen.dart b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart index 1db11c1f..f14e1124 100644 --- a/lib/screens/programmer_tasks/programmer_drill_down_screen.dart +++ b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart @@ -12,6 +12,7 @@ import '../../widgets/app_section_header.dart'; import '../../widgets/app_state_view.dart'; import '../../widgets/status_pill.dart'; import 'dashboard/programmer_dashboard.dart'; +import 'report/accomplishment_report_dialog.dart'; /// Admin-only drill-down screen that shows [ProgrammerDashboard] for a /// specific user in read-only mode, plus their recent day sheets history. @@ -52,7 +53,20 @@ class ProgrammerDrillDownScreen extends ConsumerWidget { final name = profile?.fullName ?? 'Programmer'; return Scaffold( - appBar: AppBar(title: Text(name)), + appBar: AppBar( + title: Text(name), + actions: [ + OutlinedButton( + onPressed: () => showAccomplishmentReportDialog( + context, + ref, + programmerId: userId, + ), + child: const Text('Generate Accomplishment Report'), + ), + const SizedBox(width: 8), + ], + ), body: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ diff --git a/lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart b/lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart new file mode 100644 index 00000000..e626f384 --- /dev/null +++ b/lib/screens/programmer_tasks/report/accomplishment_report_dialog.dart @@ -0,0 +1,282 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/profile.model.dart'; +import '../../../models/programmer_day_sheet.model.dart'; +import '../../../providers/profile_provider.dart'; +import '../../../providers/programmer_day_data_provider.dart'; +import '../../../providers/programmer_day_sheets_provider.dart'; +import '../../../providers/programmer_tasks_provider.dart'; +import '../../../providers/reports_provider.dart'; +import '../../../providers/supabase_provider.dart'; +import '../../../utils/app_time.dart'; +import '../../../utils/programmer_day_rows.dart'; +import '../../../widgets/pdf_preview_dialog.dart'; +import '../../reports/report_date_filter.dart'; +import 'accomplishment_report_data.dart'; +import 'accomplishment_report_pdf.dart'; + +/// Load accomplishment report data for [programmer] over [range]. +/// +/// For non-approved sheets when [includeUnapproved] is true, fetches live +/// rows via [fetchDayActivityData] + [buildDayRows]; approved sheets use +/// the stored snapshot. +Future loadAccomplishmentReport( + WidgetRef ref, { + required Profile programmer, + required ReportDateRange range, + required bool includeUnapproved, +}) async { + final sheets = + ref.read(daySheetsForUserProvider(programmer.id)).valueOrNull ?? []; + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final profileNames = {for (final p in profiles) p.id: p.fullName}; + final tasks = ref.read(programmerTasksProvider).valueOrNull ?? []; + final client = ref.read(supabaseClientProvider); + + final liveRowsByDay = >{}; + if (includeUnapproved) { + for (final sheet in sheets) { + if (!sheet.workDate.isBefore(range.start) && + sheet.workDate.isBefore(range.end) && + sheet.status != DaySheetStatus.approved) { + final data = await fetchDayActivityData( + client, + day: sheet.workDate, + tasks: tasks, + now: AppTime.now(), + ); + final rows = buildDayRows( + userId: programmer.id, + day: sheet.workDate, + tasks: data.tasks, + logsByTask: data.logsByTask, + workLogs: data.workLogs, + projectNames: const {}, + profileNames: profileNames, + now: AppTime.now(), + ); + liveRowsByDay[sheet.workDate] = rows; + } + } + } + + return buildAccomplishmentReportData( + programmer: programmer, + range: range, + includeUnapproved: includeUnapproved, + sheets: sheets, + liveRowsByDay: liveRowsByDay, + tasks: tasks, + profileNames: profileNames, + ); +} + +/// Show the Accomplishment Report generate dialog. +/// +/// - [programmerId] non-null: admin mode locked to that programmer. +/// - [programmerId] null + isStrictAdmin: admin picks from all programmers. +/// - [programmerId] null + not admin: programmer self-service (own profile). +Future showAccomplishmentReportDialog( + BuildContext context, + WidgetRef ref, { + String? programmerId, +}) { + return showDialog( + context: context, + builder: (_) => _AccomplishmentReportDialog(programmerId: programmerId), + ); +} + +// --------------------------------------------------------------------------- +// Dialog widget +// --------------------------------------------------------------------------- + +class _AccomplishmentReportDialog extends ConsumerStatefulWidget { + const _AccomplishmentReportDialog({this.programmerId}); + + final String? programmerId; + + @override + ConsumerState<_AccomplishmentReportDialog> createState() => + _AccomplishmentReportDialogState(); +} + +class _AccomplishmentReportDialogState + extends ConsumerState<_AccomplishmentReportDialog> { + Profile? _selectedProgrammer; + late ReportDateRange _dateRange; + bool _includeUnapproved = false; + bool _generating = false; + + @override + void initState() { + super.initState(); + _dateRange = thisMonthRange(AppTime.now()); + } + + Future _onGenerate() async { + final profiles = ref.read(profilesProvider).valueOrNull ?? []; + final isStrictAdmin = ref.read(isStrictAdminProvider); + final isAdminMode = widget.programmerId != null || isStrictAdmin; + + Profile? programmer; + if (widget.programmerId != null) { + for (final p in profiles) { + if (p.id == widget.programmerId) { + programmer = p; + break; + } + } + } else if (isAdminMode) { + programmer = _selectedProgrammer; + } else { + final userId = ref.read(currentUserIdProvider); + for (final p in profiles) { + if (p.id == userId) { + programmer = p; + break; + } + } + } + + if (programmer == null) return; + + setState(() => _generating = true); + try { + final data = await loadAccomplishmentReport( + ref, + programmer: programmer, + range: _dateRange, + includeUnapproved: _includeUnapproved, + ); + if (!mounted) return; + final name = data.programmerName; + Navigator.of(context).pop(); + await showPdfPreviewDialog( + context, + title: 'Accomplishment Report – $name', + filename: 'Accomplishment Report - $name.pdf', + buildBytes: () => buildAccomplishmentReportPdf(data), + ); + } catch (e) { + if (!mounted) return; + setState(() => _generating = false); + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } + } + + @override + Widget build(BuildContext context) { + final profiles = ref.watch(profilesProvider).valueOrNull ?? []; + final isStrictAdmin = ref.watch(isStrictAdminProvider); + final isAdminMode = widget.programmerId != null || isStrictAdmin; + + final programmerProfiles = + profiles.where((p) => p.role == 'programmer').toList() + ..sort((a, b) => a.fullName.compareTo(b.fullName)); + + // Generate is disabled when in admin-picker mode and nothing chosen yet. + final canGenerate = !_generating && + (isAdminMode && widget.programmerId == null + ? _selectedProgrammer != null + : true); + + return AlertDialog( + title: const Text('Accomplishment Report'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Programmer picker — only in admin-picker mode. + if (isAdminMode && widget.programmerId == null) ...[ + _ProgrammerPicker( + programmers: programmerProfiles, + value: _selectedProgrammer, + onChanged: (p) => setState(() => _selectedProgrammer = p), + ), + const SizedBox(height: 12), + ], + // Date range filter. + ReportDateFilter.controlled( + value: _dateRange, + onChanged: (r) => setState(() => _dateRange = r), + ), + const SizedBox(height: 12), + // Include-unapproved toggle. + SwitchListTile( + title: const Text('Include unapproved days (marked)'), + value: _includeUnapproved, + onChanged: (v) => setState(() => _includeUnapproved = v), + contentPadding: EdgeInsets.zero, + dense: true, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: _generating ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: canGenerate ? _onGenerate : null, + child: _generating + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Generate'), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Programmer picker widget +// --------------------------------------------------------------------------- + +class _ProgrammerPicker extends StatelessWidget { + const _ProgrammerPicker({ + required this.programmers, + required this.value, + required this.onChanged, + }); + + final List programmers; + final Profile? value; + final ValueChanged onChanged; + + @override + Widget build(BuildContext context) { + return InputDecorator( + decoration: const InputDecoration( + labelText: 'Programmer', + border: OutlineInputBorder(), + isDense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + hint: const Text('Select programmer'), + isExpanded: true, + items: programmers + .map( + (p) => DropdownMenuItem( + value: p, + child: Text(p.fullName), + ), + ) + .toList(), + onChanged: onChanged, + ), + ), + ); + } +} diff --git a/test/accomplishment_report_dialog_test.dart b/test/accomplishment_report_dialog_test.dart new file mode 100644 index 00000000..2d3e5ed7 --- /dev/null +++ b/test/accomplishment_report_dialog_test.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:tasq/models/profile.model.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/screens/programmer_tasks/report/accomplishment_report_dialog.dart'; +import 'package:tasq/theme/app_theme.dart'; +import 'package:tasq/utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +Widget _buildApp(Widget child, {List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), + ), + ); +} + +/// Opener widget that shows the dialog on tap. +class _DialogOpener extends ConsumerWidget { + const _DialogOpener({this.programmerId}); + + final String? programmerId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return ElevatedButton( + onPressed: () => showAccomplishmentReportDialog( + context, + ref, + programmerId: programmerId, + ), + child: const Text('Open'), + ); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + setUpAll(AppTime.initialize); + + // ── Programmer mode (not admin, no programmerId) ───────────────────────── + + group('as programmer (not admin)', () { + late List overrides; + + setUp(() { + overrides = [ + isStrictAdminProvider.overrideWithValue(false), + profilesProvider.overrideWith( + (ref) => Stream.value([ + Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice'), + ]), + ), + currentUserIdProvider.overrideWithValue('prog-1'), + ]; + }); + + testWidgets('shows Accomplishment Report title', (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Accomplishment Report'), findsOneWidget); + }); + + testWidgets('does not show programmer picker', (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsNothing); + }); + + testWidgets('toggle Include unapproved days is present and off by default', + (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Include unapproved days (marked)'), findsOneWidget); + final sw = tester.widget(find.byType(Switch).first); + expect(sw.value, isFalse); + }); + + testWidgets('date filter defaults to This Month', (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('This Month'), findsOneWidget); + }); + }); + + // ── Admin picker mode (isStrictAdmin, no programmerId passed) ──────────── + + group('as admin (no programmerId, strict admin)', () { + final prog1 = Profile(id: 'prog-1', role: 'programmer', fullName: 'Alice'); + final prog2 = Profile(id: 'prog-2', role: 'programmer', fullName: 'Bob'); + final admin1 = + Profile(id: 'admin-1', role: 'admin', fullName: 'Admin User'); + + late List overrides; + + setUp(() { + overrides = [ + isStrictAdminProvider.overrideWithValue(true), + profilesProvider.overrideWith( + (ref) => Stream.value([prog1, prog2, admin1]), + ), + currentUserIdProvider.overrideWithValue('admin-1'), + ]; + }); + + testWidgets('shows programmer picker', (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.byType(DropdownButton), findsOneWidget); + }); + + testWidgets('Generate is disabled until a programmer is chosen', + (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + final button = tester.widget( + find.ancestor( + of: find.text('Generate'), + matching: find.byType(FilledButton), + ), + ); + expect(button.onPressed, isNull); + }); + + testWidgets('picker shows only programmer-role profiles', (tester) async { + await tester + .pumpWidget(_buildApp(const _DialogOpener(), overrides: overrides)); + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + // Open the dropdown to expose its items. + await tester.tap(find.byType(DropdownButton)); + await tester.pumpAndSettle(); + + // Programmer names should appear in the dropdown overlay. + expect(find.text('Alice'), findsWidgets); + expect(find.text('Bob'), findsWidgets); + // Admin-role profile must not appear. + expect(find.text('Admin User'), findsNothing); + }); + }); +}