- 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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user