feat: complete client billing UI and staff benefits display (#524, #527)

- Client App: Built dedicated ShiftCompletionReviewPage  and InvoiceReadyPage
- Client App: Wired up invoice summary mapping and parsing logic from Data Connect
- Staff App: Added dynamic BenefitsOverviewPage tracking worker limits matching client mockup
- Staff App: Display progress ring values wired to real VendorBenefitPlan & BenefitsData balances
This commit is contained in:
2026-02-24 16:17:19 +05:30
parent 98c0b8a644
commit 7e26b54c50
35 changed files with 2038 additions and 199 deletions

View File

@@ -9,9 +9,14 @@ import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart';
import 'domain/usecases/get_savings_amount.dart';
import 'domain/usecases/get_spending_breakdown.dart';
import 'domain/usecases/approve_invoice.dart';
import 'domain/usecases/dispute_invoice.dart';
import 'presentation/blocs/billing_bloc.dart';
import 'presentation/models/billing_invoice_model.dart';
import 'presentation/pages/billing_page.dart';
import 'presentation/pages/timesheets_page.dart';
import 'presentation/pages/completion_review_page.dart';
import 'presentation/pages/invoice_ready_page.dart';
import 'presentation/pages/pending_invoices_page.dart';
/// Modular module for the billing feature.
class BillingModule extends Module {
@@ -29,6 +34,8 @@ class BillingModule extends Module {
i.addSingleton(GetPendingInvoicesUseCase.new);
i.addSingleton(GetInvoiceHistoryUseCase.new);
i.addSingleton(GetSpendingBreakdownUseCase.new);
i.addSingleton(ApproveInvoiceUseCase.new);
i.addSingleton(DisputeInvoiceUseCase.new);
// BLoCs
i.addSingleton<BillingBloc>(
@@ -39,6 +46,8 @@ class BillingModule extends Module {
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
approveInvoice: i.get<ApproveInvoiceUseCase>(),
disputeInvoice: i.get<DisputeInvoiceUseCase>(),
),
);
}
@@ -46,6 +55,8 @@ class BillingModule extends Module {
@override
void routes(RouteManager r) {
r.child(ClientPaths.childRoute(ClientPaths.billing, ClientPaths.billing), child: (_) => const BillingPage());
r.child('/timesheets', child: (_) => const ClientTimesheetsPage());
r.child('/completion-review', child: (_) => ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?));
r.child('/invoice-ready', child: (_) => const InvoiceReadyPage());
r.child('/awaiting-approval', child: (_) => const PendingInvoicesPage());
}
}

View File

@@ -56,5 +56,15 @@ class BillingRepositoryImpl implements BillingRepository {
period: period,
);
}
@override
Future<void> approveInvoice(String id) async {
return _connectorRepository.approveInvoice(id: id);
}
@override
Future<void> disputeInvoice(String id, String reason) async {
return _connectorRepository.disputeInvoice(id: id, reason: reason);
}
}

View File

@@ -23,4 +23,10 @@ abstract class BillingRepository {
/// Fetches invoice items for spending breakdown analysis.
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
/// Approves an invoice.
Future<void> approveInvoice(String id);
/// Disputes an invoice.
Future<void> disputeInvoice(String id, String reason);
}

View File

@@ -0,0 +1,13 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Use case for approving an invoice.
class ApproveInvoiceUseCase extends UseCase<String, void> {
/// Creates an [ApproveInvoiceUseCase].
ApproveInvoiceUseCase(this._repository);
final BillingRepository _repository;
@override
Future<void> call(String input) => _repository.approveInvoice(input);
}

View File

