feat(programmer): day sheet detail screen
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -30,6 +30,7 @@ import '../widgets/app_shell.dart';
|
||||
import '../screens/teams/teams_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_requests_list_screen.dart';
|
||||
import '../screens/it_service_requests/it_service_request_detail_screen.dart';
|
||||
import '../screens/programmer_tasks/day_sheets/day_sheet_detail_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_drill_down_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_tasks_list_screen.dart';
|
||||
import '../screens/programmer_tasks/programmer_task_detail_screen.dart';
|
||||
@@ -237,6 +238,16 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
name: 'daySheetDetail',
|
||||
path: 'day-sheets/:id',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
key: state.pageKey,
|
||||
child: DaySheetDetailScreen(
|
||||
sheetId: state.pathParameters['id'] ?? '',
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: ':id',
|
||||
pageBuilder: (context, state) => M3ContainerTransformPage(
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../models/programmer_day_sheet_event.model.dart';
|
||||
import '../../../models/programmer_task.model.dart';
|
||||
import '../../../providers/connectivity_provider.dart';
|
||||
import '../../../providers/programmer_day_data_provider.dart';
|
||||
import '../../../providers/programmer_day_sheets_provider.dart';
|
||||
import '../../../providers/programmer_tasks_provider.dart';
|
||||
import '../../../providers/profile_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_section_header.dart';
|
||||
import '../../../widgets/app_state_view.dart';
|
||||
import '../../../widgets/offline_banner.dart';
|
||||
import '../../../widgets/status_pill.dart';
|
||||
import '../widgets/work_log_section.dart';
|
||||
import 'day_sheet_dialogs.dart';
|
||||
import 'day_sheet_rows_table.dart';
|
||||
import 'day_sheet_thread.dart';
|
||||
|
||||
/// The full detail view for a single programmer day sheet.
|
||||
class DaySheetDetailScreen extends ConsumerStatefulWidget {
|
||||
const DaySheetDetailScreen({super.key, required this.sheetId});
|
||||
|
||||
final String sheetId;
|
||||
|
||||
@override
|
||||
ConsumerState<DaySheetDetailScreen> createState() =>
|
||||
_DaySheetDetailScreenState();
|
||||
}
|
||||
|
||||
class _DaySheetDetailScreenState extends ConsumerState<DaySheetDetailScreen> {
|
||||
Set<String> _selectedTaskIds = {};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
StatusKind _kindForStatus(String s) => switch (s) {
|
||||
DaySheetStatus.approved => StatusKind.success,
|
||||
DaySheetStatus.pending => StatusKind.info,
|
||||
DaySheetStatus.disapproved => StatusKind.warning,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
Set<String> _flaggedTaskIdsFromEvents(List<ProgrammerDaySheetEvent> events) {
|
||||
final disapproved = events
|
||||
.where((e) => e.kind == 'disapproved')
|
||||
.toList()
|
||||
..sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
||||
if (disapproved.isEmpty) return const {};
|
||||
return Set<String>.from(disapproved.first.flaggedTaskIds);
|
||||
}
|
||||
|
||||
void _showError(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — Owner
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _onAddCorrection(
|
||||
BuildContext ctx,
|
||||
ProgrammerTask task,
|
||||
String currentUserId,
|
||||
DateTime workDate,
|
||||
) async {
|
||||
await showModalBottomSheet<void>(
|
||||
context: ctx,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) => Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: WorkLogSection(
|
||||
task: task,
|
||||
isAssignee: task.assigneeId == currentUserId,
|
||||
workDate: workDate,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onJustify(String sheetId) async {
|
||||
final body = await showJustifyDialog(context);
|
||||
if (body == null || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(daySheetsControllerProvider)
|
||||
.justify(sheetId: sheetId, body: body);
|
||||
} on DaySheetActionException catch (e) {
|
||||
_showError(e.message);
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actions — Admin
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Future<void> _onDisapprove(
|
||||
String sheetId,
|
||||
List<DaySheetRow> rows,
|
||||
) async {
|
||||
final flaggedRows =
|
||||
rows.where((r) => _selectedTaskIds.contains(r.taskId)).toList();
|
||||
final remarks =
|
||||
await showDisapproveDialog(context, flaggedRows: flaggedRows);
|
||||
if (remarks == null || !mounted) return;
|
||||
try {
|
||||
await ref.read(daySheetsControllerProvider).disapprove(
|
||||
sheetId: sheetId,
|
||||
remarks: remarks,
|
||||
flaggedTaskIds: flaggedRows.map((r) => r.taskId).toList(),
|
||||
);
|
||||
setState(() => _selectedTaskIds = {});
|
||||
} on DaySheetActionException catch (e) {
|
||||
_showError(e.message);
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onApprove(
|
||||
ProgrammerDaySheet sheet,
|
||||
List<DaySheetRow> rows,
|
||||
String programmerName,
|
||||
) async {
|
||||
final today = ref.read(todayKeyProvider);
|
||||
final isToday = sheet.workDate == today;
|
||||
if (isToday) {
|
||||
final runStates = ref.read(programmerRunStatesProvider);
|
||||
final hasRunning = runStates.entries.any((e) =>
|
||||
e.value.state == ProgrammerRunState.running);
|
||||
if (hasRunning) {
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Timer still running'),
|
||||
content: Text(
|
||||
'$programmerName has a timer running. '
|
||||
'Work logged after approval will reopen this sheet. Approve anyway?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('Approve'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm != true || !mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
final snapshot = DaySheetSnapshot.fromRows(rows);
|
||||
try {
|
||||
await ref.read(daySheetsControllerProvider).approve(
|
||||
[(sheetId: sheet.id, snapshot: snapshot)],
|
||||
);
|
||||
setState(() => _selectedTaskIds = {});
|
||||
} on DaySheetActionException catch (e) {
|
||||
_showError(e.message);
|
||||
} catch (e) {
|
||||
_showError(e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sheetAsync = ref.watch(daySheetByIdProvider(widget.sheetId));
|
||||
final eventsAsync = ref.watch(daySheetEventsProvider(widget.sheetId));
|
||||
final profilesAsync = ref.watch(profilesProvider);
|
||||
final currentUserId = ref.watch(currentUserIdProvider);
|
||||
final isAdmin = ref.watch(isStrictAdminProvider);
|
||||
final isOnline = ref.watch(isOnlineProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Day Sheet')),
|
||||
body: OfflineBanner(
|
||||
child: sheetAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) =>
|
||||
AppErrorView(error: e, onRetry: () => ref.invalidate(daySheetByIdProvider(widget.sheetId))),
|
||||
data: (sheet) {
|
||||
if (sheet == null) {
|
||||
return const AppEmptyView(
|
||||
icon: Icons.receipt_long_outlined,
|
||||
title: 'Sheet not found',
|
||||
subtitle: 'This day sheet may have been removed.',
|
||||
);
|
||||
}
|
||||
|
||||
final events = eventsAsync.valueOrNull ?? const [];
|
||||
final profiles = profilesAsync.valueOrNull ?? const [];
|
||||
final profileById = {for (final p in profiles) p.id: p};
|
||||
final profileNames = {for (final p in profiles) p.id: p.fullName};
|
||||
|
||||
final programmerName =
|
||||
profileById[sheet.programmerId]?.fullName ?? 'Programmer';
|
||||
final isOwner = currentUserId == sheet.programmerId;
|
||||
final flaggedIds = _flaggedTaskIdsFromEvents(events);
|
||||
|
||||
// Build live rows from tasks provider.
|
||||
final allTasks =
|
||||
ref.watch(programmerTasksProvider).valueOrNull ?? const [];
|
||||
final sheetTasks = allTasks
|
||||
.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);
|
||||
final rows = rowsForSheet(sheet, liveRows);
|
||||
|
||||
final totalSecs = rows.fold<int>(0, (s, r) => s + r.seconds);
|
||||
final totalDuration = Duration(seconds: totalSecs);
|
||||
|
||||
final canAdminAct = isAdmin &&
|
||||
(sheet.status == DaySheetStatus.pending ||
|
||||
sheet.status == DaySheetStatus.disapproved);
|
||||
final canOwnerJustify = isOwner &&
|
||||
sheet.status == DaySheetStatus.disapproved;
|
||||
final canOwnerCorrect = isOwner &&
|
||||
(sheet.status == DaySheetStatus.disapproved ||
|
||||
(sheet.status == DaySheetStatus.pending &&
|
||||
sheet.workDate.isBefore(ref.read(todayKeyProvider))));
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// ── Offline indicator chip ──────────────────────────────────
|
||||
if (!isOnline)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.wifi_off_rounded,
|
||||
size: 16,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onErrorContainer),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'You are offline. Actions are unavailable.',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── Header ────────────────────────────────────────────────
|
||||
_Header(
|
||||
sheet: sheet,
|
||||
programmerName: programmerName,
|
||||
totalDuration: totalDuration,
|
||||
statusKind: _kindForStatus(sheet.status),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Rows table ────────────────────────────────────────────
|
||||
AppSectionHeader(
|
||||
'Work Entries',
|
||||
trailing: canAdminAct && rows.isNotEmpty
|
||||
? TextButton(
|
||||
onPressed: () {
|
||||
if (_selectedTaskIds.length == rows.length) {
|
||||
setState(() => _selectedTaskIds = {});
|
||||
} else {
|
||||
setState(() => _selectedTaskIds = {
|
||||
for (final r in rows) r.taskId,
|
||||
});
|
||||
}
|
||||
},
|
||||
child: Text(
|
||||
_selectedTaskIds.length == rows.length
|
||||
? 'Deselect all'
|
||||
: 'Select all',
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
DaySheetRowsTable(
|
||||
rows: rows,
|
||||
flaggedTaskIds: sheet.status == DaySheetStatus.approved
|
||||
? const {}
|
||||
: flaggedIds,
|
||||
selectedTaskIds: canAdminAct ? _selectedTaskIds : null,
|
||||
onSelectionChanged: canAdminAct
|
||||
? (ids) => setState(() => _selectedTaskIds = ids)
|
||||
: null,
|
||||
onAddCorrection: canOwnerCorrect
|
||||
? (row) {
|
||||
final task = sheetTasks
|
||||
.where((t) => t.id == row.taskId)
|
||||
.firstOrNull;
|
||||
if (task != null && currentUserId != null) {
|
||||
_onAddCorrection(
|
||||
context, task, currentUserId, sheet.workDate);
|
||||
}
|
||||
}
|
||||
: null,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// ── Admin action bar ─────────────────────────────────────
|
||||
if (canAdminAct) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.thumb_down_alt_outlined),
|
||||
label: const Text('Disapprove'),
|
||||
onPressed: isOnline
|
||||
? () => _onDisapprove(sheet.id, rows)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.check_circle_outline),
|
||||
label: const Text('Approve'),
|
||||
onPressed: isOnline
|
||||
? () => _onApprove(
|
||||
sheet,
|
||||
rows,
|
||||
programmerName,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Owner justify bar ─────────────────────────────────────
|
||||
if (canOwnerJustify) ...[
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
icon: const Icon(Icons.chat_bubble_outline),
|
||||
label: const Text('Justify & Resubmit'),
|
||||
onPressed: isOnline
|
||||
? () => _onJustify(sheet.id)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// ── Event thread ──────────────────────────────────────────
|
||||
DaySheetThread(
|
||||
events: events,
|
||||
profileNames: profileNames,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Header widget
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
const _Header({
|
||||
required this.sheet,
|
||||
required this.programmerName,
|
||||
required this.totalDuration,
|
||||
required this.statusKind,
|
||||
});
|
||||
|
||||
final ProgrammerDaySheet sheet;
|
||||
final String programmerName;
|
||||
final Duration totalDuration;
|
||||
final StatusKind statusKind;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppTime.formatDate(sheet.workDate),
|
||||
style: tt.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
),
|
||||
StatusPill.kind(
|
||||
statusKind,
|
||||
label: DaySheetStatus.label(sheet.status),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.person_outline, size: 16, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
programmerName,
|
||||
style: tt.bodyMedium?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(totalDuration),
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: cs.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (sheet.resubmissions > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Round ${sheet.resubmissions + 1}',
|
||||
style: tt.labelSmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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<DaySheetRow> _buildLiveRowsFrom(
|
||||
ProgrammerDaySheet sheet,
|
||||
List<ProgrammerDaySheetEvent> events,
|
||||
List<ProgrammerTask> 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 [];
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet.model.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
|
||||
/// Pure helper: if the sheet is approved with a snapshot, returns the snapshot
|
||||
/// rows; otherwise returns the live rows.
|
||||
List<DaySheetRow> rowsForSheet(
|
||||
ProgrammerDaySheet sheet,
|
||||
List<DaySheetRow> liveRows,
|
||||
) {
|
||||
if (sheet.status == DaySheetStatus.approved &&
|
||||
sheet.approvedSnapshot != null) {
|
||||
return sheet.approvedSnapshot!.rows;
|
||||
}
|
||||
return liveRows;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// showDisapproveDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shows an AlertDialog collecting disapproval remarks and optionally
|
||||
/// toggling which rows are flagged.
|
||||
///
|
||||
/// Returns the non-empty remarks string on confirm, or `null` on cancel.
|
||||
Future<String?> showDisapproveDialog(
|
||||
BuildContext context, {
|
||||
required List<DaySheetRow> flaggedRows,
|
||||
}) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => _DisapproveDialog(flaggedRows: flaggedRows),
|
||||
);
|
||||
}
|
||||
|
||||
class _DisapproveDialog extends StatefulWidget {
|
||||
const _DisapproveDialog({required this.flaggedRows});
|
||||
|
||||
final List<DaySheetRow> flaggedRows;
|
||||
|
||||
@override
|
||||
State<_DisapproveDialog> createState() => _DisapproveDialogState();
|
||||
}
|
||||
|
||||
class _DisapproveDialogState extends State<_DisapproveDialog> {
|
||||
final _remarksCtrl = TextEditingController();
|
||||
String? _error;
|
||||
late Set<String> _selected;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = {for (final r in widget.flaggedRows) r.taskId};
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remarksCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final remarks = _remarksCtrl.text.trim();
|
||||
if (remarks.isEmpty) {
|
||||
setState(() => _error = 'Remarks are required');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(remarks);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Disapprove'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _remarksCtrl,
|
||||
maxLines: 3,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Remarks',
|
||||
hintText: 'Explain what needs to change',
|
||||
errorText: _error,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
),
|
||||
if (widget.flaggedRows.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Flagged tasks',
|
||||
style: tt.labelMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
for (final row in widget.flaggedRows)
|
||||
CheckboxListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(row.title, style: tt.bodySmall),
|
||||
value: _selected.contains(row.taskId),
|
||||
onChanged: (v) {
|
||||
setState(() {
|
||||
if (v == true) {
|
||||
_selected.add(row.taskId);
|
||||
} else {
|
||||
_selected.remove(row.taskId);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Disapprove'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// showJustifyDialog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Shows an AlertDialog for the programmer to write a justification before
|
||||
/// resubmitting.
|
||||
///
|
||||
/// Returns the non-empty justification string on confirm, or `null` on cancel.
|
||||
Future<String?> showJustifyDialog(BuildContext context) {
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (ctx) => const _JustifyDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _JustifyDialog extends StatefulWidget {
|
||||
const _JustifyDialog();
|
||||
|
||||
@override
|
||||
State<_JustifyDialog> createState() => _JustifyDialogState();
|
||||
}
|
||||
|
||||
class _JustifyDialogState extends State<_JustifyDialog> {
|
||||
final _bodyCtrl = TextEditingController();
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_bodyCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final body = _bodyCtrl.text.trim();
|
||||
if (body.isEmpty) {
|
||||
setState(() => _error = 'Please write a justification');
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pop(body);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Justify & Resubmit'),
|
||||
content: SizedBox(
|
||||
width: 360,
|
||||
child: TextField(
|
||||
controller: _bodyCtrl,
|
||||
maxLines: 4,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Justification',
|
||||
hintText: 'Explain what changed or why the sheet is correct',
|
||||
errorText: _error,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (_) {
|
||||
if (_error != null) setState(() => _error = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(null),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _submit,
|
||||
child: const Text('Submit'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/programmer_day_rows.dart';
|
||||
import '../../../utils/programmer_task_ledger.dart';
|
||||
import '../../../widgets/tech_chip.dart';
|
||||
|
||||
/// Table of day-sheet rows with optional selection checkboxes and a per-row
|
||||
/// "add correction" action icon.
|
||||
class DaySheetRowsTable extends StatelessWidget {
|
||||
const DaySheetRowsTable({
|
||||
super.key,
|
||||
required this.rows,
|
||||
required this.flaggedTaskIds,
|
||||
this.selectedTaskIds,
|
||||
this.onSelectionChanged,
|
||||
this.onAddCorrection,
|
||||
});
|
||||
|
||||
/// The rows to render (assignee + helper tasks for this sheet).
|
||||
final List<DaySheetRow> rows;
|
||||
|
||||
/// Task IDs flagged in the most recent disapproved event. These are
|
||||
/// highlighted while the sheet is not yet approved.
|
||||
final Set<String> flaggedTaskIds;
|
||||
|
||||
/// When non-null, admin selection checkboxes are shown and changes are
|
||||
/// forwarded to this callback.
|
||||
final Set<String>? selectedTaskIds;
|
||||
final ValueChanged<Set<String>>? onSelectionChanged;
|
||||
|
||||
/// When non-null, a correction icon is shown on each row and tapping it
|
||||
/// invokes this callback with the affected row.
|
||||
final ValueChanged<DaySheetRow>? onAddCorrection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
|
||||
if (rows.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
'No rows yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
for (final row in rows)
|
||||
_RowTile(
|
||||
key: ValueKey(row.taskId),
|
||||
row: row,
|
||||
isFlagged: flaggedTaskIds.contains(row.taskId),
|
||||
isSelected: selectedTaskIds?.contains(row.taskId) ?? false,
|
||||
showCheckbox: selectedTaskIds != null,
|
||||
showCorrection: onAddCorrection != null,
|
||||
statusColors: status,
|
||||
onToggle: selectedTaskIds != null && onSelectionChanged != null
|
||||
? (checked) {
|
||||
final next = Set<String>.from(selectedTaskIds!);
|
||||
if (checked) {
|
||||
next.add(row.taskId);
|
||||
} else {
|
||||
next.remove(row.taskId);
|
||||
}
|
||||
onSelectionChanged!(next);
|
||||
}
|
||||
: null,
|
||||
onAddCorrection:
|
||||
onAddCorrection != null ? () => onAddCorrection!(row) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RowTile extends StatelessWidget {
|
||||
const _RowTile({
|
||||
super.key,
|
||||
required this.row,
|
||||
required this.isFlagged,
|
||||
required this.isSelected,
|
||||
required this.showCheckbox,
|
||||
required this.showCorrection,
|
||||
required this.statusColors,
|
||||
this.onToggle,
|
||||
this.onAddCorrection,
|
||||
});
|
||||
|
||||
final DaySheetRow row;
|
||||
final bool isFlagged;
|
||||
final bool isSelected;
|
||||
final bool showCheckbox;
|
||||
final bool showCorrection;
|
||||
final AppStatusColors statusColors;
|
||||
final ValueChanged<bool>? onToggle;
|
||||
final VoidCallback? onAddCorrection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final flaggedColor = statusColors.warning.container;
|
||||
final bgColor = isFlagged ? flaggedColor : Colors.transparent;
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
color: bgColor,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCheckbox)
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: onToggle != null ? (v) => onToggle!(v ?? false) : null,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
row.title,
|
||||
style: tt.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
formatDurationHm(Duration(seconds: row.seconds)),
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
TechChip(row.category, dense: true),
|
||||
if (row.kind == 'helper')
|
||||
TechChip(
|
||||
'Helper${row.helpedName != null ? ' → ${row.helpedName}' : ''}',
|
||||
dense: true,
|
||||
status: StatusKind.info,
|
||||
),
|
||||
if (row.projectName != null)
|
||||
TechChip(row.projectName!, dense: true),
|
||||
if (isFlagged)
|
||||
TechChip(
|
||||
'Flagged',
|
||||
dense: true,
|
||||
status: StatusKind.warning,
|
||||
icon: Icons.flag_outlined,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (row.notes.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
for (final note in row.notes)
|
||||
Text(
|
||||
'• $note',
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showCorrection)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add_comment_outlined, size: 18),
|
||||
tooltip: 'Add correction',
|
||||
onPressed: onAddCorrection,
|
||||
padding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../models/programmer_day_sheet_event.model.dart';
|
||||
import '../../../theme/app_status_colors.dart';
|
||||
import '../../../utils/app_time.dart';
|
||||
import '../../../widgets/app_section_header.dart';
|
||||
|
||||
/// Maps an event kind to a human-readable label.
|
||||
String _threadLabel(String kind) => switch (kind) {
|
||||
'submitted' => 'Submitted',
|
||||
'auto_submitted' => 'Auto-submitted at end of day',
|
||||
'disapproved' => 'Disapproved',
|
||||
'justified' => 'Justified',
|
||||
'approved' => 'Approved',
|
||||
'amended' => 'Reopened — new work after approval',
|
||||
_ => kind,
|
||||
};
|
||||
|
||||
IconData _threadIcon(String kind) => switch (kind) {
|
||||
'submitted' => Icons.send_outlined,
|
||||
'auto_submitted' => Icons.schedule_send_outlined,
|
||||
'disapproved' => Icons.thumb_down_alt_outlined,
|
||||
'justified' => Icons.chat_bubble_outline,
|
||||
'approved' => Icons.check_circle_outline,
|
||||
'amended' => Icons.redo_outlined,
|
||||
_ => Icons.circle_outlined,
|
||||
};
|
||||
|
||||
StatusKind _threadKind(String kind) => switch (kind) {
|
||||
'approved' => StatusKind.success,
|
||||
'disapproved' => StatusKind.warning,
|
||||
'justified' => StatusKind.info,
|
||||
_ => StatusKind.neutral,
|
||||
};
|
||||
|
||||
/// Renders day-sheet events as a vertical timeline, oldest first.
|
||||
class DaySheetThread extends StatelessWidget {
|
||||
const DaySheetThread({
|
||||
super.key,
|
||||
required this.events,
|
||||
required this.profileNames,
|
||||
});
|
||||
|
||||
final List<ProgrammerDaySheetEvent> events;
|
||||
|
||||
/// Map of userId → display name used to show actor names.
|
||||
final Map<String, String> profileNames;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const AppSectionHeader('Activity'),
|
||||
if (events.isEmpty)
|
||||
Text(
|
||||
'No activity yet.',
|
||||
style: tt.bodySmall?.copyWith(color: cs.onSurfaceVariant),
|
||||
)
|
||||
else
|
||||
for (final event in events)
|
||||
_ThreadRow(
|
||||
event: event,
|
||||
actorName: event.actorId != null
|
||||
? profileNames[event.actorId] ?? 'Someone'
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThreadRow extends StatelessWidget {
|
||||
const _ThreadRow({required this.event, this.actorName});
|
||||
|
||||
final ProgrammerDaySheetEvent event;
|
||||
final String? actorName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tt = Theme.of(context).textTheme;
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final status = AppStatusColors.of(context);
|
||||
final kind = _threadKind(event.kind);
|
||||
final swatch = status.byKind(kind);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: swatch.container,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
_threadIcon(event.kind),
|
||||
size: 14,
|
||||
color: swatch.onContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
actorName != null
|
||||
? '$actorName · ${_threadLabel(event.kind)}'
|
||||
: _threadLabel(event.kind),
|
||||
style: tt.bodyMedium,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
AppTime.relative(event.createdAt),
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (event.body != null && event.body!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.body!,
|
||||
style: tt.bodySmall
|
||||
?.copyWith(color: cs.onSurfaceVariant),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
if (event.flaggedTaskIds.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Flagged: ${event.flaggedTaskIds.length} task(s)',
|
||||
style: tt.bodySmall?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:tasq/models/programmer_day_sheet.model.dart';
|
||||
import 'package:tasq/screens/programmer_tasks/day_sheets/day_sheet_dialogs.dart';
|
||||
import 'package:tasq/utils/programmer_day_rows.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Widget _wrap(Widget child) {
|
||||
return MaterialApp(home: Scaffold(body: child));
|
||||
}
|
||||
|
||||
// Minimal tappable button that opens the dialog under test.
|
||||
class _DisapproveOpener extends StatelessWidget {
|
||||
const _DisapproveOpener({required this.rows, this.result});
|
||||
|
||||
final List<DaySheetRow> rows;
|
||||
final ValueNotifier<String?>? result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final r = await showDisapproveDialog(context, flaggedRows: rows);
|
||||
result?.value = r;
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _JustifyOpener extends StatelessWidget {
|
||||
const _JustifyOpener({this.result});
|
||||
|
||||
final ValueNotifier<String?>? result;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ElevatedButton(
|
||||
onPressed: () async {
|
||||
final r = await showJustifyDialog(context);
|
||||
result?.value = r;
|
||||
},
|
||||
child: const Text('Open'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaySheetRow fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
DaySheetRow _row(String taskId) => DaySheetRow(
|
||||
taskId: taskId,
|
||||
title: 'Task $taskId',
|
||||
category: 'Software Development',
|
||||
kind: 'assignee',
|
||||
seconds: 3600,
|
||||
notes: const [],
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
void main() {
|
||||
group('showDisapproveDialog', () {
|
||||
testWidgets('stays open and shows error when remarks are empty',
|
||||
(tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_DisapproveOpener(
|
||||
rows: [_row('t1')],
|
||||
result: result,
|
||||
)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Dialog should be visible.
|
||||
expect(find.text('Disapprove'), findsWidgets);
|
||||
|
||||
// Tap the Disapprove action button with empty remarks.
|
||||
final disapproveButtons = find.text('Disapprove');
|
||||
await tester.tap(disapproveButtons.last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Error message should appear and dialog should still be open.
|
||||
expect(find.text('Remarks are required'), findsOneWidget);
|
||||
expect(find.text('Disapprove'), findsWidgets);
|
||||
expect(result.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('returns the entered remarks when non-empty', (tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_DisapproveOpener(
|
||||
rows: [_row('t1')],
|
||||
result: result,
|
||||
)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(
|
||||
find.byType(TextField).first, 'Missing notes');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final disapproveButtons = find.text('Disapprove');
|
||||
await tester.tap(disapproveButtons.last);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result.value, 'Missing notes');
|
||||
});
|
||||
});
|
||||
|
||||
group('showJustifyDialog', () {
|
||||
testWidgets('stays open and shows error when justification is empty',
|
||||
(tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// Tap submit with no text.
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Please write a justification'), findsOneWidget);
|
||||
expect(result.value, isNull);
|
||||
});
|
||||
|
||||
testWidgets('returns body when non-empty', (tester) async {
|
||||
final result = ValueNotifier<String?>(null);
|
||||
await tester.pumpWidget(_wrap(_JustifyOpener(result: result)));
|
||||
|
||||
await tester.tap(find.text('Open'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).first, 'My justification');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Submit'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(result.value, 'My justification');
|
||||
});
|
||||
});
|
||||
|
||||
group('rowsForSheet', () {
|
||||
test('returns snapshot rows when approved and snapshot exists', () {
|
||||
final snapshotRow = _row('snap-1');
|
||||
final snapshot = DaySheetSnapshot(
|
||||
totalSeconds: 3600,
|
||||
rows: [snapshotRow],
|
||||
);
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-1',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.approved,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
approvedSnapshot: snapshot,
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [snapshotRow]);
|
||||
});
|
||||
|
||||
test('returns live rows when not approved', () {
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-2',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.pending,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [liveRow]);
|
||||
});
|
||||
|
||||
test('returns live rows when approved but snapshot is null', () {
|
||||
final sheet = ProgrammerDaySheet(
|
||||
id: 'sheet-3',
|
||||
programmerId: 'user-1',
|
||||
status: DaySheetStatus.approved,
|
||||
workDate: DateTime.utc(2026, 9, 27),
|
||||
approvedSnapshot: null,
|
||||
createdAt: DateTime.utc(2026, 9, 27),
|
||||
updatedAt: DateTime.utc(2026, 9, 27),
|
||||
);
|
||||
final liveRow = _row('live-1');
|
||||
|
||||
final result = rowsForSheet(sheet, [liveRow]);
|
||||
|
||||
expect(result, [liveRow]);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user