diff --git a/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart b/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart new file mode 100644 index 00000000..34f6ccb6 --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/approvals_tab.dart @@ -0,0 +1,388 @@ +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'; + +/// 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 createState() => _ApprovalsTabState(); +} + +class _ApprovalsTabState extends ConsumerState { + /// 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 _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 _onApprove(List 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( + value: _programmerFilter, + hint: const Text('All programmers'), + underline: const SizedBox.shrink(), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All programmers'), + ), + for (final p in programmerProfiles) + DropdownMenuItem( + 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(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), + 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, + }); + + final ProgrammerDaySheet sheet; + final Profile? profile; + final bool showCheckbox; + final bool selected; + final ValueChanged? onToggle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final totalSecs = sheet.approvedSnapshot?.totalSeconds ?? 0; + final taskCount = sheet.approvedSnapshot?.rows.length ?? 0; + 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( + formatDurationHm(Duration(seconds: totalSecs)), + ), + TechChip('$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, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart new file mode 100644 index 00000000..cd0b445e --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart @@ -0,0 +1,85 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../models/programmer_day_sheet.model.dart'; +import '../../../providers/programmer_day_data_provider.dart'; +import '../../../utils/app_time.dart'; +import '../../../utils/programmer_day_rows.dart'; + +/// Builds a [DaySheetSnapshot] for [sheet]. +/// +/// * **Approved** sheets: the stored [ProgrammerDaySheet.approvedSnapshot] is +/// returned directly — no live fetch required. +/// * **All other statuses** (pending / disapproved / draft): live activity data +/// for the sheet's work-date is fetched via [programmerDayDataProvider] and +/// rows are computed with [buildDayRows]. +/// +/// This is the canonical snapshot source for the bulk-approve flow in +/// [ApprovalsTab]. Pass `ref` from a [ConsumerStatefulWidget]'s state. +Future buildSnapshotForSheet( + WidgetRef ref, + ProgrammerDaySheet sheet, +) async { + if (sheet.status == DaySheetStatus.approved && + sheet.approvedSnapshot != null) { + return sheet.approvedSnapshot!; + } + + final data = + await ref.read(programmerDayDataProvider(sheet.workDate).future); + + final rows = buildDayRows( + userId: sheet.programmerId, + day: sheet.workDate, + tasks: data.tasks, + logsByTask: data.logsByTask, + workLogs: data.workLogs, + // Project / profile names are display metadata only — pass empty maps so + // that approval RPCs are not blocked on a separate name lookup. + projectNames: const {}, + profileNames: const {}, + now: AppTime.now(), + ); + + return DaySheetSnapshot.fromRows(rows); +} + +/// Groups [sheets] by [ProgrammerDaySheet.workDate]. +/// +/// Returns a [LinkedHashMap]-backed map whose **keys are in descending date +/// order** (most recent date first). Insertion order within each date group +/// is preserved. +Map> groupSheetsByDate( + List sheets, +) { + final map = >{}; + for (final sheet in sheets) { + map.putIfAbsent(sheet.workDate, () => []).add(sheet); + } + final keys = map.keys.toList()..sort((a, b) => b.compareTo(a)); + return {for (final k in keys) k: map[k]!}; +} + +/// Counts the approval-relevant statuses across [sheets]. +/// +/// * [pending] — submitted by the programmer, awaiting admin review. +/// * [awaiting] — disapproved; programmer needs to justify / resubmit. +/// * [approved] — fully reviewed and approved. +({int pending, int awaiting, int approved}) approvalCounts( + List sheets, +) { + int pending = 0, awaiting = 0, approved = 0; + for (final s in sheets) { + switch (s.status) { + case DaySheetStatus.pending: + pending++; + break; + case DaySheetStatus.disapproved: + awaiting++; + break; + case DaySheetStatus.approved: + approved++; + break; + } + } + return (pending: pending, awaiting: awaiting, approved: approved); +} diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart b/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart new file mode 100644 index 00000000..d6ed2a2f --- /dev/null +++ b/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.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 '../../../theme/app_status_colors.dart'; +import '../../../utils/app_time.dart'; +import '../../../utils/programmer_day_rows.dart'; +import '../../../utils/programmer_task_ledger.dart'; +import '../../../widgets/app_state_view.dart'; +import '../../../widgets/app_status_summary.dart'; +import '../../../widgets/status_pill.dart'; + +/// Programmer-facing tab for managing personal day sheets. +/// +/// Sections: +/// 1. **Today** — live work rows, total duration, sheet status and a +/// "Submit day" button (enabled when the sheet is missing/draft and there +/// is at least one logged row). +/// 2. **Needs your attention** — disapproved sheets waiting for justification. +/// 3. **History** — filterable list of past sheets +/// (All / Pending / Disapproved / Approved); tapping a card opens the +/// [DaySheetDetailScreen]. +class DaySheetsTab extends ConsumerStatefulWidget { + const DaySheetsTab({super.key}); + + @override + ConsumerState createState() => _DaySheetsTabState(); +} + +class _DaySheetsTabState extends ConsumerState { + /// Null → show all history; or a [DaySheetStatus] constant to filter. + String? _historyFilter; + + bool _submitting = false; + + // --------------------------------------------------------------------------- + // Actions + // --------------------------------------------------------------------------- + + Future _submit(DateTime today) async { + setState(() => _submitting = true); + try { + await ref.read(daySheetsControllerProvider).submit(today); + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(const SnackBar(content: Text('Day submitted'))); + } on DaySheetActionException catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.message))); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(e.toString()))); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + // --------------------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------------------- + + StatusKind _kindFor(String status) => switch (status) { + DaySheetStatus.approved => StatusKind.success, + DaySheetStatus.pending => StatusKind.info, + DaySheetStatus.disapproved => StatusKind.warning, + _ => StatusKind.neutral, + }; + + // --------------------------------------------------------------------------- + // Build + // --------------------------------------------------------------------------- + + @override + Widget build(BuildContext context) { + final userId = ref.watch(currentUserIdProvider); + if (userId == null) { + return const Center(child: CircularProgressIndicator()); + } + + final today = ref.watch(todayKeyProvider); + final dayDataAsync = ref.watch(programmerDayDataProvider(today)); + final mySheetsAsync = ref.watch(myDaySheetsProvider); + final profiles = ref.watch(profilesProvider).valueOrNull ?? const []; + final profileNames = {for (final p in profiles) p.id: p.fullName}; + + final mySheets = mySheetsAsync.valueOrNull ?? const []; + + // Today's sheet (if any) — compare UTC date keys. + final todaySheet = + mySheets.where((s) => s.workDate == today).firstOrNull; + + // Build live rows for today. + final todayRows = dayDataAsync.when( + data: (data) => buildDayRows( + userId: userId, + day: today, + tasks: data.tasks, + logsByTask: data.logsByTask, + workLogs: data.workLogs, + projectNames: const {}, + profileNames: profileNames, + now: AppTime.now(), + ), + loading: () => const [], + error: (_, _) => const [], + ); + + final todayTotal = Duration(seconds: totalSeconds(todayRows)); + final todayStatus = todaySheet?.status ?? DaySheetStatus.draft; + final canSubmit = + (todaySheet == null || todayStatus == DaySheetStatus.draft) && + todayRows.isNotEmpty; + + // Attention: disapproved sheets. + final disapproved = + mySheets.where((s) => s.status == DaySheetStatus.disapproved).toList(); + + // History: past sheets (exclude today), optionally filtered by status. + final history = mySheets.where((s) { + if (s.workDate == today) return false; + if (_historyFilter == null) return true; + return s.status == _historyFilter; + }).toList(); + + // Counts for the history filter chips. + final historyAll = mySheets.where((s) => s.workDate != today).toList(); + final countPending = + historyAll.where((s) => s.status == DaySheetStatus.pending).length; + final countDisapproved = + historyAll.where((s) => s.status == DaySheetStatus.disapproved).length; + final countApproved = + historyAll.where((s) => s.status == DaySheetStatus.approved).length; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Today card ───────────────────────────────────────────────────── + _TodayCard( + total: todayTotal, + status: todayStatus, + statusKind: _kindFor(todayStatus), + canSubmit: canSubmit, + submitting: _submitting, + onSubmit: () => _submit(today), + onTap: todaySheet != null + ? () => context.pushNamed( + 'daySheetDetail', + pathParameters: {'id': todaySheet.id}, + ) + : null, + ), + + const SizedBox(height: 20), + + // ── Needs your attention ────────────────────────────────────────── + if (disapproved.isNotEmpty) ...[ + Text( + 'Needs your attention', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + for (final sheet in disapproved) + _SheetHistoryTile( + sheet: sheet, + statusKind: _kindFor(sheet.status), + onTap: () => context.pushNamed( + 'daySheetDetail', + pathParameters: {'id': sheet.id}, + ), + ), + const SizedBox(height: 16), + ], + + // ── History ──────────────────────────────────────────────────────── + Text('History', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + AppStatusSummaryRow( + items: [ + AppStatusSummaryItem( + label: 'All', + count: historyAll.length, + kind: StatusKind.neutral, + selected: _historyFilter == null, + onTap: () => setState(() => _historyFilter = null), + ), + AppStatusSummaryItem( + label: 'Pending', + count: countPending, + kind: StatusKind.info, + selected: _historyFilter == DaySheetStatus.pending, + onTap: () => setState(() => _historyFilter = DaySheetStatus.pending), + ), + AppStatusSummaryItem( + label: 'Disapproved', + count: countDisapproved, + kind: StatusKind.warning, + selected: _historyFilter == DaySheetStatus.disapproved, + onTap: () => + setState(() => _historyFilter = DaySheetStatus.disapproved), + ), + AppStatusSummaryItem( + label: 'Approved', + count: countApproved, + kind: StatusKind.success, + selected: _historyFilter == DaySheetStatus.approved, + onTap: () => + setState(() => _historyFilter = DaySheetStatus.approved), + ), + ], + ), + const SizedBox(height: 8), + if (history.isEmpty) + const AppEmptyView( + icon: Icons.history, + title: 'No sheets yet', + subtitle: 'Submit your first day sheet above.', + ) + else + for (final sheet in history) + _SheetHistoryTile( + sheet: sheet, + statusKind: _kindFor(sheet.status), + onTap: () => context.pushNamed( + 'daySheetDetail', + pathParameters: {'id': sheet.id}, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Today card +// --------------------------------------------------------------------------- + +class _TodayCard extends StatelessWidget { + const _TodayCard({ + required this.total, + required this.status, + required this.statusKind, + required this.canSubmit, + required this.submitting, + required this.onSubmit, + this.onTap, + }); + + final Duration total; + final String status; + final StatusKind statusKind; + final bool canSubmit; + final bool submitting; + final VoidCallback onSubmit; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final tt = Theme.of(context).textTheme; + final cs = Theme.of(context).colorScheme; + + return Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Today', style: tt.titleMedium), + ), + StatusPill.kind( + statusKind, + label: DaySheetStatus.label(status), + ), + ], + ), + const SizedBox(height: 8), + Text( + formatDurationHm(total), + style: tt.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + color: cs.primary, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: FilledButton.tonal( + onPressed: canSubmit && !submitting ? onSubmit : null, + child: submitting + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Submit day'), + ), + ), + ], + ), + ), + ), + ); + } +} + +// --------------------------------------------------------------------------- +// History tile +// --------------------------------------------------------------------------- + +class _SheetHistoryTile extends StatelessWidget { + const _SheetHistoryTile({ + required this.sheet, + required this.statusKind, + required this.onTap, + }); + + final ProgrammerDaySheet sheet; + final StatusKind statusKind; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + child: ListTile( + title: Text(AppTime.formatDate(sheet.workDate)), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StatusPill.kind( + statusKind, + label: DaySheetStatus.label(sheet.status), + ), + const SizedBox(width: 4), + const Icon(Icons.chevron_right), + ], + ), + onTap: onTap, + ), + ); + } +} diff --git a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart index 1144d3e1..53f4c93c 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_list_screen.dart @@ -10,6 +10,8 @@ import '../../widgets/app_state_view.dart'; import '../../widgets/reconnect_overlay.dart'; import '../../widgets/responsive_body.dart'; import 'dashboard/programmer_dashboard.dart'; +import 'day_sheets/approvals_tab.dart'; +import 'day_sheets/day_sheets_tab.dart'; import 'monitor/monitor_tab.dart'; import 'programmer_task_create.dart'; import 'programmer_tasks_tabs.dart'; @@ -112,6 +114,11 @@ class _ProgrammerTasksListScreenState return const MonitorTab(); } + // Approvals tab — admin bulk-approve day sheets. + if (currentTab == ProgrammerTasksTab.approvals) { + return const ApprovalsTab(); + } + // Mine tab — delegate entirely to ProgrammerDashboard. if (currentTab == ProgrammerTasksTab.mine) { if (userId == null) { @@ -120,6 +127,11 @@ class _ProgrammerTasksListScreenState return ProgrammerDashboard(userId: userId); } + // Day Sheets tab — programmer personal sheet management. + if (currentTab == ProgrammerTasksTab.daySheets) { + return const DaySheetsTab(); + } + // All tab — flat list with error / loading / empty states. if (tasksAsync.hasError && !tasksAsync.hasValue) { return AppErrorView( diff --git a/lib/screens/programmer_tasks/programmer_tasks_tabs.dart b/lib/screens/programmer_tasks/programmer_tasks_tabs.dart index 3bf98e46..13f6c74a 100644 --- a/lib/screens/programmer_tasks/programmer_tasks_tabs.dart +++ b/lib/screens/programmer_tasks/programmer_tasks_tabs.dart @@ -7,16 +7,24 @@ /// approvals and daySheets are deferred to later slices. enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets } -/// Returns the ordered tab list for the current role in this slice. +/// Returns the ordered tab list for the current role. +/// +/// Admin: [monitor, approvals, mine, all] +/// Programmer: [mine, all, daySheets] List programmerTasksTabsFor({required bool isStrictAdmin}) { if (isStrictAdmin) { return [ ProgrammerTasksTab.monitor, + ProgrammerTasksTab.approvals, ProgrammerTasksTab.mine, ProgrammerTasksTab.all, ]; } - return [ProgrammerTasksTab.mine, ProgrammerTasksTab.all]; + return [ + ProgrammerTasksTab.mine, + ProgrammerTasksTab.all, + ProgrammerTasksTab.daySheets, + ]; } /// Parses a URL query-parameter value to a [ProgrammerTasksTab]. diff --git a/test/day_sheet_tabs_test.dart b/test/day_sheet_tabs_test.dart new file mode 100644 index 00000000..a6d200df --- /dev/null +++ b/test/day_sheet_tabs_test.dart @@ -0,0 +1,180 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:tasq/models/programmer_day_sheet.model.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/providers/programmer_day_sheets_provider.dart'; +import 'package:tasq/screens/programmer_tasks/day_sheets/approvals_tab.dart'; +import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_snapshot_builder.dart'; +import 'package:tasq/theme/app_theme.dart'; +import 'package:tasq/utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ProgrammerDaySheet _sheet({ + required String id, + required String status, + DateTime? workDate, + bool autoSubmitted = false, + int resubmissions = 0, +}) { + final now = AppTime.now(); + final date = workDate ?? DateTime.utc(now.year, now.month, 15); + return ProgrammerDaySheet( + id: id, + programmerId: 'prog-1', + status: status, + workDate: date, + autoSubmitted: autoSubmitted, + resubmissions: resubmissions, + createdAt: now, + updatedAt: now, + ); +} + +Widget _buildApp(Widget child, {List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light(), + home: Scaffold(body: child), + ), + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + setUpAll(AppTime.initialize); + + // ── groupSheetsByDate ──────────────────────────────────────────────────── + + group('groupSheetsByDate', () { + test('groups by workDate, keys descending', () { + final d1 = DateTime.utc(2024, 1, 3); + final d2 = DateTime.utc(2024, 1, 2); + final d3 = DateTime.utc(2024, 1, 1); + + final sheets = [ + _sheet(id: 'a', status: DaySheetStatus.pending, workDate: d2), + _sheet(id: 'b', status: DaySheetStatus.approved, workDate: d1), + _sheet(id: 'c', status: DaySheetStatus.draft, workDate: d3), + _sheet(id: 'd', status: DaySheetStatus.pending, workDate: d2), + ]; + + final grouped = groupSheetsByDate(sheets); + final keys = grouped.keys.toList(); + + expect(keys, [d1, d2, d3]); + expect(grouped[d1]!.map((s) => s.id).toList(), ['b']); + expect(grouped[d2]!.map((s) => s.id).toList(), ['a', 'd']); + expect(grouped[d3]!.map((s) => s.id).toList(), ['c']); + }); + + test('single sheet returns single-entry map', () { + final d = DateTime.utc(2024, 6, 1); + final result = groupSheetsByDate([_sheet(id: 'x', status: DaySheetStatus.pending, workDate: d)]); + expect(result.keys.toList(), [d]); + expect(result[d]!.length, 1); + }); + + test('empty list returns empty map', () { + expect(groupSheetsByDate([]), isEmpty); + }); + }); + + // ── approvalCounts ─────────────────────────────────────────────────────── + + group('approvalCounts', () { + test('counts pending, awaiting (disapproved) and approved', () { + final sheets = [ + _sheet(id: '1', status: DaySheetStatus.pending), + _sheet(id: '2', status: DaySheetStatus.pending), + _sheet(id: '3', status: DaySheetStatus.disapproved), + _sheet(id: '4', status: DaySheetStatus.approved), + _sheet(id: '5', status: DaySheetStatus.draft), + ]; + + final counts = approvalCounts(sheets); + expect(counts.pending, 2); + expect(counts.awaiting, 1); + expect(counts.approved, 1); + }); + + test('all zero for empty list', () { + final counts = approvalCounts([]); + expect(counts.pending, 0); + expect(counts.awaiting, 0); + expect(counts.approved, 0); + }); + + test('draft sheets are not counted', () { + final counts = approvalCounts([ + _sheet(id: '1', status: DaySheetStatus.draft), + _sheet(id: '2', status: DaySheetStatus.draft), + ]); + expect(counts.pending, 0); + expect(counts.awaiting, 0); + expect(counts.approved, 0); + }); + }); + + // ── ApprovalsTab widget ────────────────────────────────────────────────── + + group('ApprovalsTab', () { + List _pendingSheets(int n) { + final now = AppTime.now(); + return List.generate( + n, + (i) => _sheet( + id: 'sheet-$i', + status: DaySheetStatus.pending, + workDate: DateTime.utc(now.year, now.month, 15), + ), + ); + } + + testWidgets( + 'selecting 2 of 3 pending cards shows Approve selected (2)', + (tester) async { + final sheets = _pendingSheets(3); + + await tester.pumpWidget( + _buildApp( + const ApprovalsTab(), + overrides: [ + allDaySheetsProvider.overrideWith( + (ref) => Stream.value(sheets), + ), + profilesProvider.overrideWith( + (ref) => Stream.value([]), + ), + isStrictAdminProvider.overrideWith((ref) => true), + currentUserIdProvider.overrideWith((ref) => 'admin-1'), + ], + ), + ); + + // Let the stream settle. + await tester.pump(); + await tester.pump(); + + // All 3 pending cards should be visible with checkboxes. + final checkboxes = find.byType(Checkbox); + expect(checkboxes, findsAtLeastNWidgets(2)); + + // Select two cards. + await tester.tap(checkboxes.at(0)); + await tester.pump(); + await tester.tap(checkboxes.at(1)); + await tester.pump(); + + // The approve button must reflect the selection count. + expect(find.text('Approve selected (2)'), findsOneWidget); + }); + }); +} diff --git a/test/programmer_tabs_test.dart b/test/programmer_tabs_test.dart index 802f9951..07e34384 100644 --- a/test/programmer_tabs_test.dart +++ b/test/programmer_tabs_test.dart @@ -49,18 +49,26 @@ void main() { setUpAll(AppTime.initialize); group('programmerTasksTabsFor', () { - test('tabs in this slice', () { + test('admin gets monitor + approvals + mine + all', () { expect( programmerTasksTabsFor(isStrictAdmin: true), [ ProgrammerTasksTab.monitor, + ProgrammerTasksTab.approvals, ProgrammerTasksTab.mine, ProgrammerTasksTab.all, ], ); + }); + + test('programmer gets mine + all + daySheets', () { expect( programmerTasksTabsFor(isStrictAdmin: false), - [ProgrammerTasksTab.mine, ProgrammerTasksTab.all], + [ + ProgrammerTasksTab.mine, + ProgrammerTasksTab.all, + ProgrammerTasksTab.daySheets, + ], ); }); });