4f60e23003
approvedSnapshot is only populated at approval time; pending and disapproved sheets had null snapshot so the card always displayed 0m · 0 tasks. Now totalSecs/taskCount are passed as nullable params from the parent; the card renders — when no snapshot is available yet. Also accept camelCase 'daySheets' URL param in parseProgrammerTasksTab alongside the existing hyphenated 'day-sheets' spelling. Co-Authored-By: claude-flow <ruv@ruv.net>
409 lines
15 KiB
Dart
409 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import '../../../models/profile.model.dart';
|
|
import '../../../models/programmer_day_sheet.model.dart';
|
|
import '../../../providers/profile_provider.dart';
|
|
import '../../../providers/programmer_day_sheets_provider.dart';
|
|
import '../../../providers/reports_provider.dart';
|
|
import '../../../theme/app_status_colors.dart';
|
|
import '../../../utils/app_time.dart';
|
|
import '../../../utils/programmer_task_ledger.dart';
|
|
import '../../../widgets/app_state_view.dart';
|
|
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.
|
|
///
|
|
/// Features:
|
|
/// * Status filter chips: Pending (default) / Awaiting justification / Approved.
|
|
/// * Programmer dropdown: All programmers or a specific person.
|
|
/// * Controlled date range filter (default: this month).
|
|
/// * Cards grouped by work-date; pending cards have selection checkboxes.
|
|
/// * "Approve selected (n)" button that calls [buildSnapshotForSheet] for each
|
|
/// selected sheet before calling the bulk-approve RPC.
|
|
class ApprovalsTab extends ConsumerStatefulWidget {
|
|
const ApprovalsTab({super.key});
|
|
|
|
@override
|
|
ConsumerState<ApprovalsTab> createState() => _ApprovalsTabState();
|
|
}
|
|
|
|
class _ApprovalsTabState extends ConsumerState<ApprovalsTab> {
|
|
/// Active status filter — one of the [DaySheetStatus] constants.
|
|
String _filter = DaySheetStatus.pending;
|
|
|
|
/// Selected programmer ID, or null for "all programmers".
|
|
String? _programmerFilter;
|
|
|
|
late ReportDateRange _dateRange;
|
|
|
|
/// IDs of pending sheets selected for bulk approval.
|
|
final Set<String> _selected = {};
|
|
|
|
bool _approving = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_dateRange = thisMonthRange(AppTime.now());
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
bool _inDateRange(DateTime workDate) =>
|
|
!workDate.isBefore(_dateRange.start) && workDate.isBefore(_dateRange.end);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actions
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Future<void> _onApprove(List<ProgrammerDaySheet> allSheets) async {
|
|
setState(() => _approving = true);
|
|
try {
|
|
final toApprove = allSheets
|
|
.where((s) =>
|
|
_selected.contains(s.id) &&
|
|
s.status == DaySheetStatus.pending)
|
|
.toList();
|
|
|
|
final items = <({String sheetId, DaySheetSnapshot snapshot})>[];
|
|
for (final sheet in toApprove) {
|
|
final snapshot = await buildSnapshotForSheet(ref, sheet);
|
|
items.add((sheetId: sheet.id, snapshot: snapshot));
|
|
}
|
|
|
|
final result = await ref.read(daySheetsControllerProvider).approve(items);
|
|
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_selected.clear();
|
|
_approving = false;
|
|
});
|
|
|
|
final buf = StringBuffer('Approved ${result.approvedIds.length}');
|
|
if (result.skippedIds.isNotEmpty) {
|
|
buf.write(', ${result.skippedIds.length} already reviewed');
|
|
}
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(buf.toString())));
|
|
} on DaySheetActionException catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _approving = false);
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(e.message)));
|
|
} catch (e) {
|
|
if (!mounted) return;
|
|
setState(() => _approving = false);
|
|
ScaffoldMessenger.of(context)
|
|
.showSnackBar(SnackBar(content: Text(e.toString())));
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Build
|
|
// ---------------------------------------------------------------------------
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final allSheets = ref.watch(allDaySheetsProvider).valueOrNull ?? const [];
|
|
final profiles = ref.watch(profilesProvider).valueOrNull ?? const [];
|
|
|
|
final profileById = {for (final p in profiles) p.id: p};
|
|
final programmerProfiles =
|
|
profiles.where((p) => p.role == 'programmer').toList()
|
|
..sort((a, b) => a.fullName.compareTo(b.fullName));
|
|
|
|
// Sheets passing the date + programmer filters (used for chip counts).
|
|
final dateAndProgFiltered = allSheets.where((s) {
|
|
return _inDateRange(s.workDate) &&
|
|
(_programmerFilter == null || s.programmerId == _programmerFilter);
|
|
}).toList();
|
|
|
|
final counts = approvalCounts(dateAndProgFiltered);
|
|
|
|
// Sheets also matching the status filter (displayed in the list).
|
|
final filtered =
|
|
dateAndProgFiltered.where((s) => s.status == _filter).toList();
|
|
|
|
final grouped = groupSheetsByDate(filtered);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// ── Status filter chips ────────────────────────────────────────────
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
|
child: AppStatusSummaryRow(
|
|
items: [
|
|
AppStatusSummaryItem(
|
|
label: 'Pending',
|
|
count: counts.pending,
|
|
kind: StatusKind.info,
|
|
selected: _filter == DaySheetStatus.pending,
|
|
onTap: () => setState(() {
|
|
_filter = DaySheetStatus.pending;
|
|
_selected.clear();
|
|
}),
|
|
),
|
|
AppStatusSummaryItem(
|
|
label: 'Awaiting justification',
|
|
count: counts.awaiting,
|
|
kind: StatusKind.warning,
|
|
selected: _filter == DaySheetStatus.disapproved,
|
|
onTap: () => setState(() {
|
|
_filter = DaySheetStatus.disapproved;
|
|
_selected.clear();
|
|
}),
|
|
),
|
|
AppStatusSummaryItem(
|
|
label: 'Approved',
|
|
count: counts.approved,
|
|
kind: StatusKind.success,
|
|
selected: _filter == DaySheetStatus.approved,
|
|
onTap: () => setState(() {
|
|
_filter = DaySheetStatus.approved;
|
|
_selected.clear();
|
|
}),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// ── Programmer dropdown + date filter ──────────────────────────────
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Row(
|
|
children: [
|
|
DropdownButton<String?>(
|
|
value: _programmerFilter,
|
|
hint: const Text('All programmers'),
|
|
underline: const SizedBox.shrink(),
|
|
items: [
|
|
const DropdownMenuItem<String?>(
|
|
value: null,
|
|
child: Text('All programmers'),
|
|
),
|
|
for (final p in programmerProfiles)
|
|
DropdownMenuItem<String?>(
|
|
value: p.id,
|
|
child: Text(p.fullName),
|
|
),
|
|
],
|
|
onChanged: (v) => setState(() {
|
|
_programmerFilter = v;
|
|
_selected.clear();
|
|
}),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: ReportDateFilter.controlled(
|
|
value: _dateRange,
|
|
onChanged: (r) => setState(() {
|
|
_dateRange = r;
|
|
_selected.clear();
|
|
}),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
IconButton(
|
|
icon: const Icon(Icons.description_outlined),
|
|
tooltip: 'Accomplishment Report',
|
|
onPressed: () => showAccomplishmentReportDialog(
|
|
context,
|
|
ref,
|
|
programmerId: _programmerFilter,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
// ── Card list ──────────────────────────────────────────────────────
|
|
Expanded(
|
|
child: filtered.isEmpty
|
|
? AppEmptyView(
|
|
icon: Icons.check_circle_outline,
|
|
title: _filter == DaySheetStatus.pending
|
|
? 'No pending sheets'
|
|
: 'Nothing here',
|
|
subtitle: 'All caught up!',
|
|
)
|
|
: ListView.builder(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
itemCount: grouped.length,
|
|
itemBuilder: (context, groupIdx) {
|
|
final dateKey = grouped.keys.elementAt(groupIdx);
|
|
final groupSheets = grouped[dateKey]!;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8, bottom: 4),
|
|
child: Text(
|
|
AppTime.formatDate(dateKey),
|
|
style: Theme.of(context)
|
|
.textTheme
|
|
.labelMedium
|
|
?.copyWith(
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.onSurfaceVariant,
|
|
),
|
|
),
|
|
),
|
|
for (final sheet in groupSheets)
|
|
_SheetApprovalCard(
|
|
sheet: sheet,
|
|
profile: profileById[sheet.programmerId],
|
|
showCheckbox: _filter == DaySheetStatus.pending,
|
|
selected: _selected.contains(sheet.id),
|
|
totalSecs: sheet.approvedSnapshot?.totalSeconds,
|
|
taskCount: sheet.approvedSnapshot?.rows.length,
|
|
onToggle: _filter == DaySheetStatus.pending
|
|
? (v) => setState(() {
|
|
if (v == true) {
|
|
_selected.add(sheet.id);
|
|
} else {
|
|
_selected.remove(sheet.id);
|
|
}
|
|
})
|
|
: null,
|
|
onTap: () => context.pushNamed(
|
|
'daySheetDetail',
|
|
pathParameters: {'id': sheet.id},
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// ── Approve button ─────────────────────────────────────────────────
|
|
if (_selected.isNotEmpty)
|
|
SafeArea(
|
|
top: false,
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
|
child: FilledButton(
|
|
onPressed: _approving ? null : () => _onApprove(allSheets),
|
|
child: _approving
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: Text('Approve selected (${_selected.length})'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Card widget
|
|
// ---------------------------------------------------------------------------
|
|
|
|
class _SheetApprovalCard extends StatelessWidget {
|
|
const _SheetApprovalCard({
|
|
required this.sheet,
|
|
required this.profile,
|
|
required this.showCheckbox,
|
|
required this.selected,
|
|
required this.onToggle,
|
|
required this.onTap,
|
|
this.totalSecs,
|
|
this.taskCount,
|
|
});
|
|
|
|
final ProgrammerDaySheet sheet;
|
|
final Profile? profile;
|
|
final bool showCheckbox;
|
|
final bool selected;
|
|
final ValueChanged<bool?>? onToggle;
|
|
final VoidCallback onTap;
|
|
/// Null when no approved snapshot exists yet (pending/disapproved sheets).
|
|
final int? totalSecs;
|
|
final int? taskCount;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final roundNum = sheet.resubmissions + 1;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
children: [
|
|
if (showCheckbox && onToggle != null) ...[
|
|
Checkbox(value: selected, onChanged: onToggle),
|
|
const SizedBox(width: 4),
|
|
],
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
profile?.fullName ?? sheet.programmerId,
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Wrap(
|
|
spacing: 6,
|
|
runSpacing: 4,
|
|
children: [
|
|
TechChip(
|
|
totalSecs != null
|
|
? formatDurationHm(Duration(seconds: totalSecs!))
|
|
: '—',
|
|
),
|
|
TechChip(
|
|
taskCount != null ? '$taskCount tasks' : '—',
|
|
),
|
|
if (sheet.autoSubmitted)
|
|
const TechChip('Auto', status: StatusKind.info)
|
|
else
|
|
const TechChip('Early', status: StatusKind.neutral),
|
|
if (roundNum > 1)
|
|
TechChip(
|
|
'Round $roundNum',
|
|
status: StatusKind.warning,
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.chevron_right,
|
|
color: Theme.of(context).colorScheme.onSurfaceVariant,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|