Refactor billing data parsing and filtering, update invoice queries, and remove the dedicated timesheets page.

This commit is contained in:
Achintha Isuru
2026-02-28 15:26:05 -05:00
parent 119b6cc000
commit 8c0708d2d3
8 changed files with 414 additions and 286 deletions

View File

@@ -62,13 +62,13 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
action: () async {
final List<dynamic> results =
await Future.wait<dynamic>(<Future<dynamic>>[
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
_getCurrentBillAmount.call(),
_getSavingsAmount.call(),
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
@@ -78,10 +78,12 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices =
pendingInvoices.map(_mapInvoiceToUiModel).toList();
final List<BillingInvoice> uiInvoiceHistory =
invoiceHistory.map(_mapInvoiceToUiModel).toList();
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
.map(_mapInvoiceToUiModel)
.toList();
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
.map(_mapInvoiceToUiModel)
.toList();
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
@@ -101,10 +103,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
),
);
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -115,8 +115,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await handleError(
emit: emit.call,
action: () async {
final List<InvoiceItem> spendingItems =
await _getSpendingBreakdown.call(event.period);
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
.call(event.period);
final List<SpendingBreakdownItem> uiSpendingBreakdown =
_mapSpendingItemsToUiModel(spendingItems);
final double periodTotal = uiSpendingBreakdown.fold(
@@ -131,10 +131,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
),
);
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -148,10 +146,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
await _approveInvoice.call(event.invoiceId);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -167,10 +163,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
onError: (String errorKey) =>
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
);
}
@@ -180,15 +174,18 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
? 'N/A'
: formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((InvoiceWorker w) {
final List<BillingWorkerRecord> workers = invoice.workers.map((
InvoiceWorker w,
) {
final DateFormat timeFormat = DateFormat('h:mm a');
return BillingWorkerRecord(
workerName: w.name,
roleName: w.role,
totalAmount: w.amount,
hours: w.hours,
rate: w.rate,
startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--',
endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--',
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
@@ -196,33 +193,35 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
String? overallStart;
String? overallEnd;
// Find valid times from workers instead of just taking the first one
final validStartTimes = workers
.where((w) => w.startTime != '--:--')
.map((w) => w.startTime)
// Find valid times from actual DateTime checks to ensure chronological sorting
final List<DateTime> validCheckIns = invoice.workers
.where((InvoiceWorker w) => w.checkIn != null)
.map((InvoiceWorker w) => w.checkIn!)
.toList();
final validEndTimes = workers
.where((w) => w.endTime != '--:--')
.map((w) => w.endTime)
final List<DateTime> validCheckOuts = invoice.workers
.where((InvoiceWorker w) => w.checkOut != null)
.map((InvoiceWorker w) => w.checkOut!)
.toList();
if (validStartTimes.isNotEmpty) {
validStartTimes.sort();
overallStart = validStartTimes.first;
final DateFormat timeFormat = DateFormat('h:mm a');
if (validCheckIns.isNotEmpty) {
validCheckIns.sort();
overallStart = timeFormat.format(validCheckIns.first);
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validEndTimes.isNotEmpty) {
validEndTimes.sort();
overallEnd = validEndTimes.last;
if (validCheckOuts.isNotEmpty) {
validCheckOuts.sort();
overallEnd = timeFormat.format(validCheckOuts.last);
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: invoice.invoiceNumber ?? invoice.id,
id: invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',

View File

@@ -96,7 +96,7 @@ class _BillingViewState extends State<BillingView> {
leading: Center(
child: UiIconButton(
icon: UiIcons.arrowLeft,
backgroundColor: UiColors.white.withOpacity(0.15),
backgroundColor: UiColors.white.withValues(alpha: 0.15),
iconColor: UiColors.white,
useBlur: true,
size: 40,
@@ -119,7 +119,7 @@ class _BillingViewState extends State<BillingView> {
Text(
t.client_billing.current_period,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withOpacity(0.7),
color: UiColors.white.withValues(alpha: 0.7),
),
),
const SizedBox(height: UiConstants.space1),
@@ -232,77 +232,4 @@ class _BillingViewState extends State<BillingView> {
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: UiConstants.space12),
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgPopup,
shape: BoxShape.circle,
border: Border.all(color: UiColors.border),
),
child: const Icon(
UiIcons.file,
size: 48,
color: UiColors.textSecondary,
),
),
const SizedBox(height: UiConstants.space4),
Text(
t.client_billing.no_invoices_period,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
],
),
);
}
}
class _InvoicesReadyBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => Modular.to.toInvoiceReady(),
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.success.withValues(alpha: 0.1),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.success.withValues(alpha: 0.3)),
),
child: Row(
children: [
const Icon(UiIcons.file, color: UiColors.success),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_billing.invoices_ready_title,
style: UiTypography.body1b.copyWith(
color: UiColors.success,
),
),
Text(
t.client_billing.invoices_ready_subtitle,
style: UiTypography.footnote2r.copyWith(
color: UiColors.success,
),
),
],
),
),
const Icon(UiIcons.chevronRight, color: UiColors.success),
],
),
),
);
}
}

