398 lines
12 KiB
Dart
398 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../core/formatters.dart';
|
|
import '../core/theme.dart';
|
|
import '../models/timecard.dart';
|
|
import '../state/timecards_controller.dart';
|
|
import '../widgets/common.dart';
|
|
|
|
class TimecardsScreen extends ConsumerWidget {
|
|
const TimecardsScreen({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final cards = ref.watch(timecardsProvider);
|
|
final pending = cards.where((c) => c.status == TimecardStatus.needsReview);
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Your timecards'),
|
|
actions: [
|
|
if (pending.isNotEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 16),
|
|
child: Center(
|
|
child: StatusChip(
|
|
'${pending.length} to review',
|
|
tone: ChipTone.yellow,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 28),
|
|
children: timecardItems(cards),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The timecard list as plain children, so the Clock tab can drop them into
|
|
/// its sliver list and the pushed screen into a ListView, with one layout.
|
|
List<Widget> timecardItems(List<Timecard> cards) {
|
|
if (cards.isEmpty) {
|
|
return const [
|
|
EmptyState(
|
|
icon: LucideIcons.fileText,
|
|
title: 'No timecards yet',
|
|
message: 'One is filed automatically every time you clock out.',
|
|
),
|
|
];
|
|
}
|
|
return [
|
|
for (var i = 0; i < cards.length; i++) ...[
|
|
if (i > 0) const SizedBox(height: 11),
|
|
TimecardTile(card: cards[i]),
|
|
],
|
|
];
|
|
}
|
|
|
|
class TimecardTile extends StatelessWidget {
|
|
const TimecardTile({super.key, required this.card});
|
|
|
|
final Timecard card;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final (label, tone) = switch (card.status) {
|
|
TimecardStatus.needsReview => ('Needs review', ChipTone.yellow),
|
|
TimecardStatus.approved => ('Approved', ChipTone.neutral),
|
|
TimecardStatus.disputed => ('Disputed', ChipTone.danger),
|
|
};
|
|
|
|
return KrowCard(
|
|
onTap: () => context.push('/timecard/${card.id}'),
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
BusinessBadge(card.business[0], size: 40),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
card.role,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.4,
|
|
),
|
|
),
|
|
Text(
|
|
'${card.business} · ${Fmt.dayShort(card.date)}',
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
StatusChip(label, tone: tone),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
const Divider(height: 1),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _MiniStat(
|
|
LucideIcons.calendar,
|
|
'Scheduled',
|
|
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
|
),
|
|
),
|
|
Container(width: 1, height: 34, color: KrowPalette.line),
|
|
Expanded(
|
|
child: _MiniStat(
|
|
LucideIcons.clock,
|
|
'Clocked',
|
|
Fmt.window(card.clockIn, card.clockOut),
|
|
highlight: true,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Filed ${Fmt.time(card.submittedAt)} · '
|
|
'${Fmt.duration(card.worked)}',
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
fontWeight: FontWeight.w600,
|
|
color: Color(0xFF949FA5),
|
|
),
|
|
),
|
|
Text(
|
|
Fmt.money(card.amount),
|
|
style: const TextStyle(
|
|
fontSize: 17,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.6,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MiniStat extends StatelessWidget {
|
|
const _MiniStat(this.icon, this.label, this.value, {this.highlight = false});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final String value;
|
|
final bool highlight;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
Container(
|
|
width: 34,
|
|
height: 34,
|
|
decoration: BoxDecoration(
|
|
color: KrowPalette.blueTint,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Icon(icon, size: 16, color: KrowPalette.blue),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: KrowPalette.mute,
|
|
),
|
|
),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.3,
|
|
color: highlight ? KrowPalette.blue : KrowPalette.ink,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Review a filed timecard: confirm it, or raise an issue before it locks for
|
|
/// payroll.
|
|
class TimecardReviewScreen extends ConsumerWidget {
|
|
const TimecardReviewScreen({super.key, required this.timecardId});
|
|
|
|
final String timecardId;
|
|
|
|
Future<void> _dispute(BuildContext context, WidgetRef ref) async {
|
|
final controller = TextEditingController();
|
|
final note = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Report an issue'),
|
|
content: TextField(
|
|
controller: controller,
|
|
autofocus: true,
|
|
maxLines: 4,
|
|
decoration: const InputDecoration(
|
|
hintText: 'What is wrong with these hours?',
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
|
style: FilledButton.styleFrom(minimumSize: const Size(0, 44)),
|
|
child: const Text('Send'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (note == null || note.isEmpty || !context.mounted) return;
|
|
ref.read(timecardsProvider.notifier).dispute(timecardId, note);
|
|
ScaffoldMessenger.of(
|
|
context,
|
|
).showSnackBar(const SnackBar(content: Text('Issue raised with payroll')));
|
|
context.pop();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final card = ref.watch(timecardsProvider.notifier).byId(timecardId);
|
|
if (card == null) {
|
|
return Scaffold(
|
|
appBar: AppBar(),
|
|
body: const Center(child: Text('Timecard not found.')),
|
|
);
|
|
}
|
|
|
|
final variance = card.varianceMinutes;
|
|
final open = card.status == TimecardStatus.needsReview;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Review timecard'),
|
|
actions: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 16),
|
|
child: Center(
|
|
child: StatusChip(
|
|
card.status.label,
|
|
tone: switch (card.status) {
|
|
TimecardStatus.needsReview => ChipTone.yellow,
|
|
TimecardStatus.approved => ChipTone.neutral,
|
|
TimecardStatus.disputed => ChipTone.danger,
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 28),
|
|
children: [
|
|
Text(
|
|
'${card.role} at ${card.business}',
|
|
style: const TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: -0.8,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
Fmt.dayFull(card.date),
|
|
style: const TextStyle(fontSize: 13.5, color: KrowPalette.mute),
|
|
),
|
|
const SizedBox(height: 16),
|
|
InfoBanner(
|
|
open
|
|
? 'Sent automatically when you clocked out. You have 7 days to '
|
|
'raise an issue before it locks for payroll.'
|
|
: 'This timecard is ${card.status.label.toLowerCase()}.',
|
|
),
|
|
const SectionLabel('Shift'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
DetailRow('Date', Fmt.dayShort(card.date)),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Scheduled',
|
|
Fmt.window(card.scheduledStart, card.scheduledEnd),
|
|
),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Clocked',
|
|
Fmt.window(card.clockIn, card.clockOut),
|
|
valueColor: KrowPalette.blue,
|
|
),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Variance',
|
|
variance == 0
|
|
? 'On time'
|
|
: '${variance > 0 ? '+' : ''}$variance min',
|
|
valueColor: variance > 5
|
|
? const Color(0xFFA6403F)
|
|
: KrowPalette.blue,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SectionLabel('Hours and pay'),
|
|
KrowCard(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: Column(
|
|
children: [
|
|
DetailRow('Base rate', '${Fmt.money(card.rate)}/hr'),
|
|
const Divider(height: 1),
|
|
DetailRow('Break, unpaid', '${card.breakMinutes} min'),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Regular · ${Fmt.duration(card.worked)}',
|
|
Fmt.money(card.amount),
|
|
),
|
|
const Divider(height: 1),
|
|
DetailRow(
|
|
'Total, pre-tax',
|
|
Fmt.money(card.amount),
|
|
emphasis: true,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (card.disputeNote != null) ...[
|
|
const SectionLabel('Your note'),
|
|
KrowCard(child: Text(card.disputeNote!)),
|
|
],
|
|
const SizedBox(height: 22),
|
|
if (open)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton(
|
|
onPressed: () => _dispute(context, ref),
|
|
child: const Text('Report an issue'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
ref.read(timecardsProvider.notifier).approve(card.id);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Timecard approved')),
|
|
);
|
|
context.pop();
|
|
},
|
|
child: const Text('Looks right'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|