@@ -0,0 +1,21 @@
import 'package:krow_core/core.dart';
import '../repositories/billing_repository.dart';
/// Params for [DisputeInvoiceUseCase].
class DisputeInvoiceParams {
const DisputeInvoiceParams({required this.id, required this.reason});
final String id;
final String reason;
}
/// Use case for disputing an invoice.
class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
/// Creates a [DisputeInvoiceUseCase].
DisputeInvoiceUseCase(this._repository);
final BillingRepository _repository;
@override
Future<void> call(DisputeInvoiceParams input) =>
_repository.disputeInvoice(input.id, input.reason);
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.dart';
@@ -7,6 +8,8 @@ import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart';
import '../../domain/usecases/get_savings_amount.dart';
import '../../domain/usecases/get_spending_breakdown.dart';
import '../../domain/usecases/approve_invoice.dart';
import '../../domain/usecases/dispute_invoice.dart';
import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart';
import 'billing_event.dart';
@@ -23,15 +26,21 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
required ApproveInvoiceUseCase approveInvoice,
required DisputeInvoiceUseCase disputeInvoice,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
_getSpendingBreakdown = getSpendingBreakdown,
_approveInvoice = approveInvoice,
_disputeInvoice = disputeInvoice,
super(const BillingState()) {
on<BillingLoadStarted>(_onLoadStarted);
on<BillingPeriodChanged>(_onPeriodChanged);
on<BillingInvoiceApproved>(_onInvoiceApproved);
on<BillingInvoiceDisputed>(_onInvoiceDisputed);
}
final GetBankAccountsUseCase _getBankAccounts;
@@ -40,6 +49,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
final GetPendingInvoicesUseCase _getPendingInvoices;
final GetInvoiceHistoryUseCase _getInvoiceHistory;
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
final ApproveInvoiceUseCase _approveInvoice;
final DisputeInvoiceUseCase _disputeInvoice;
Future<void> _onLoadStarted(
BillingLoadStarted event,
@@ -127,25 +138,102 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
);
}
Future<void> _onInvoiceApproved(
BillingInvoiceApproved event,
Emitter<BillingState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _approveInvoice.call(event.invoiceId);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onInvoiceDisputed(
BillingInvoiceDisputed event,
Emitter<BillingState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
await _disputeInvoice.call(
DisputeInvoiceParams(id: event.invoiceId, reason: event.reason),
);
add(const BillingLoadStarted());
},
onError: (String errorKey) => state.copyWith(
status: BillingStatus.failure,
errorMessage: errorKey,
),
);
}
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
// In a real app, fetches related Event/Business names via ID.
// For now, mapping available fields and hardcoding missing UI placeholders.
// Preserving "Existing Behavior" means we show something.
final DateFormat formatter = DateFormat('EEEE, MMMM d');
final String dateLabel = invoice.issueDate == null
? '2024-01-24'
: invoice.issueDate!.toIso8601String().split('T').first;
final String titleLabel = invoice.invoiceNumber ?? invoice.id;
? 'N/A'
: formatter.format(invoice.issueDate!);
final List<BillingWorkerRecord> workers = invoice.workers.map((InvoiceWorker w) {
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')}' : '--:--',
breakMinutes: w.breakMinutes,
workerAvatarUrl: w.avatarUrl,
);
}).toList();
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)
.toList();
final validEndTimes = workers
.where((w) => w.endTime != '--:--')
.map((w) => w.endTime)
.toList();
if (validStartTimes.isNotEmpty) {
validStartTimes.sort();
overallStart = validStartTimes.first;
} else if (workers.isNotEmpty) {
overallStart = workers.first.startTime;
}
if (validEndTimes.isNotEmpty) {
validEndTimes.sort();
overallEnd = validEndTimes.last;
} else if (workers.isNotEmpty) {
overallEnd = workers.first.endTime;
}
return BillingInvoice(
id: titleLabel,
title: 'Invoice #${invoice.id}', // Placeholder as Invoice lacks title
locationAddress:
'Location for ${invoice.eventId}', // Placeholder for address
clientName: 'Client ${invoice.businessId}', // Placeholder for client name
id: invoice.invoiceNumber ?? invoice.id,
title: invoice.title ?? 'N/A',
locationAddress: invoice.locationAddress ?? 'Remote',
clientName: invoice.clientName ?? 'N/A',
date: dateLabel,
totalAmount: invoice.totalAmount,
workersCount: 5, // Placeholder count
totalHours: invoice.workAmount / 25.0, // Estimating hours from amount
workersCount: invoice.staffCount ?? 0,
totalHours: invoice.totalHours ?? 0.0,
status: invoice.status.name.toUpperCase(),
workers: workers,
startTime: overallStart,
endTime: overallEnd,
);
}

View File

@@ -24,3 +24,20 @@ class BillingPeriodChanged extends BillingEvent {
@override
List<Object?> get props => <Object?>[period];
}
class BillingInvoiceApproved extends BillingEvent {
const BillingInvoiceApproved(this.invoiceId);
final String invoiceId;
@override
List<Object?> get props => <Object?>[invoiceId];
}
class BillingInvoiceDisputed extends BillingEvent {
const BillingInvoiceDisputed(this.invoiceId, this.reason);
final String invoiceId;
final String reason;
@override
List<Object?> get props => <Object?>[invoiceId, reason];
}