View File

@@ -1,85 +0,0 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:core_localization/core_localization.dart';
class ClientTimesheetsPage extends StatelessWidget {
const ClientTimesheetsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(context.t.client_billing.timesheets.title),
elevation: 0,
backgroundColor: UiColors.white,
foregroundColor: UiColors.primary,
),
body: ListView.separated(
padding: const EdgeInsets.all(UiConstants.space5),
itemCount: 3,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final workers = ['Sarah Miller', 'David Chen', 'Mike Ross'];
final roles = ['Cashier', 'Stocker', 'Event Support'];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.separatorPrimary),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(workers[index], style: UiTypography.body2b.textPrimary),
Text('\$84.00', style: UiTypography.body2b.primary),
],
),
Text(roles[index], style: UiTypography.footnote2r.textSecondary),
const SizedBox(height: 12),
Row(
children: [
const Icon(UiIcons.clock, size: 14, color: UiColors.iconSecondary),
const SizedBox(width: 6),
Text('09:00 AM - 05:00 PM (8h)', style: UiTypography.footnote2r.textSecondary),
],
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: UiButton.secondary(
text: context.t.client_billing.timesheets.decline_button,
style: OutlinedButton.styleFrom(
foregroundColor: UiColors.destructive,
side: const BorderSide(color: UiColors.destructive),
),
onPressed: () {},
),
),
const SizedBox(width: 12),
Expanded(
child: UiButton.primary(
text: context.t.client_billing.timesheets.approve_button,
onPressed: () {
UiSnackbar.show(
context,
message: context.t.client_billing.timesheets.approved_message,
type: UiSnackbarType.success,
);
},
),
),
],
),
],
),
);
},
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../../blocs/billing_bloc.dart';
import '../../blocs/billing_event.dart';
@@ -72,8 +73,7 @@ class CompletionReviewActions extends StatelessWidget {
Modular.get<BillingBloc>().add(
BillingInvoiceDisputed(invoiceId, controller.text),
);
Navigator.pop(dialogContext);
Modular.to.pop();
Modular.to.toClientBilling();
UiSnackbar.show(
context,
message: t.client_billing.flagged_success,

View File

@@ -25,7 +25,7 @@ class InvoiceHistorySection extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
@@ -77,7 +77,7 @@ class _InvoiceItem extends StatelessWidget {
),
child: Icon(
UiIcons.file,
color: UiColors.iconSecondary.withOpacity(0.6),
color: UiColors.iconSecondary.withValues(alpha: 0.6),
size: 20,
),
),
@@ -86,10 +86,7 @@ class _InvoiceItem extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
invoice.id,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
Text(invoice.title, style: UiTypography.body1r.textPrimary),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,

View File

@@ -25,7 +25,7 @@ class PendingInvoicesSection extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
border: Border.all(color: UiColors.border.withValues(alpha: 0.5)),
),
child: Row(
children: <Widget>[
@@ -41,9 +41,9 @@ class PendingInvoicesSection extends StatelessWidget {
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
children: <Widget>[
Row(
children: [
children: <Widget>[
Text(
t.client_billing.awaiting_approval,
style: UiTypography.body1b.textPrimary,
@@ -79,7 +79,7 @@ class PendingInvoicesSection extends StatelessWidget {
Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary.withOpacity(0.5),
color: UiColors.iconSecondary.withValues(alpha: 0.5),
),
],
),
@@ -180,7 +180,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container(
width: 1,
height: 32,
color: UiColors.border.withOpacity(0.3),
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
@@ -192,7 +192,7 @@ class PendingInvoiceCard extends StatelessWidget {
Container(
width: 1,
height: 32,
color: UiColors.border.withOpacity(0.3),
color: UiColors.border.withValues(alpha: 0.3),
),
Expanded(
child: _buildStatItem(
@@ -225,7 +225,11 @@ class PendingInvoiceCard extends StatelessWidget {
Widget _buildStatItem(IconData icon, String value, String label) {
return Column(
children: <Widget>[
Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)),
Icon(
icon,
size: 20,
color: UiColors.iconSecondary.withValues(alpha: 0.8),
),
const SizedBox(height: 6),
Text(
value,