Files
tasq/lib/screens/programmer_tasks/day_sheets/day_sheets_tab.dart
T
redz1029 54ce528b02 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>
2026-09-28 14:43:52 +08:00

354 lines
12 KiB
Dart

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,
),
);
}
}