diff --git a/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart index 3234e716..db9c84b4 100644 --- a/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart +++ b/lib/screens/programmer_tasks/dashboard/programmer_dashboard.dart @@ -7,6 +7,7 @@ import '../../../models/programmer_task.model.dart'; import '../../../providers/leave_provider.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 '../../../utils/app_time.dart'; import '../../../utils/programmer_daily_time.dart'; @@ -43,6 +44,9 @@ class ProgrammerDashboard extends ConsumerWidget { final now = ref.watch(clockTickerProvider).valueOrNull ?? AppTime.now(); + final sheetsAsync = ref.watch(daySheetsForUserProvider(userId)); + final sheets = sheetsAsync.valueOrNull ?? const []; + final tasks = tasksAsync.valueOrNull ?? const []; final profileById = { for (final p in profilesAsync.valueOrNull ?? []) p.id: p, @@ -75,7 +79,7 @@ class ProgrammerDashboard extends ConsumerWidget { userId: userId, now: now, tasks: tasks, - sheets: const [], + sheets: sheets, leaveDays: leaveDays, todayFocus: todayFocus, ); diff --git a/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart b/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart index 317aab8c..a72746c0 100644 --- a/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart +++ b/lib/screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart @@ -143,8 +143,16 @@ class _DaySheetDetailScreenState extends ConsumerState { final today = ref.read(todayKeyProvider); final isToday = sheet.workDate == today; if (isToday) { + // Only warn if THIS programmer has a running task (T20/QN-1 fix). + final allTasks = + ref.read(programmerTasksProvider).valueOrNull ?? const []; + final programmerTaskIds = allTasks + .where((t) => t.assigneeId == sheet.programmerId) + .map((t) => t.id) + .toSet(); final runStates = ref.read(programmerRunStatesProvider); final hasRunning = runStates.entries.any((e) => + programmerTaskIds.contains(e.key) && e.value.state == ProgrammerRunState.running); if (hasRunning) { final confirm = await showDialog( @@ -230,10 +238,26 @@ class _DaySheetDetailScreenState extends ConsumerState { .where((t) => t.assigneeId == sheet.programmerId) .toList(); - // For simplicity, build live rows from any tasks that appear in events - // or we just use empty — the sheet already has snapshot rows if approved. - // Live rows come from an RPC-built dataset; use snapshot rows if approved. - final liveRows = _buildLiveRowsFrom(sheet, events, allTasks); + // For approved sheets use the stored snapshot. For all other + // statuses fetch live row data from the day-activity provider. + final dayDataForSheet = ref + .watch(programmerDayDataProvider(sheet.workDate)) + .valueOrNull; + final liveRows = sheet.status == DaySheetStatus.approved && + sheet.approvedSnapshot != null + ? sheet.approvedSnapshot!.rows + : dayDataForSheet != null + ? buildDayRows( + userId: sheet.programmerId, + day: sheet.workDate, + tasks: allTasks, + logsByTask: dayDataForSheet.logsByTask, + workLogs: dayDataForSheet.workLogs, + projectNames: const {}, + profileNames: profileNames, + now: AppTime.now(), + ) + : const []; final rows = rowsForSheet(sheet, liveRows); final totalSecs = rows.fold(0, (s, r) => s + r.seconds); @@ -475,24 +499,3 @@ class _Header extends StatelessWidget { } } -// --------------------------------------------------------------------------- -// Live rows helper -// --------------------------------------------------------------------------- - -/// Attempt to surface live rows from the existing tasks list. -/// For a fully approved sheet the snapshot rows will be used anyway, -/// so this is only a best-effort fallback. -List _buildLiveRowsFrom( - ProgrammerDaySheet sheet, - List events, - List allTasks, -) { - // If the sheet has an approved snapshot, the caller will use snapshot rows. - if (sheet.approvedSnapshot != null && - sheet.status == DaySheetStatus.approved) { - return sheet.approvedSnapshot!.rows; - } - // Otherwise we don't have the full row data client-side without a separate - // fetch — return empty and let the screen show whatever the snapshot provides. - return const []; -} diff --git a/lib/screens/programmer_tasks/monitor/monitor_tab.dart b/lib/screens/programmer_tasks/monitor/monitor_tab.dart index cd440574..5bc27b70 100644 --- a/lib/screens/programmer_tasks/monitor/monitor_tab.dart +++ b/lib/screens/programmer_tasks/monitor/monitor_tab.dart @@ -2,11 +2,14 @@ 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/leave_provider.dart'; import '../../../providers/pass_slip_provider.dart'; import '../../../providers/profile_provider.dart'; import '../../../providers/programmer_day_data_provider.dart'; +import '../../../providers/programmer_day_sheets_provider.dart'; import '../../../utils/app_time.dart'; +import '../../../utils/programmer_daily_time.dart'; import '../../../utils/staff_presence.dart'; import '../../../widgets/app_status_summary.dart'; import '../../../theme/app_status_colors.dart'; @@ -28,6 +31,7 @@ class MonitorTab extends ConsumerWidget { final runStates = ref.watch(programmerRunStatesProvider); final leavesAsync = ref.watch(leavesProvider); final passSlipsAsync = ref.watch(passSlipsProvider); + final allSheetsAsync = ref.watch(allDaySheetsProvider); final profiles = profilesAsync.valueOrNull ?? []; final programmerProfiles = @@ -41,6 +45,20 @@ class MonitorTab extends ConsumerWidget { final activeLeaves = activeLeaveByUser(leaves, now); final activePassSlips = activePassSlipByUser(passSlips); + // Build sheetStatusByUser for today: programmerId → status. + final allSheets = allSheetsAsync.valueOrNull ?? const []; + final sheetStatusByUser = {}; + var sheetsPending = 0; + for (final sheet in allSheets) { + if (sheet.workDate == manilaDay(now)) { + sheetStatusByUser[sheet.programmerId] = sheet.status; + if (sheet.status == DaySheetStatus.pending || + sheet.status == DaySheetStatus.disapproved) { + sheetsPending++; + } + } + } + if (dayDataAsync.isLoading && !dayDataAsync.hasValue) { return const Center(child: CircularProgressIndicator()); } @@ -55,7 +73,7 @@ class MonitorTab extends ConsumerWidget { runStates: runStates, leaves: activeLeaves, passSlips: activePassSlips, - sheetStatusByUser: const {}, + sheetStatusByUser: sheetStatusByUser, projectNames: const {}, profileNames: profileNames, now: now, @@ -102,6 +120,11 @@ class MonitorTab extends ConsumerWidget { count: away, kind: StatusKind.info, ), + AppStatusSummaryItem( + label: 'Sheets pending', + count: sheetsPending, + kind: StatusKind.warning, + ), ]; return LayoutBuilder( diff --git a/lib/screens/programmer_tasks/programmer_drill_down_screen.dart b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart index 2508ed48..1db11c1f 100644 --- a/lib/screens/programmer_tasks/programmer_drill_down_screen.dart +++ b/lib/screens/programmer_tasks/programmer_drill_down_screen.dart @@ -1,13 +1,20 @@ 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 '../../theme/app_status_colors.dart'; +import '../../utils/app_time.dart'; +import '../../widgets/app_section_header.dart'; import '../../widgets/app_state_view.dart'; +import '../../widgets/status_pill.dart'; import 'dashboard/programmer_dashboard.dart'; /// Admin-only drill-down screen that shows [ProgrammerDashboard] for a -/// specific user in read-only mode. +/// specific user in read-only mode, plus their recent day sheets history. /// /// Route: /programmer-tasks/programmers/:userId class ProgrammerDrillDownScreen extends ConsumerWidget { @@ -46,7 +53,102 @@ class ProgrammerDrillDownScreen extends ConsumerWidget { return Scaffold( appBar: AppBar(title: Text(name)), - body: ProgrammerDashboard(userId: userId, readOnly: true), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Dashboard fills the upper portion of the screen. + Expanded( + child: ProgrammerDashboard(userId: userId, readOnly: true), + ), + const Divider(height: 1), + // Recent day sheets section — fixed-height scrollable panel. + _RecentDaySheetsPanel(userId: userId), + ], + ), + ); + } +} + +// --------------------------------------------------------------------------- +// Recent sheets panel +// --------------------------------------------------------------------------- + +class _RecentDaySheetsPanel extends ConsumerWidget { + const _RecentDaySheetsPanel({required this.userId}); + + final String userId; + + StatusKind _kindFor(String status) => switch (status) { + DaySheetStatus.approved => StatusKind.success, + DaySheetStatus.pending => StatusKind.info, + DaySheetStatus.disapproved => StatusKind.warning, + _ => StatusKind.neutral, + }; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final sheetsAsync = ref.watch(daySheetsForUserProvider(userId)); + final allSheets = sheetsAsync.valueOrNull ?? const []; + // Show the most-recent 10 sheets (provider already orders newest-first). + final recent = allSheets.length > 10 ? allSheets.sublist(0, 10) : allSheets; + + final cs = Theme.of(context).colorScheme; + final tt = Theme.of(context).textTheme; + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 320), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: AppSectionHeader('Recent day sheets'), + ), + if (recent.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'No day sheets yet.', + style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant), + ), + ) + else + Flexible( + child: ListView.builder( + padding: const EdgeInsets.only(bottom: 12), + itemCount: recent.length, + itemBuilder: (context, i) { + final sheet = recent[i]; + return InkWell( + onTap: () => context.pushNamed( + 'daySheetDetail', + pathParameters: {'id': sheet.id}, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + AppTime.formatDate(sheet.workDate), + style: tt.bodyMedium, + ), + ), + StatusPill.kind( + _kindFor(sheet.status), + label: DaySheetStatus.label(sheet.status), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), ); } } diff --git a/test/programmer_drill_down_test.dart b/test/programmer_drill_down_test.dart new file mode 100644 index 00000000..1d81a740 --- /dev/null +++ b/test/programmer_drill_down_test.dart @@ -0,0 +1,128 @@ +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/models/programmer_day_sheet.model.dart'; +import 'package:tasq/providers/leave_provider.dart'; +import 'package:tasq/providers/programmer_day_data_provider.dart'; +import 'package:tasq/providers/programmer_day_sheets_provider.dart'; +import 'package:tasq/providers/programmer_tasks_provider.dart'; +import 'package:tasq/providers/profile_provider.dart'; +import 'package:tasq/screens/programmer_tasks/programmer_drill_down_screen.dart'; +import 'package:tasq/theme/app_theme.dart'; +import 'package:tasq/utils/app_time.dart'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +ProgrammerDaySheet _sheet({ + required String id, + required String status, + required DateTime workDate, +}) { + final now = AppTime.now(); + return ProgrammerDaySheet( + id: id, + programmerId: 'u1', + status: status, + workDate: workDate, + autoSubmitted: false, + resubmissions: 0, + createdAt: now, + updatedAt: now, + ); +} + +Widget _buildApp(String userId, {List overrides = const []}) { + return ProviderScope( + overrides: overrides, + child: MaterialApp( + theme: AppTheme.light(), + home: ProgrammerDrillDownScreen(userId: userId), + ), + ); +} + +List _minimalOverrides({ + List sheets = const [], +}) { + final today = DateTime.utc(2026, 9, 28); + return [ + isStrictAdminProvider.overrideWith((ref) => true), + profilesProvider.overrideWith((ref) => Stream.value([])), + currentProfileProvider.overrideWith((ref) => Stream.value(null)), + programmerTasksProvider.overrideWith((ref) => Stream.value([])), + programmerRunStatesProvider.overrideWith((ref) => const {}), + clockTickerProvider.overrideWith((ref) => Stream.value(today)), + todayKeyProvider.overrideWith((ref) => today), + leavesProvider.overrideWith((ref) => Stream.value([])), + programmerDayDataProvider.overrideWith( + (ref, day) => Future.value( + DayActivityData(day: day, tasks: [], logsByTask: {}, workLogs: []), + ), + ), + daySheetsForUserProvider.overrideWith((ref, userId) => Stream.value(sheets)), + ]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +void main() { + setUpAll(AppTime.initialize); + + group('ProgrammerDrillDownScreen — recent day sheets', () { + testWidgets('shows Recent day sheets header when there are sheets', + (tester) async { + final sheets = [ + _sheet( + id: 's1', + status: DaySheetStatus.pending, + workDate: DateTime.utc(2026, 9, 27), + ), + _sheet( + id: 's2', + status: DaySheetStatus.approved, + workDate: DateTime.utc(2026, 9, 26), + ), + ]; + + await tester.pumpWidget( + _buildApp('u1', overrides: _minimalOverrides(sheets: sheets)), + ); + await tester.pump(); + + // AppSectionHeader renders title in uppercase + expect(find.text('RECENT DAY SHEETS'), findsOneWidget); + }); + + testWidgets('shows both sheet dates', (tester) async { + final d1 = DateTime.utc(2026, 9, 27); + final d2 = DateTime.utc(2026, 9, 26); + final sheets = [ + _sheet(id: 's1', status: DaySheetStatus.pending, workDate: d1), + _sheet(id: 's2', status: DaySheetStatus.approved, workDate: d2), + ]; + + await tester.pumpWidget( + _buildApp('u1', overrides: _minimalOverrides(sheets: sheets)), + ); + await tester.pump(); + + expect(find.text(AppTime.formatDate(d1)), findsOneWidget); + expect(find.text(AppTime.formatDate(d2)), findsOneWidget); + }); + + testWidgets('shows RECENT DAY SHEETS even with empty sheets list', + (tester) async { + await tester.pumpWidget( + _buildApp('u1', overrides: _minimalOverrides()), + ); + await tester.pump(); + + expect(find.text('RECENT DAY SHEETS'), findsOneWidget); + }); + }); +}