feat(programmer): day sheets + approvals tabs
- day_sheet_snapshot_builder.dart: buildSnapshotForSheet (approved → stored snapshot, else live buildDayRows), groupSheetsByDate (descending keys), approvalCounts (pending/awaiting/approved) - approvals_tab.dart: status filter chips, programmer dropdown, ReportDateFilter.controlled, date-grouped cards with Auto/Early/Round badges, checkbox multi-select, bulk Approve selected (n) flow - day_sheets_tab.dart: Today card (live rows, total, status, Submit day), Needs your attention (disapproved), history with AppStatusSummaryRow All/Pending/Disapproved/Approved filter - programmerTasksTabsFor: admin → [monitor, approvals, mine, all]; programmer → [mine, all, daySheets] - programmer_tasks_list_screen.dart: wire approvals + daySheets cases Tests: 15 passing (day_sheet_tabs_test + programmer_tabs_test) Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -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<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(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<bool?>? 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<DaySheetSnapshot> 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<DateTime, List<ProgrammerDaySheet>> groupSheetsByDate(
|
||||||
|
List<ProgrammerDaySheet> sheets,
|
||||||
|
) {
|
||||||
|
final map = <DateTime, List<ProgrammerDaySheet>>{};
|
||||||
|
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<ProgrammerDaySheet> 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);
|
||||||
|
}
|
||||||
@@ -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<DaySheetsTab> createState() => _DaySheetsTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DaySheetsTabState extends ConsumerState<DaySheetsTab> {
|
||||||
|
/// Null → show all history; or a [DaySheetStatus] constant to filter.
|
||||||
|
String? _historyFilter;
|
||||||
|
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Future<void> _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 <DaySheetRow>[],
|
||||||
|
error: (_, _) => const <DaySheetRow>[],
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import '../../widgets/app_state_view.dart';
|
|||||||
import '../../widgets/reconnect_overlay.dart';
|
import '../../widgets/reconnect_overlay.dart';
|
||||||
import '../../widgets/responsive_body.dart';
|
import '../../widgets/responsive_body.dart';
|
||||||
import 'dashboard/programmer_dashboard.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 'monitor/monitor_tab.dart';
|
||||||
import 'programmer_task_create.dart';
|
import 'programmer_task_create.dart';
|
||||||
import 'programmer_tasks_tabs.dart';
|
import 'programmer_tasks_tabs.dart';
|
||||||
@@ -112,6 +114,11 @@ class _ProgrammerTasksListScreenState
|
|||||||
return const MonitorTab();
|
return const MonitorTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Approvals tab — admin bulk-approve day sheets.
|
||||||
|
if (currentTab == ProgrammerTasksTab.approvals) {
|
||||||
|
return const ApprovalsTab();
|
||||||
|
}
|
||||||
|
|
||||||
// Mine tab — delegate entirely to ProgrammerDashboard.
|
// Mine tab — delegate entirely to ProgrammerDashboard.
|
||||||
if (currentTab == ProgrammerTasksTab.mine) {
|
if (currentTab == ProgrammerTasksTab.mine) {
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
@@ -120,6 +127,11 @@ class _ProgrammerTasksListScreenState
|
|||||||
return ProgrammerDashboard(userId: userId);
|
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.
|
// All tab — flat list with error / loading / empty states.
|
||||||
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
if (tasksAsync.hasError && !tasksAsync.hasValue) {
|
||||||
return AppErrorView(
|
return AppErrorView(
|
||||||
|
|||||||
@@ -7,16 +7,24 @@
|
|||||||
/// approvals and daySheets are deferred to later slices.
|
/// approvals and daySheets are deferred to later slices.
|
||||||
enum ProgrammerTasksTab { monitor, approvals, mine, all, daySheets }
|
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<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}) {
|
List<ProgrammerTasksTab> programmerTasksTabsFor({required bool isStrictAdmin}) {
|
||||||
if (isStrictAdmin) {
|
if (isStrictAdmin) {
|
||||||
return [
|
return [
|
||||||
ProgrammerTasksTab.monitor,
|
ProgrammerTasksTab.monitor,
|
||||||
|
ProgrammerTasksTab.approvals,
|
||||||
ProgrammerTasksTab.mine,
|
ProgrammerTasksTab.mine,
|
||||||
ProgrammerTasksTab.all,
|
ProgrammerTasksTab.all,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [ProgrammerTasksTab.mine, ProgrammerTasksTab.all];
|
return [
|
||||||
|
ProgrammerTasksTab.mine,
|
||||||
|
ProgrammerTasksTab.all,
|
||||||
|
ProgrammerTasksTab.daySheets,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses a URL query-parameter value to a [ProgrammerTasksTab].
|
/// Parses a URL query-parameter value to a [ProgrammerTasksTab].
|
||||||
|
|||||||
@@ -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<Override> 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<ProgrammerDaySheet> _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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,18 +49,26 @@ void main() {
|
|||||||
setUpAll(AppTime.initialize);
|
setUpAll(AppTime.initialize);
|
||||||
|
|
||||||
group('programmerTasksTabsFor', () {
|
group('programmerTasksTabsFor', () {
|
||||||
test('tabs in this slice', () {
|
test('admin gets monitor + approvals + mine + all', () {
|
||||||
expect(
|
expect(
|
||||||
programmerTasksTabsFor(isStrictAdmin: true),
|
programmerTasksTabsFor(isStrictAdmin: true),
|
||||||
[
|
[
|
||||||
ProgrammerTasksTab.monitor,
|
ProgrammerTasksTab.monitor,
|
||||||
|
ProgrammerTasksTab.approvals,
|
||||||
ProgrammerTasksTab.mine,
|
ProgrammerTasksTab.mine,
|
||||||
ProgrammerTasksTab.all,
|
ProgrammerTasksTab.all,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('programmer gets mine + all + daySheets', () {
|
||||||
expect(
|
expect(
|
||||||
programmerTasksTabsFor(isStrictAdmin: false),
|
programmerTasksTabsFor(isStrictAdmin: false),
|
||||||
[ProgrammerTasksTab.mine, ProgrammerTasksTab.all],
|
[
|
||||||
|
ProgrammerTasksTab.mine,
|
||||||
|
ProgrammerTasksTab.all,
|
||||||
|
ProgrammerTasksTab.daySheets,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user