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 <ruv@ruv.net>
This commit is contained in:
2026-09-28 19:09:09 +08:00
parent 167e208ffa
commit 1101fb0a97
5 changed files with 489 additions and 1 deletions
@@ -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<ApprovalsTab> {
}),
),
),
const SizedBox(width: 4),
IconButton(
icon: const Icon(Icons.description_outlined),
tooltip: 'Accomplishment Report',
onPressed: () => showAccomplishmentReportDialog(
context,
ref,
programmerId: _programmerFilter,
),
),
],
),
),
@@ -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<DaySheetsTab> {
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,
@@ -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: [
@@ -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<AccomplishmentReportData> 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 = <DateTime, List<DaySheetRow>>{};
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<void> showAccomplishmentReportDialog(
BuildContext context,
WidgetRef ref, {
String? programmerId,
}) {
return showDialog<void>(
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<void> _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<Profile> programmers;
final Profile? value;
final ValueChanged<Profile?> 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<Profile>(
value: value,
hint: const Text('Select programmer'),
isExpanded: true,
items: programmers
.map(
(p) => DropdownMenuItem<Profile>(
value: p,
child: Text(p.fullName),
),
)
.toList(),
onChanged: onChanged,
),
),
);
}
}
+170
View File
@@ -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<Override> 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<Override> 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<Profile>), 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<Switch>(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<Override> 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<Profile>), 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<FilledButton>(
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<Profile>));
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);
});
});
}