View File

@@ -11,6 +11,9 @@ class BillingInvoice extends Equatable {
required this.workersCount,
required this.totalHours,
required this.status,
this.workers = const [],
this.startTime,
this.endTime,
});
final String id;
@@ -22,6 +25,9 @@ class BillingInvoice extends Equatable {
final int workersCount;
final double totalHours;
final String status;
final List<BillingWorkerRecord> workers;
final String? startTime;
final String? endTime;
@override
List<Object?> get props => <Object?>[
@@ -34,5 +40,45 @@ class BillingInvoice extends Equatable {
workersCount,
totalHours,
status,
workers,
startTime,
endTime,
];
}
class BillingWorkerRecord extends Equatable {
const BillingWorkerRecord({
required this.workerName,
required this.roleName,
required this.totalAmount,
required this.hours,
required this.rate,
required this.startTime,
required this.endTime,
required this.breakMinutes,
this.workerAvatarUrl,
});
final String workerName;
final String roleName;
final double totalAmount;
final double hours;
final double rate;
final String startTime;
final String endTime;
final int breakMinutes;
final String? workerAvatarUrl;
@override
List<Object?> get props => [
workerName,
roleName,
totalAmount,
hours,
rate,
startTime,
endTime,
breakMinutes,
workerAvatarUrl,
];
}

View File

@@ -72,6 +72,7 @@ class _BillingViewState extends State<BillingView> {
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: UiColors.background,
body: BlocConsumer<BillingBloc, BillingState>(
listener: (BuildContext context, BillingState state) {
if (state.status == BillingStatus.failure &&
@@ -89,33 +90,29 @@ class _BillingViewState extends State<BillingView> {
slivers: <Widget>[
SliverAppBar(
pinned: true,
expandedHeight: 200.0,
expandedHeight: 220.0,
backgroundColor: UiColors.primary,
elevation: 0,
leadingWidth: 72,
leading: Center(
child: UiIconButton.secondary(
child: UiIconButton(
icon: UiIcons.arrowLeft,
backgroundColor: UiColors.white.withOpacity(0.15),
iconColor: UiColors.white,
useBlur: true,
size: 40,
onTap: () => Modular.to.toClientHome(),
),
),
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(
_isScrolled
? '\$${state.currentBill.toStringAsFixed(2)}'
: t.client_billing.title,
key: ValueKey<bool>(_isScrolled),
style: UiTypography.headline4m.copyWith(
color: UiColors.white,
),
),
title: Text(
t.client_billing.title,
style: UiTypography.headline3b.copyWith(color: UiColors.white),
),
centerTitle: false,
flexibleSpace: FlexibleSpaceBar(
background: Padding(
padding: const EdgeInsets.only(
top: UiConstants.space0,
left: UiConstants.space5,
right: UiConstants.space5,
bottom: UiConstants.space10,
bottom: UiConstants.space8,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
@@ -123,21 +120,22 @@ class _BillingViewState extends State<BillingView> {
Text(
t.client_billing.current_period,
style: UiTypography.footnote2r.copyWith(
color: UiColors.white.withValues(alpha: 0.7),
color: UiColors.white.withOpacity(0.7),
),
),
const SizedBox(height: UiConstants.space1),
Text(
'\$${state.currentBill.toStringAsFixed(2)}',
style: UiTypography.display1b.copyWith(
style: UiTypography.displayM.copyWith(
color: UiColors.white,
fontSize: 40,
),
),
const SizedBox(height: UiConstants.space2),
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: UiColors.accent,
@@ -148,16 +146,16 @@ class _BillingViewState extends State<BillingView> {
children: <Widget>[
const Icon(
UiIcons.trendingDown,
size: 12,
color: UiColors.foreground,
size: 14,
color: UiColors.accentForeground,
),
const SizedBox(width: UiConstants.space1),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.saved_amount(
amount: state.savings.toStringAsFixed(0),
),
style: UiTypography.footnote2b.copyWith(
color: UiColors.foreground,
color: UiColors.accentForeground,
),
),
],
@@ -200,13 +198,13 @@ class _BillingViewState extends State<BillingView> {
Text(
state.errorMessage != null
? translateErrorKey(state.errorMessage!)
: 'An error occurred',
: t.client_billing.error_occurred,
style: UiTypography.body1m.textError,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space4),
UiButton.secondary(
text: 'Retry',
text: t.client_billing.retry,
onPressed: () => BlocProvider.of<BillingBloc>(
context,
).add(const BillingLoadStarted()),
@@ -221,24 +219,95 @@ class _BillingViewState extends State<BillingView> {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: UiConstants.space4,
spacing: UiConstants.space6,
children: <Widget>[
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
PendingInvoicesSection(invoices: state.pendingInvoices),
],
const PaymentMethodCard(),
const SpendingBreakdownCard(),
if (state.invoiceHistory.isEmpty)
_buildEmptyState(context)
else
_buildSavingsCard(state.savings),
if (state.invoiceHistory.isNotEmpty)
InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space32),
_buildExportButton(),
const SizedBox(height: UiConstants.space12),
],
),
);
}
Widget _buildSavingsCard(double amount) {
return Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.accent.withOpacity(0.5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(UiConstants.space2),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(UiIcons.trendingDown, size: 18, color: UiColors.accentForeground),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t.client_billing.rate_optimization_title,
style: UiTypography.body2b.textPrimary,
),
const SizedBox(height: 4),
Text.rich(
TextSpan(
style: UiTypography.footnote2r.textSecondary,
children: [
TextSpan(text: t.client_billing.rate_optimization_save),
TextSpan(
text: t.client_billing.rate_optimization_amount(amount: amount.toStringAsFixed(0)),
style: UiTypography.footnote2b.textPrimary,
),
TextSpan(text: t.client_billing.rate_optimization_shifts),
],
),
),
const SizedBox(height: UiConstants.space3),
SizedBox(
height: 32,
child: UiButton.primary(
text: t.client_billing.view_details,
onPressed: () {},
size: UiButtonSize.small,
),
),
],
),
),
],
),
);
}
Widget _buildExportButton() {
return SizedBox(
width: double.infinity,
child: UiButton.secondary(
text: t.client_billing.export_button,
leadingIcon: UiIcons.download,
onPressed: () {},
size: UiButtonSize.large,
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(
@@ -260,7 +329,7 @@ class _BillingViewState extends State<BillingView> {
),
const SizedBox(height: UiConstants.space4),
Text(
'No Invoices for the selected period',
t.client_billing.no_invoices_period,
style: UiTypography.body1m.textSecondary,
textAlign: TextAlign.center,
),
@@ -269,3 +338,42 @@ class _BillingViewState extends State<BillingView> {
);
}
}
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

@@ -0,0 +1,421 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../models/billing_invoice_model.dart';
class ShiftCompletionReviewPage extends StatefulWidget {
const ShiftCompletionReviewPage({this.invoice, super.key});
final BillingInvoice? invoice;
@override
State<ShiftCompletionReviewPage> createState() => _ShiftCompletionReviewPageState();
}
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
late BillingInvoice invoice;
String searchQuery = '';
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
@override
void initState() {
super.initState();
// Use widget.invoice if provided, else try to get from arguments
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice;
}
@override
Widget build(BuildContext context) {
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((BillingWorkerRecord w) {
if (searchQuery.isEmpty) return true;
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
}).toList();
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
body: SafeArea(
child: Column(
children: <Widget>[
_buildHeader(context),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(height: UiConstants.space4),
_buildInvoiceInfoCard(),
const SizedBox(height: UiConstants.space4),
_buildAmountCard(),
const SizedBox(height: UiConstants.space6),
_buildWorkersHeader(),
const SizedBox(height: UiConstants.space4),
_buildSearchAndTabs(),
const SizedBox(height: UiConstants.space4),
...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)),
const SizedBox(height: UiConstants.space6),
_buildActionButtons(context),
const SizedBox(height: UiConstants.space4),
_buildDownloadLink(),
const SizedBox(height: UiConstants.space8),
],
),
),
),
],
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4),
decoration: const BoxDecoration(
color: Colors.white,
border: Border(bottom: BorderSide(color: UiColors.border)),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: UiColors.border,
borderRadius: UiConstants.radiusFull,
),
),
const SizedBox(height: UiConstants.space4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(t.client_billing.invoice_ready, style: UiTypography.headline4b.textPrimary),
Text(t.client_billing.review_and_approve_subtitle, style: UiTypography.body2r.textSecondary),
],
),
UiIconButton.secondary(
icon: UiIcons.close,
onTap: () => Navigator.of(context).pop(),
),
],
),
],
),
);
}
Widget _buildInvoiceInfoCard() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
Text(invoice.clientName, style: UiTypography.body2r.textSecondary),
const SizedBox(height: UiConstants.space4),
_buildInfoRow(UiIcons.calendar, invoice.date),
const SizedBox(height: UiConstants.space2),
_buildInfoRow(UiIcons.clock, '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}'),
const SizedBox(height: UiConstants.space2),
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
],
);
}
Widget _buildInfoRow(IconData icon, String text) {
return Row(
children: <Widget>[
Icon(icon, size: 16, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space3),
Text(text, style: UiTypography.body2r.textSecondary),
],
);
}
Widget _buildAmountCard() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: UiConstants.radiusLg,
border: Border.all(color: const Color(0xFFDBEAFE)),
),
child: Column(
children: <Widget>[
Text(
t.client_billing.total_amount_label,
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
),
const SizedBox(height: UiConstants.space2),
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
),
const SizedBox(height: UiConstants.space1),
Text(
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix}\$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
style: UiTypography.footnote2b.textSecondary,
),
],
),
);
}
Widget _buildWorkersHeader() {
return Row(
children: <Widget>[
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.workers_tab.title(count: invoice.workersCount),
style: UiTypography.title2b.textPrimary,
),
],
);
}
Widget _buildSearchAndTabs() {
return Column(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
decoration: BoxDecoration(
color: const Color(0xFFF1F5F9),
borderRadius: UiConstants.radiusMd,
),
child: TextField(
onChanged: (String val) => setState(() => searchQuery = val),
decoration: InputDecoration(
icon: const Icon(UiIcons.search, size: 18, color: UiColors.iconSecondary),
hintText: t.client_billing.workers_tab.search_hint,
hintStyle: UiTypography.body2r.textSecondary,
border: InputBorder.none,
),
),
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Expanded(
child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1),
),
],
),
],
);
}
Widget _buildTabButton(String text, int index) {
final bool isSelected = selectedTab == index;
return GestureDetector(
onTap: () => setState(() => selectedTab = index),
child: Container(
height: 40,
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border),
),
child: Center(
child: Text(
text,
style: UiTypography.body2b.copyWith(
color: isSelected ? Colors.white : UiColors.textSecondary,
),
),
),
),
);
}
Widget _buildWorkerCard(BillingWorkerRecord worker) {
return Container(
margin: const EdgeInsets.only(bottom: UiConstants.space3),
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
),
child: Column(
children: <Widget>[
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
CircleAvatar(
radius: 20,
backgroundColor: UiColors.bgSecondary,
backgroundImage: worker.workerAvatarUrl != null ? NetworkImage(worker.workerAvatarUrl!) : null,
child: worker.workerAvatarUrl == null ? const Icon(UiIcons.user, size: 20, color: UiColors.iconSecondary) : null,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(worker.workerName, style: UiTypography.body1b.textPrimary),
Text(worker.roleName, style: UiTypography.footnote2r.textSecondary),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[
Text('\$${worker.totalAmount.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary),
Text('${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', style: UiTypography.footnote2r.textSecondary),
],
),
],
),
const SizedBox(height: UiConstants.space4),
Row(
children: <Widget>[
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Text('${worker.startTime} - ${worker.endTime}', style: UiTypography.footnote2b.textPrimary),
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: UiConstants.radiusMd,
border: Border.all(color: UiColors.border),
),
child: Row(
children: [
const Icon(UiIcons.coffee, size: 12, color: UiColors.iconSecondary),
const SizedBox(width: 4),
Text('${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', style: UiTypography.footnote2r.textSecondary),
],
),
),
const Spacer(),
UiIconButton.secondary(
icon: UiIcons.edit,
onTap: () {},
),
const SizedBox(width: UiConstants.space2),
UiIconButton.secondary(
icon: UiIcons.warning,
onTap: () {},
),
],
),
],
),
);
}
Widget _buildActionButtons(BuildContext context) {
return Column(
children: <Widget>[
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: t.client_billing.actions.approve_pay,
leadingIcon: UiIcons.checkCircle,
onPressed: () {
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoice.id));
Modular.to.pop();
UiSnackbar.show(context, message: t.client_billing.approved_success, type: UiSnackbarType.success);
},
size: UiButtonSize.large,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF22C55E),
foregroundColor: Colors.white,
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
),
),
),
const SizedBox(height: UiConstants.space3),
SizedBox(
width: double.infinity,
child: Container(
decoration: BoxDecoration(
borderRadius: UiConstants.radiusMd,
border: Border.all(color: Colors.orange, width: 2),
),
child: UiButton.secondary(
text: t.client_billing.actions.flag_review,
leadingIcon: UiIcons.warning,
onPressed: () => _showFlagDialog(context),
size: UiButtonSize.large,
style: OutlinedButton.styleFrom(
foregroundColor: Colors.orange,
side: BorderSide.none,
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
),
),
),
),
],
);
}
Widget _buildDownloadLink() {
return Center(
child: TextButton.icon(
onPressed: () {},
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
label: Text(
t.client_billing.actions.download_pdf,
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
),
),
);
}
void _showFlagDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(t.client_billing.flag_dialog.title),
content: TextField(
controller: controller,
decoration: InputDecoration(
hintText: t.client_billing.flag_dialog.hint,
),
maxLines: 3,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(t.common.cancel),
),
TextButton(
onPressed: () {
if (controller.text.isNotEmpty) {
Modular.get<BillingBloc>().add(
BillingInvoiceDisputed(invoice.id, controller.text),
);
Navigator.pop(dialogContext);
Modular.to.pop();
UiSnackbar.show(context, message: t.client_billing.flagged_success, type: UiSnackbarType.warning);
}
},
child: Text(t.client_billing.flag_dialog.button),
),
],
),
);
}
}

