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