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:
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user