View File

@@ -0,0 +1,143 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_event.dart';
import '../blocs/billing_state.dart';
import '../models/billing_invoice_model.dart';
class InvoiceReadyPage extends StatelessWidget {
const InvoiceReadyPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider<BillingBloc>.value(
value: Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
child: const InvoiceReadyView(),
);
}
}
class InvoiceReadyView extends StatelessWidget {
const InvoiceReadyView({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Invoices Ready'),
leading: UiIconButton.secondary(
icon: UiIcons.arrowLeft,
onTap: () => Modular.to.pop(),
),
),
body: BlocBuilder<BillingBloc, BillingState>(
builder: (context, state) {
if (state.status == BillingStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.invoiceHistory.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary),
const SizedBox(height: UiConstants.space4),
Text(
'No invoices ready yet',
style: UiTypography.body1m.textSecondary,
),
],
),
);
}
return ListView.separated(
padding: const EdgeInsets.all(UiConstants.space5),
itemCount: state.invoiceHistory.length,
separatorBuilder: (context, index) => const SizedBox(height: 16),
itemBuilder: (context, index) {
final invoice = state.invoiceHistory[index];
return _InvoiceSummaryCard(invoice: invoice);
},
);
},
),
);
}
}
class _InvoiceSummaryCard extends StatelessWidget {
const _InvoiceSummaryCard({required this.invoice});
final BillingInvoice invoice;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: [
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: UiColors.success.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'READY',
style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success),
),
),
Text(
invoice.date,
style: UiTypography.footnote2r.textTertiary,
),
],
),
const SizedBox(height: 16),
Text(invoice.title, style: UiTypography.title2b.textPrimary),
const SizedBox(height: 8),
Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary),
const Divider(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary),
Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary),
],
),
UiButton.primary(
text: 'View Details',
onPressed: () {
// TODO: Navigate to invoice details
},
size: UiButtonSize.small,
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,124 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
import '../widgets/pending_invoices_section.dart';
class PendingInvoicesPage extends StatelessWidget {
const PendingInvoicesPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
body: BlocBuilder<BillingBloc, BillingState>(
bloc: Modular.get<BillingBloc>(),
builder: (context, state) {
return CustomScrollView(
slivers: [
_buildHeader(context, state.pendingInvoices.length),
if (state.status == BillingStatus.loading)
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
)
else if (state.pendingInvoices.isEmpty)
_buildEmptyState()
else
SliverPadding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
100, // Bottom padding for scroll clearance
),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space4),
child: PendingInvoiceCard(invoice: state.pendingInvoices[index]),
);
},
childCount: state.pendingInvoices.length,
),
),
),
],
);
},
),
);
}
Widget _buildHeader(BuildContext context, int count) {
return SliverAppBar(
pinned: true,
expandedHeight: 140.0,
backgroundColor: UiColors.primary,
elevation: 0,
leadingWidth: 72,
leading: Center(
child: UiIconButton(
icon: UiIcons.arrowLeft,
backgroundColor: UiColors.white.withOpacity(0.15),
iconColor: UiColors.white,
useBlur: true,
size: 40,
onTap: () => Navigator.of(context).pop(),
),
),
flexibleSpace: FlexibleSpaceBar(
centerTitle: true,
title: Text(
t.client_billing.awaiting_approval,
style: UiTypography.headline4b.copyWith(color: UiColors.white),
),
background: Center(
child: Padding(
padding: const EdgeInsets.only(top: 40),
child: Opacity(
opacity: 0.1,
child: Icon(UiIcons.clock, size: 100, color: UiColors.white),
),
),
),
),
);
}
Widget _buildEmptyState() {
return SliverFillRemaining(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
padding: const EdgeInsets.all(UiConstants.space6),
decoration: BoxDecoration(
color: UiColors.bgPopup,
shape: BoxShape.circle,
),
child: const Icon(UiIcons.checkCircle, size: 48, color: UiColors.success),
),
const SizedBox(height: UiConstants.space4),
Text(
t.client_billing.all_caught_up,
style: UiTypography.body1m.textPrimary,
),
Text(
t.client_billing.no_pending_invoices,
style: UiTypography.body2r.textSecondary,
),
],
),
),
);
}
}
// We need to export the card widget from the section file if we want to reuse it,
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.

