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