View File

@@ -22,20 +22,37 @@ class InvoiceHistorySection extends StatelessWidget {
t.client_billing.invoice_history,
style: UiTypography.title2b.textPrimary,
),
const SizedBox.shrink(),
TextButton(
onPressed: () {},
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Row(
children: [
Text(
t.client_billing.view_all,
style: UiTypography.body2b.copyWith(color: UiColors.primary),
),
const SizedBox(width: 4),
const Icon(UiIcons.chevronRight, size: 16, color: UiColors.primary),
],
),
),
],
),
const SizedBox(height: UiConstants.space2),
const SizedBox(height: UiConstants.space3),
Container(
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
border: Border.all(color: UiColors.border.withOpacity(0.5)),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
@@ -68,7 +85,10 @@ class _InvoiceItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(UiConstants.space4),
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space4,
),
child: Row(
children: <Widget>[
Container(
@@ -77,14 +97,21 @@ class _InvoiceItem extends StatelessWidget {
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: const Icon(UiIcons.file, color: UiColors.primary, size: 20),
child: Icon(
UiIcons.file,
color: UiColors.iconSecondary.withOpacity(0.6),
size: 20,
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(invoice.id, style: UiTypography.body2b.textPrimary),
Text(
invoice.id,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
Text(
invoice.date,
style: UiTypography.footnote2r.textSecondary,
@@ -97,12 +124,17 @@ class _InvoiceItem extends StatelessWidget {
children: <Widget>[
Text(
'\$${invoice.totalAmount.toStringAsFixed(2)}',
style: UiTypography.body2b.textPrimary,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
),
_StatusBadge(status: invoice.status),
],
),
const SizedBox.shrink(),
const SizedBox(width: UiConstants.space4),
Icon(
UiIcons.download,
size: 20,
color: UiColors.iconSecondary.withOpacity(0.3),
),
],
),
);

View File

@@ -1,9 +1,11 @@
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 '../models/billing_invoice_model.dart';
/// Section showing invoices awaiting approval.
/// Section showing a banner for invoices awaiting approval.
class PendingInvoicesSection extends StatelessWidget {
/// Creates a [PendingInvoicesSection].
const PendingInvoicesSection({required this.invoices, super.key});
@@ -13,55 +15,86 @@ class PendingInvoicesSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Row(
if (invoices.isEmpty) return const SizedBox.shrink();
return GestureDetector(
onTap: () => Modular.to.toAwaitingApproval(),
child: Container(
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.04),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.textWarning,
color: Colors.orange,
shape: BoxShape.circle,
),
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.awaiting_approval,
style: UiTypography.title2b.textPrimary,
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
t.client_billing.awaiting_approval,
style: UiTypography.body1b.textPrimary,
),
const SizedBox(width: UiConstants.space2),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: Text(
'${invoices.length}',
style: UiTypography.footnote2b.copyWith(
color: UiColors.accentForeground,
fontSize: 10,
),
),
),
],
),
const SizedBox(height: 2),
Text(
t.client_billing.review_and_approve_subtitle,
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
const SizedBox(width: UiConstants.space2),
Container(
width: 24,
height: 24,
decoration: const BoxDecoration(
color: UiColors.accent,
shape: BoxShape.circle,
),
child: Center(
child: Text(
'${invoices.length}',
style: UiTypography.footnote2b.textPrimary,
),
),
Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary.withOpacity(0.5),
),
],
),
const SizedBox(height: UiConstants.space3),
...invoices.map(
(BillingInvoice invoice) => Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: _PendingInvoiceCard(invoice: invoice),
),
),
],
),
);
}
}
class _PendingInvoiceCard extends StatelessWidget {
const _PendingInvoiceCard({required this.invoice});
/// Card showing a single pending invoice.
class PendingInvoiceCard extends StatelessWidget {
/// Creates a [PendingInvoiceCard].
const PendingInvoiceCard({required this.invoice, super.key});
final BillingInvoice invoice;
@@ -71,17 +104,17 @@ class _PendingInvoiceCard extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
border: Border.all(color: UiColors.border.withOpacity(0.5)),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Padding(
padding: const EdgeInsets.all(UiConstants.space4),
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
@@ -89,10 +122,10 @@ class _PendingInvoiceCard extends StatelessWidget {
children: <Widget>[
const Icon(
UiIcons.mapPin,
size: 14,
size: 16,
color: UiColors.iconSecondary,
),
const SizedBox(width: UiConstants.space1),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
invoice.locationAddress,
@@ -103,8 +136,8 @@ class _PendingInvoiceCard extends StatelessWidget {
),
],
),
const SizedBox(height: UiConstants.space1),
Text(invoice.title, style: UiTypography.body2b.textPrimary),
const SizedBox(height: UiConstants.space2),
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
const SizedBox(height: UiConstants.space1),
Row(
children: <Widget>[
@@ -125,8 +158,8 @@ class _PendingInvoiceCard extends StatelessWidget {
Row(
children: <Widget>[
Container(
width: 6,
height: 6,
width: 8,
height: 8,
decoration: const BoxDecoration(
color: UiColors.textWarning,
shape: BoxShape.circle,
@@ -134,7 +167,7 @@ class _PendingInvoiceCard extends StatelessWidget {
),
const SizedBox(width: UiConstants.space2),
Text(
t.client_billing.pending_badge,
t.client_billing.pending_badge.toUpperCase(),
style: UiTypography.titleUppercase4b.copyWith(
color: UiColors.textWarning,
),
@@ -142,48 +175,49 @@ class _PendingInvoiceCard extends StatelessWidget {
],
),
const SizedBox(height: UiConstants.space4),
Container(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
decoration: const BoxDecoration(
border: Border.symmetric(
horizontal: BorderSide(color: UiColors.border),
),
),
const Divider(height: 1, color: UiColors.border),
Padding(
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
child: Row(
children: <Widget>[
Expanded(
child: _buildStatItem(
UiIcons.dollar,
'\$${invoice.totalAmount.toStringAsFixed(2)}',
'Total',
t.client_billing.stats.total,
),
),
Container(width: 1, height: 30, color: UiColors.border),
Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)),
Expanded(
child: _buildStatItem(
UiIcons.users,
'${invoice.workersCount}',
'Workers',
t.client_billing.stats.workers,
),
),
Container(width: 1, height: 30, color: UiColors.border),
Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)),
Expanded(
child: _buildStatItem(
UiIcons.clock,
invoice.totalHours.toStringAsFixed(1),
'HRS',
'${invoice.totalHours.toStringAsFixed(1)}',
t.client_billing.stats.hrs,
),
),
],
),
),
const SizedBox(height: UiConstants.space4),
const Divider(height: 1, color: UiColors.border),
const SizedBox(height: UiConstants.space5),
SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Review & Approve',
onPressed: () {},
size: UiButtonSize.small,
text: t.client_billing.review_and_approve,
leadingIcon: UiIcons.checkCircle,
onPressed: () => Modular.to.toCompletionReview(arguments: invoice),
size: UiButtonSize.large,
style: ElevatedButton.styleFrom(
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
),
),
),
],
@@ -195,12 +229,18 @@ class _PendingInvoiceCard extends StatelessWidget {
Widget _buildStatItem(IconData icon, String value, String label) {
return Column(
children: <Widget>[
Icon(icon, size: 14, color: UiColors.iconSecondary),
const SizedBox(height: 2),
Text(value, style: UiTypography.body2b.textPrimary),
Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)),
const SizedBox(height: 6),
Text(
label.toUpperCase(),
style: UiTypography.titleUppercase4m.textSecondary,
value,
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 16),
),
Text(
label.toLowerCase(),
style: UiTypography.titleUppercase4m.textSecondary.copyWith(
fontSize: 10,
letterSpacing: 0,
),
),
],
);