feat: Migrate staff profile features from Data Connect to V2 REST API
- Removed data_connect package from mobile pubspec.yaml. - Added documentation for V2 profile migration status and QA findings. - Implemented new session management with ClientSessionStore and StaffSessionStore. - Created V2SessionService for handling user sessions via the V2 API. - Developed use cases for cancelling late worker assignments and submitting worker reviews. - Added arguments and use cases for payment chart retrieval and profile completion checks. - Implemented repository interfaces and their implementations for staff main and profile features. - Ensured proper error handling and validation in use cases.
This commit is contained in:
@@ -1,30 +1,37 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'domain/repositories/billing_repository.dart';
|
||||
import 'domain/usecases/get_bank_accounts.dart';
|
||||
import 'domain/usecases/get_current_bill_amount.dart';
|
||||
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/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'presentation/models/billing_invoice_model.dart';
|
||||
import 'presentation/pages/billing_page.dart';
|
||||
import 'presentation/pages/completion_review_page.dart';
|
||||
import 'presentation/pages/invoice_ready_page.dart';
|
||||
import 'presentation/pages/pending_invoices_page.dart';
|
||||
import 'package:billing/src/data/repositories_impl/billing_repository_impl.dart';
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/pages/billing_page.dart';
|
||||
import 'package:billing/src/presentation/pages/completion_review_page.dart';
|
||||
import 'package:billing/src/presentation/pages/invoice_ready_page.dart';
|
||||
import 'package:billing/src/presentation/pages/pending_invoices_page.dart';
|
||||
|
||||
/// Modular module for the billing feature.
|
||||
///
|
||||
/// Uses [BaseApiService] for all backend access via V2 REST API.
|
||||
class BillingModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[CoreModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||
i.addLazySingleton<BillingRepository>(
|
||||
() => BillingRepositoryImpl(apiService: i.get<BaseApiService>()),
|
||||
);
|
||||
|
||||
// Use Cases
|
||||
i.addLazySingleton(GetBankAccountsUseCase.new);
|
||||
@@ -32,7 +39,7 @@ class BillingModule extends Module {
|
||||
i.addLazySingleton(GetSavingsAmountUseCase.new);
|
||||
i.addLazySingleton(GetPendingInvoicesUseCase.new);
|
||||
i.addLazySingleton(GetInvoiceHistoryUseCase.new);
|
||||
i.addLazySingleton(GetSpendingBreakdownUseCase.new);
|
||||
i.addLazySingleton(GetSpendBreakdownUseCase.new);
|
||||
i.addLazySingleton(ApproveInvoiceUseCase.new);
|
||||
i.addLazySingleton(DisputeInvoiceUseCase.new);
|
||||
|
||||
@@ -44,7 +51,7 @@ class BillingModule extends Module {
|
||||
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
||||
getSpendBreakdown: i.get<GetSpendBreakdownUseCase>(),
|
||||
),
|
||||
);
|
||||
i.add<ShiftCompletionReviewBloc>(
|
||||
@@ -62,16 +69,20 @@ class BillingModule extends Module {
|
||||
child: (_) => const BillingPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) =>
|
||||
ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.completionReview),
|
||||
child: (_) => ShiftCompletionReviewPage(
|
||||
invoice:
|
||||
r.args.data is Invoice ? r.args.data as Invoice : null,
|
||||
),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.invoiceReady),
|
||||
child: (_) => const InvoiceReadyPage(),
|
||||
);
|
||||
r.child(
|
||||
ClientPaths.childRoute(ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
ClientPaths.childRoute(
|
||||
ClientPaths.billing, ClientPaths.awaitingApproval),
|
||||
child: (_) => const PendingInvoicesPage(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,70 +1,103 @@
|
||||
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] using the V2 REST API.
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
/// All backend calls go through [BaseApiService] with [V2ApiEndpoints].
|
||||
class BillingRepositoryImpl implements BillingRepository {
|
||||
/// Creates a [BillingRepositoryImpl].
|
||||
BillingRepositoryImpl({required BaseApiService apiService})
|
||||
: _apiService = apiService;
|
||||
|
||||
BillingRepositoryImpl({
|
||||
dc.BillingConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getBillingRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
final dc.BillingConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
/// The API service used for all HTTP requests.
|
||||
final BaseApiService _apiService;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> getBankAccounts() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getBankAccounts(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getCurrentBillAmount() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getInvoiceHistory() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getInvoiceHistory(businessId: businessId);
|
||||
Future<List<BillingAccount>> getBankAccounts() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingAccounts);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
BillingAccount.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getPendingInvoices() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getPendingInvoices(businessId: businessId);
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesPending);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getSavingsAmount() async {
|
||||
// Simulating savings calculation
|
||||
return 0.0;
|
||||
Future<List<Invoice>> getInvoiceHistory() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingInvoicesHistory);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map(
|
||||
(dynamic json) => Invoice.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getSpendingBreakdown(
|
||||
businessId: businessId,
|
||||
period: period,
|
||||
Future<int> getCurrentBillCents() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingCurrentBill);
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
return (data['currentBillCents'] as num).toInt();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int> getSavingsCents() async {
|
||||
final ApiResponse response =
|
||||
await _apiService.get(V2ApiEndpoints.clientBillingSavings);
|
||||
final Map<String, dynamic> data =
|
||||
response.data as Map<String, dynamic>;
|
||||
return (data['savingsCents'] as num).toInt();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SpendItem>> getSpendBreakdown({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
}) async {
|
||||
final ApiResponse response = await _apiService.get(
|
||||
V2ApiEndpoints.clientBillingSpendBreakdown,
|
||||
params: <String, dynamic>{
|
||||
'startDate': startDate,
|
||||
'endDate': endDate,
|
||||
},
|
||||
);
|
||||
final List<dynamic> items =
|
||||
(response.data as Map<String, dynamic>)['items'] as List<dynamic>;
|
||||
return items
|
||||
.map((dynamic json) =>
|
||||
SpendItem.fromJson(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> approveInvoice(String id) async {
|
||||
return _connectorRepository.approveInvoice(id: id);
|
||||
await _apiService.post(V2ApiEndpoints.clientInvoiceApprove(id));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> disputeInvoice(String id, String reason) async {
|
||||
return _connectorRepository.disputeInvoice(id: id, reason: reason);
|
||||
await _apiService.post(
|
||||
V2ApiEndpoints.clientInvoiceDispute(id),
|
||||
data: <String, dynamic>{'reason': reason},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:krow_domain/krow_domain.dart';
|
||||
/// It allows the Domain layer to remain independent of specific data sources.
|
||||
abstract class BillingRepository {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BusinessBankAccount>> getBankAccounts();
|
||||
Future<List<BillingAccount>> getBankAccounts();
|
||||
|
||||
/// Fetches invoices that are pending approval or payment.
|
||||
Future<List<Invoice>> getPendingInvoices();
|
||||
@@ -15,14 +15,17 @@ abstract class BillingRepository {
|
||||
/// Fetches historically paid invoices.
|
||||
Future<List<Invoice>> getInvoiceHistory();
|
||||
|
||||
/// Fetches the current bill amount for the period.
|
||||
Future<double> getCurrentBillAmount();
|
||||
/// Fetches the current bill amount in cents for the period.
|
||||
Future<int> getCurrentBillCents();
|
||||
|
||||
/// Fetches the savings amount.
|
||||
Future<double> getSavingsAmount();
|
||||
/// Fetches the savings amount in cents.
|
||||
Future<int> getSavingsCents();
|
||||
|
||||
/// Fetches invoice items for spending breakdown analysis.
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
||||
/// Fetches spending breakdown by category for a date range.
|
||||
Future<List<SpendItem>> getSpendBreakdown({
|
||||
required String startDate,
|
||||
required String endDate,
|
||||
});
|
||||
|
||||
/// Approves an invoice.
|
||||
Future<void> approveInvoice(String id);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for approving an invoice.
|
||||
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||
/// Creates an [ApproveInvoiceUseCase].
|
||||
ApproveInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Params for [DisputeInvoiceUseCase].
|
||||
class DisputeInvoiceParams {
|
||||
/// Creates [DisputeInvoiceParams].
|
||||
const DisputeInvoiceParams({required this.id, required this.reason});
|
||||
|
||||
/// The invoice ID to dispute.
|
||||
final String id;
|
||||
|
||||
/// The reason for the dispute.
|
||||
final String reason;
|
||||
}
|
||||
|
||||
@@ -13,6 +19,7 @@ class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||
/// Creates a [DisputeInvoiceUseCase].
|
||||
DisputeInvoiceUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the bank accounts associated with the business.
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
|
||||
class GetBankAccountsUseCase extends NoInputUseCase<List<BillingAccount>> {
|
||||
/// Creates a [GetBankAccountsUseCase].
|
||||
GetBankAccountsUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
|
||||
Future<List<BillingAccount>> call() => _repository.getBankAccounts();
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the current bill amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the total amount due for the current billing period.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetCurrentBillAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetCurrentBillAmountUseCase].
|
||||
GetCurrentBillAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getCurrentBillAmount();
|
||||
Future<int> call() => _repository.getCurrentBillCents();
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the invoice history.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the list of past paid invoices.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves the list of past paid invoices.
|
||||
class GetInvoiceHistoryUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetInvoiceHistoryUseCase].
|
||||
GetInvoiceHistoryUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the pending invoices.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving invoices that are currently open or disputed.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
/// Retrieves invoices that are currently open or disputed.
|
||||
class GetPendingInvoicesUseCase extends NoInputUseCase<List<Invoice>> {
|
||||
/// Creates a [GetPendingInvoicesUseCase].
|
||||
GetPendingInvoicesUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the savings amount in cents.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the estimated savings for the client.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<double> {
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSavingsAmountUseCase extends NoInputUseCase<int> {
|
||||
/// Creates a [GetSavingsAmountUseCase].
|
||||
GetSavingsAmountUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<double> call() => _repository.getSavingsAmount();
|
||||
Future<int> call() => _repository.getSavingsCents();
|
||||
}
|
||||
|
||||
@@ -1,19 +1,38 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the spending breakdown items.
|
||||
import 'package:billing/src/domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Parameters for [GetSpendBreakdownUseCase].
|
||||
class SpendBreakdownParams {
|
||||
/// Creates [SpendBreakdownParams].
|
||||
const SpendBreakdownParams({
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
});
|
||||
|
||||
/// ISO-8601 start date for the range.
|
||||
final String startDate;
|
||||
|
||||
/// ISO-8601 end date for the range.
|
||||
final String endDate;
|
||||
}
|
||||
|
||||
/// Use case for fetching the spending breakdown by category.
|
||||
///
|
||||
/// This use case encapsulates the logic for retrieving the spending breakdown by category or item.
|
||||
/// It delegates the data retrieval to the [BillingRepository].
|
||||
class GetSpendingBreakdownUseCase
|
||||
extends UseCase<BillingPeriod, List<InvoiceItem>> {
|
||||
/// Creates a [GetSpendingBreakdownUseCase].
|
||||
GetSpendingBreakdownUseCase(this._repository);
|
||||
/// Delegates data retrieval to the [BillingRepository].
|
||||
class GetSpendBreakdownUseCase
|
||||
extends UseCase<SpendBreakdownParams, List<SpendItem>> {
|
||||
/// Creates a [GetSpendBreakdownUseCase].
|
||||
GetSpendBreakdownUseCase(this._repository);
|
||||
|
||||
/// The billing repository.
|
||||
final BillingRepository _repository;
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> call(BillingPeriod period) =>
|
||||
_repository.getSpendingBreakdown(period);
|
||||
Future<List<SpendItem>> call(SpendBreakdownParams input) =>
|
||||
_repository.getSpendBreakdown(
|
||||
startDate: input.startDate,
|
||||
endDate: input.endDate,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
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';
|
||||
import '../../domain/usecases/get_current_bill_amount.dart';
|
||||
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 '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
import 'billing_event.dart';
|
||||
import 'billing_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/get_bank_accounts.dart';
|
||||
import 'package:billing/src/domain/usecases/get_current_bill_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_invoice_history.dart';
|
||||
import 'package:billing/src/domain/usecases/get_pending_invoices.dart';
|
||||
import 'package:billing/src/domain/usecases/get_savings_amount.dart';
|
||||
import 'package:billing/src/domain/usecases/get_spending_breakdown.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// BLoC for managing billing state and data loading.
|
||||
class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
@@ -23,14 +23,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
required GetSavingsAmountUseCase getSavingsAmount,
|
||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendingBreakdown = getSpendingBreakdown,
|
||||
super(const BillingState()) {
|
||||
required GetSpendBreakdownUseCase getSpendBreakdown,
|
||||
}) : _getBankAccounts = getBankAccounts,
|
||||
_getCurrentBillAmount = getCurrentBillAmount,
|
||||
_getSavingsAmount = getSavingsAmount,
|
||||
_getPendingInvoices = getPendingInvoices,
|
||||
_getInvoiceHistory = getInvoiceHistory,
|
||||
_getSpendBreakdown = getSpendBreakdown,
|
||||
super(const BillingState()) {
|
||||
on<BillingLoadStarted>(_onLoadStarted);
|
||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||
}
|
||||
@@ -40,61 +40,60 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
||||
final GetSpendBreakdownUseCase _getSpendBreakdown;
|
||||
|
||||
/// Executes [loader] and returns null on failure, logging the error.
|
||||
Future<T?> _loadSafe<T>(Future<T> Function() loader) async {
|
||||
try {
|
||||
return await loader();
|
||||
} catch (e, stackTrace) {
|
||||
developer.log(
|
||||
'Partial billing load failed: $e',
|
||||
name: 'BillingBloc',
|
||||
error: e,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLoadStarted(
|
||||
BillingLoadStarted event,
|
||||
Emitter<BillingState> emit,
|
||||
) async {
|
||||
emit(state.copyWith(status: BillingStatus.loading));
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<dynamic> results =
|
||||
await Future.wait<dynamic>(<Future<dynamic>>[
|
||||
_getCurrentBillAmount.call(),
|
||||
_getSavingsAmount.call(),
|
||||
_getPendingInvoices.call(),
|
||||
_getInvoiceHistory.call(),
|
||||
_getSpendingBreakdown.call(state.period),
|
||||
_getBankAccounts.call(),
|
||||
]);
|
||||
|
||||
final double savings = results[1] as double;
|
||||
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
||||
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
||||
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
||||
final List<BusinessBankAccount> bankAccounts =
|
||||
results[5] as List<BusinessBankAccount>;
|
||||
final SpendBreakdownParams spendParams = _dateRangeFor(state.periodTab);
|
||||
|
||||
// Map Domain Entities to Presentation Models
|
||||
final List<BillingInvoice> uiPendingInvoices = pendingInvoices
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<BillingInvoice> uiInvoiceHistory = invoiceHistory
|
||||
.map(_mapInvoiceToUiModel)
|
||||
.toList();
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final List<Object?> results = await Future.wait<Object?>(
|
||||
<Future<Object?>>[
|
||||
_loadSafe<int>(() => _getCurrentBillAmount.call()),
|
||||
_loadSafe<int>(() => _getSavingsAmount.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getPendingInvoices.call()),
|
||||
_loadSafe<List<Invoice>>(() => _getInvoiceHistory.call()),
|
||||
_loadSafe<List<SpendItem>>(() => _getSpendBreakdown.call(spendParams)),
|
||||
_loadSafe<List<BillingAccount>>(() => _getBankAccounts.call()),
|
||||
],
|
||||
);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBill: periodTotal,
|
||||
savings: savings,
|
||||
pendingInvoices: uiPendingInvoices,
|
||||
invoiceHistory: uiInvoiceHistory,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
bankAccounts: bankAccounts,
|
||||
),
|
||||
);
|
||||
},
|
||||
onError: (String errorKey) =>
|
||||
state.copyWith(status: BillingStatus.failure, errorMessage: errorKey),
|
||||
final int? currentBillCents = results[0] as int?;
|
||||
final int? savingsCents = results[1] as int?;
|
||||
final List<Invoice>? pendingInvoices = results[2] as List<Invoice>?;
|
||||
final List<Invoice>? invoiceHistory = results[3] as List<Invoice>?;
|
||||
final List<SpendItem>? spendBreakdown = results[4] as List<SpendItem>?;
|
||||
final List<BillingAccount>? bankAccounts =
|
||||
results[5] as List<BillingAccount>?;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: BillingStatus.success,
|
||||
currentBillCents: currentBillCents ?? state.currentBillCents,
|
||||
savingsCents: savingsCents ?? state.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? state.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? state.invoiceHistory,
|
||||
spendBreakdown: spendBreakdown ?? state.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? state.bankAccounts,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,19 +104,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
await handleError(
|
||||
emit: emit.call,
|
||||
action: () async {
|
||||
final List<InvoiceItem> spendingItems = await _getSpendingBreakdown
|
||||
.call(event.period);
|
||||
final List<SpendingBreakdownItem> uiSpendingBreakdown =
|
||||
_mapSpendingItemsToUiModel(spendingItems);
|
||||
final double periodTotal = uiSpendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final SpendBreakdownParams params =
|
||||
_dateRangeFor(event.periodTab);
|
||||
final List<SpendItem> spendBreakdown =
|
||||
await _getSpendBreakdown.call(params);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
period: event.period,
|
||||
spendingBreakdown: uiSpendingBreakdown,
|
||||
currentBill: periodTotal,
|
||||
periodTab: event.periodTab,
|
||||
spendBreakdown: spendBreakdown,
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -126,98 +121,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
||||
);
|
||||
}
|
||||
|
||||
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.issueDate == null
|
||||
? 'N/A'
|
||||
: formatter.format(invoice.issueDate!);
|
||||
|
||||
final List<BillingWorkerRecord> workers = invoice.workers.map((
|
||||
InvoiceWorker w,
|
||||
) {
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
return BillingWorkerRecord(
|
||||
workerName: w.name,
|
||||
roleName: w.role,
|
||||
totalAmount: w.amount,
|
||||
hours: w.hours,
|
||||
rate: w.rate,
|
||||
startTime: w.checkIn != null ? timeFormat.format(w.checkIn!) : '--:--',
|
||||
endTime: w.checkOut != null ? timeFormat.format(w.checkOut!) : '--:--',
|
||||
breakMinutes: w.breakMinutes,
|
||||
workerAvatarUrl: w.avatarUrl,
|
||||
);
|
||||
}).toList();
|
||||
|
||||
String? overallStart;
|
||||
String? overallEnd;
|
||||
|
||||
// Find valid times from actual DateTime checks to ensure chronological sorting
|
||||
final List<DateTime> validCheckIns = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkIn != null)
|
||||
.map((InvoiceWorker w) => w.checkIn!)
|
||||
.toList();
|
||||
final List<DateTime> validCheckOuts = invoice.workers
|
||||
.where((InvoiceWorker w) => w.checkOut != null)
|
||||
.map((InvoiceWorker w) => w.checkOut!)
|
||||
.toList();
|
||||
|
||||
final DateFormat timeFormat = DateFormat('h:mm a');
|
||||
|
||||
if (validCheckIns.isNotEmpty) {
|
||||
validCheckIns.sort();
|
||||
overallStart = timeFormat.format(validCheckIns.first);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallStart = workers.first.startTime;
|
||||
}
|
||||
|
||||
if (validCheckOuts.isNotEmpty) {
|
||||
validCheckOuts.sort();
|
||||
overallEnd = timeFormat.format(validCheckOuts.last);
|
||||
} else if (workers.isNotEmpty) {
|
||||
overallEnd = workers.first.endTime;
|
||||
}
|
||||
|
||||
return BillingInvoice(
|
||||
id: invoice.id,
|
||||
title: invoice.title ?? 'N/A',
|
||||
locationAddress: invoice.locationAddress ?? 'Remote',
|
||||
clientName: invoice.clientName ?? 'N/A',
|
||||
date: dateLabel,
|
||||
totalAmount: invoice.totalAmount,
|
||||
workersCount: invoice.staffCount ?? 0,
|
||||
totalHours: invoice.totalHours ?? 0.0,
|
||||
status: invoice.status.name.toUpperCase(),
|
||||
workers: workers,
|
||||
startTime: overallStart,
|
||||
endTime: overallEnd,
|
||||
/// Computes ISO-8601 date range for the selected period tab.
|
||||
SpendBreakdownParams _dateRangeFor(BillingPeriodTab tab) {
|
||||
final DateTime now = DateTime.now().toUtc();
|
||||
final int days = tab == BillingPeriodTab.week ? 7 : 30;
|
||||
final DateTime start = now.subtract(Duration(days: days));
|
||||
return SpendBreakdownParams(
|
||||
startDate: start.toIso8601String(),
|
||||
endDate: now.toIso8601String(),
|
||||
);
|
||||
}
|
||||
|
||||
List<SpendingBreakdownItem> _mapSpendingItemsToUiModel(
|
||||
List<InvoiceItem> items,
|
||||
) {
|
||||
final Map<String, SpendingBreakdownItem> aggregation =
|
||||
<String, SpendingBreakdownItem>{};
|
||||
|
||||
for (final InvoiceItem item in items) {
|
||||
final String category = item.staffId;
|
||||
final SpendingBreakdownItem? existing = aggregation[category];
|
||||
if (existing != null) {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: existing.hours + item.workHours.round(),
|
||||
amount: existing.amount + item.amount,
|
||||
);
|
||||
} else {
|
||||
aggregation[category] = SpendingBreakdownItem(
|
||||
category: category,
|
||||
hours: item.workHours.round(),
|
||||
amount: item.amount,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return aggregation.values.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Base class for all billing events.
|
||||
abstract class BillingEvent extends Equatable {
|
||||
@@ -16,11 +17,14 @@ class BillingLoadStarted extends BillingEvent {
|
||||
const BillingLoadStarted();
|
||||
}
|
||||
|
||||
/// Event triggered when the spend breakdown period tab changes.
|
||||
class BillingPeriodChanged extends BillingEvent {
|
||||
const BillingPeriodChanged(this.period);
|
||||
/// Creates a [BillingPeriodChanged] event.
|
||||
const BillingPeriodChanged(this.periodTab);
|
||||
|
||||
final BillingPeriod period;
|
||||
/// The selected period tab.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[period];
|
||||
List<Object?> get props => <Object?>[periodTab];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
/// The loading status of the billing feature.
|
||||
enum BillingStatus {
|
||||
@@ -18,83 +16,104 @@ enum BillingStatus {
|
||||
failure,
|
||||
}
|
||||
|
||||
/// Which period the spend breakdown covers.
|
||||
enum BillingPeriodTab {
|
||||
/// Last 7 days.
|
||||
week,
|
||||
|
||||
/// Last 30 days.
|
||||
month,
|
||||
}
|
||||
|
||||
/// Represents the state of the billing feature.
|
||||
class BillingState extends Equatable {
|
||||
/// Creates a [BillingState].
|
||||
const BillingState({
|
||||
this.status = BillingStatus.initial,
|
||||
this.currentBill = 0.0,
|
||||
this.savings = 0.0,
|
||||
this.pendingInvoices = const <BillingInvoice>[],
|
||||
this.invoiceHistory = const <BillingInvoice>[],
|
||||
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
||||
this.bankAccounts = const <BusinessBankAccount>[],
|
||||
this.period = BillingPeriod.week,
|
||||
this.currentBillCents = 0,
|
||||
this.savingsCents = 0,
|
||||
this.pendingInvoices = const <Invoice>[],
|
||||
this.invoiceHistory = const <Invoice>[],
|
||||
this.spendBreakdown = const <SpendItem>[],
|
||||
this.bankAccounts = const <BillingAccount>[],
|
||||
this.periodTab = BillingPeriodTab.week,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
/// The current feature status.
|
||||
final BillingStatus status;
|
||||
|
||||
/// The total amount for the current billing period.
|
||||
final double currentBill;
|
||||
/// The total amount for the current billing period in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// Total savings achieved compared to traditional agencies.
|
||||
final double savings;
|
||||
/// Total savings in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Invoices awaiting client approval.
|
||||
final List<BillingInvoice> pendingInvoices;
|
||||
final List<Invoice> pendingInvoices;
|
||||
|
||||
/// History of paid invoices.
|
||||
final List<BillingInvoice> invoiceHistory;
|
||||
final List<Invoice> invoiceHistory;
|
||||
|
||||
/// Breakdown of spending by category.
|
||||
final List<SpendingBreakdownItem> spendingBreakdown;
|
||||
final List<SpendItem> spendBreakdown;
|
||||
|
||||
/// Bank accounts associated with the business.
|
||||
final List<BusinessBankAccount> bankAccounts;
|
||||
final List<BillingAccount> bankAccounts;
|
||||
|
||||
/// Selected period for the breakdown.
|
||||
final BillingPeriod period;
|
||||
/// Selected period tab for the breakdown.
|
||||
final BillingPeriodTab periodTab;
|
||||
|
||||
/// Error message if loading failed.
|
||||
final String? errorMessage;
|
||||
|
||||
/// Current bill formatted as dollars.
|
||||
double get currentBillDollars => currentBillCents / 100.0;
|
||||
|
||||
/// Savings formatted as dollars.
|
||||
double get savingsDollars => savingsCents / 100.0;
|
||||
|
||||
/// Total spend across the breakdown in cents.
|
||||
int get spendTotalCents => spendBreakdown.fold(
|
||||
0,
|
||||
(int sum, SpendItem item) => sum + item.amountCents,
|
||||
);
|
||||
|
||||
/// Creates a copy of this state with updated fields.
|
||||
BillingState copyWith({
|
||||
BillingStatus? status,
|
||||
double? currentBill,
|
||||
double? savings,
|
||||
List<BillingInvoice>? pendingInvoices,
|
||||
List<BillingInvoice>? invoiceHistory,
|
||||
List<SpendingBreakdownItem>? spendingBreakdown,
|
||||
List<BusinessBankAccount>? bankAccounts,
|
||||
BillingPeriod? period,
|
||||
int? currentBillCents,
|
||||
int? savingsCents,
|
||||
List<Invoice>? pendingInvoices,
|
||||
List<Invoice>? invoiceHistory,
|
||||
List<SpendItem>? spendBreakdown,
|
||||
List<BillingAccount>? bankAccounts,
|
||||
BillingPeriodTab? periodTab,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return BillingState(
|
||||
status: status ?? this.status,
|
||||
currentBill: currentBill ?? this.currentBill,
|
||||
savings: savings ?? this.savings,
|
||||
currentBillCents: currentBillCents ?? this.currentBillCents,
|
||||
savingsCents: savingsCents ?? this.savingsCents,
|
||||
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
||||
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
||||
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
||||
spendBreakdown: spendBreakdown ?? this.spendBreakdown,
|
||||
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||
period: period ?? this.period,
|
||||
periodTab: periodTab ?? this.periodTab,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
status,
|
||||
currentBill,
|
||||
savings,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendingBreakdown,
|
||||
bankAccounts,
|
||||
period,
|
||||
errorMessage,
|
||||
];
|
||||
status,
|
||||
currentBillCents,
|
||||
savingsCents,
|
||||
pendingInvoices,
|
||||
invoiceHistory,
|
||||
spendBreakdown,
|
||||
bankAccounts,
|
||||
periodTab,
|
||||
errorMessage,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import '../../../domain/usecases/approve_invoice.dart';
|
||||
import '../../../domain/usecases/dispute_invoice.dart';
|
||||
import 'shift_completion_review_event.dart';
|
||||
import 'shift_completion_review_state.dart';
|
||||
|
||||
import 'package:billing/src/domain/usecases/approve_invoice.dart';
|
||||
import 'package:billing/src/domain/usecases/dispute_invoice.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// BLoC for approving or disputing an invoice from the review page.
|
||||
class ShiftCompletionReviewBloc
|
||||
extends Bloc<ShiftCompletionReviewEvent, ShiftCompletionReviewState>
|
||||
with BlocErrorHandler<ShiftCompletionReviewState> {
|
||||
/// Creates a [ShiftCompletionReviewBloc].
|
||||
ShiftCompletionReviewBloc({
|
||||
required ApproveInvoiceUseCase approveInvoice,
|
||||
required DisputeInvoiceUseCase disputeInvoice,
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
}) : _approveInvoice = approveInvoice,
|
||||
_disputeInvoice = disputeInvoice,
|
||||
super(const ShiftCompletionReviewState()) {
|
||||
on<ShiftCompletionReviewApproved>(_onApproved);
|
||||
on<ShiftCompletionReviewDisputed>(_onDisputed);
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class BillingInvoice extends Equatable {
|
||||
const BillingInvoice({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.locationAddress,
|
||||
required this.clientName,
|
||||
required this.date,
|
||||
required this.totalAmount,
|
||||
required this.workersCount,
|
||||
required this.totalHours,
|
||||
required this.status,
|
||||
this.workers = const <BillingWorkerRecord>[],
|
||||
this.startTime,
|
||||
this.endTime,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String locationAddress;
|
||||
final String clientName;
|
||||
final String date;
|
||||
final double totalAmount;
|
||||
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?>[
|
||||
id,
|
||||
title,
|
||||
locationAddress,
|
||||
clientName,
|
||||
date,
|
||||
totalAmount,
|
||||
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 => <Object?>[
|
||||
workerName,
|
||||
roleName,
|
||||
totalAmount,
|
||||
hours,
|
||||
rate,
|
||||
startTime,
|
||||
endTime,
|
||||
breakMinutes,
|
||||
workerAvatarUrl,
|
||||
];
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
/// Represents a single item in the spending breakdown.
|
||||
class SpendingBreakdownItem extends Equatable {
|
||||
/// Creates a [SpendingBreakdownItem].
|
||||
const SpendingBreakdownItem({
|
||||
required this.category,
|
||||
required this.hours,
|
||||
required this.amount,
|
||||
});
|
||||
|
||||
/// The category name (e.g., "Server Staff").
|
||||
final String category;
|
||||
|
||||
/// The total hours worked in this category.
|
||||
final int hours;
|
||||
|
||||
/// The total amount spent in this category.
|
||||
final double amount;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[category, hours, amount];
|
||||
}
|
||||
@@ -5,13 +5,13 @@ 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 '../blocs/billing_state.dart';
|
||||
import '../widgets/billing_page_skeleton.dart';
|
||||
import '../widgets/invoice_history_section.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import '../widgets/spending_breakdown_card.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/billing_page_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoice_history_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/widgets/spending_breakdown_card.dart';
|
||||
|
||||
/// The entry point page for the client billing feature.
|
||||
///
|
||||
@@ -32,8 +32,7 @@ class BillingPage extends StatelessWidget {
|
||||
|
||||
/// The main view for the client billing feature.
|
||||
///
|
||||
/// This widget displays the billing dashboard content based on the current
|
||||
/// state of the [BillingBloc].
|
||||
/// Displays the billing dashboard content based on the current [BillingState].
|
||||
class BillingView extends StatefulWidget {
|
||||
/// Creates a [BillingView].
|
||||
const BillingView({super.key});
|
||||
@@ -125,7 +124,7 @@ class _BillingViewState extends State<BillingView> {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||
'\$${state.currentBillDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.displayM.copyWith(
|
||||
color: UiColors.white,
|
||||
fontSize: 40,
|
||||
@@ -152,7 +151,8 @@ class _BillingViewState extends State<BillingView> {
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: state.savings.toStringAsFixed(0),
|
||||
amount: state.savingsDollars
|
||||
.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.accentForeground,
|
||||
@@ -221,7 +221,6 @@ class _BillingViewState extends State<BillingView> {
|
||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||
],
|
||||
// const PaymentMethodCard(),
|
||||
const SpendingBreakdownCard(),
|
||||
if (state.invoiceHistory.isNotEmpty)
|
||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
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:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/completion_review/completion_review_actions.dart';
|
||||
import '../widgets/completion_review/completion_review_amount.dart';
|
||||
import '../widgets/completion_review/completion_review_info.dart';
|
||||
import '../widgets/completion_review/completion_review_search_and_tabs.dart';
|
||||
import '../widgets/completion_review/completion_review_worker_card.dart';
|
||||
import '../widgets/completion_review/completion_review_workers_header.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_actions.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_amount.dart';
|
||||
import 'package:billing/src/presentation/widgets/completion_review/completion_review_info.dart';
|
||||
|
||||
/// Page for reviewing and approving/disputing an invoice.
|
||||
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
/// Creates a [ShiftCompletionReviewPage].
|
||||
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||
|
||||
final BillingInvoice? invoice;
|
||||
/// The invoice to review.
|
||||
final Invoice? invoice;
|
||||
|
||||
@override
|
||||
State<ShiftCompletionReviewPage> createState() =>
|
||||
@@ -21,31 +23,45 @@ class ShiftCompletionReviewPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
late BillingInvoice invoice;
|
||||
String searchQuery = '';
|
||||
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
|
||||
/// The resolved invoice, or null if route data is missing/invalid.
|
||||
late final Invoice? invoice;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Use widget.invoice if provided, else try to get from arguments
|
||||
invoice = widget.invoice ?? Modular.args.data as BillingInvoice;
|
||||
invoice = widget.invoice ??
|
||||
(Modular.args.data is Invoice
|
||||
? Modular.args.data as Invoice
|
||||
: null);
|
||||
}
|
||||
|
||||
@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();
|
||||
final Invoice? resolvedInvoice = invoice;
|
||||
if (resolvedInvoice == null) {
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: t.client_billing.review_and_approve,
|
||||
showBackButton: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Text(
|
||||
t.errors.generic.unknown,
|
||||
style: UiTypography.body1m.textError,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = resolvedInvoice.dueDate != null
|
||||
? formatter.format(resolvedInvoice.dueDate!)
|
||||
: 'N/A';
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(
|
||||
title: invoice.title,
|
||||
subtitle: invoice.clientName,
|
||||
title: resolvedInvoice.invoiceNumber,
|
||||
subtitle: resolvedInvoice.vendorName ?? '',
|
||||
showBackButton: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
@@ -55,26 +71,13 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewInfo(invoice: invoice),
|
||||
CompletionReviewInfo(
|
||||
dateLabel: dateLabel,
|
||||
vendorName: resolvedInvoice.vendorName,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
CompletionReviewAmount(invoice: invoice),
|
||||
CompletionReviewAmount(amountCents: resolvedInvoice.amountCents),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
// CompletionReviewWorkersHeader(workersCount: invoice.workersCount),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// CompletionReviewSearchAndTabs(
|
||||
// selectedTab: selectedTab,
|
||||
// workersCount: invoice.workersCount,
|
||||
// onTabChanged: (int index) =>
|
||||
// setState(() => selectedTab = index),
|
||||
// onSearchChanged: (String val) =>
|
||||
// setState(() => searchQuery = val),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
// ...filteredWorkers.map(
|
||||
// (BillingWorkerRecord worker) =>
|
||||
// CompletionReviewWorkerCard(worker: worker),
|
||||
// ),
|
||||
// const SizedBox(height: UiConstants.space4),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -87,7 +90,9 @@ class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||
top: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
),
|
||||
child: SafeArea(child: CompletionReviewActions(invoiceId: invoice.id)),
|
||||
child: SafeArea(
|
||||
child: CompletionReviewActions(invoiceId: resolvedInvoice.invoiceId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,14 +2,17 @@ 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:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
|
||||
/// Page displaying invoices that are ready.
|
||||
class InvoiceReadyPage extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyPage].
|
||||
const InvoiceReadyPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -21,7 +24,9 @@ class InvoiceReadyPage extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// View for the invoice ready page.
|
||||
class InvoiceReadyView extends StatelessWidget {
|
||||
/// Creates an [InvoiceReadyView].
|
||||
const InvoiceReadyView({super.key});
|
||||
|
||||
@override
|
||||
@@ -60,7 +65,7 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
separatorBuilder: (BuildContext context, int index) =>
|
||||
const SizedBox(height: 16),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final BillingInvoice invoice = state.invoiceHistory[index];
|
||||
final Invoice invoice = state.invoiceHistory[index];
|
||||
return _InvoiceSummaryCard(invoice: invoice);
|
||||
},
|
||||
);
|
||||
@@ -72,10 +77,17 @@ class InvoiceReadyView extends StatelessWidget {
|
||||
|
||||
class _InvoiceSummaryCard extends StatelessWidget {
|
||||
const _InvoiceSummaryCard({required this.invoice});
|
||||
final BillingInvoice invoice;
|
||||
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
@@ -106,22 +118,26 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'READY',
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(invoice.date, style: UiTypography.footnote2r.textTertiary),
|
||||
Text(dateLabel, 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,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.title2b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (invoice.vendorName != null)
|
||||
Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
const Divider(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
@@ -134,7 +150,7 @@ class _InvoiceSummaryCard extends StatelessWidget {
|
||||
style: UiTypography.titleUppercase4m.textSecondary,
|
||||
),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.title2b.primary,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,12 +5,14 @@ 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/invoices_list_skeleton.dart';
|
||||
import '../widgets/pending_invoices_section.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
import 'package:billing/src/presentation/widgets/invoices_list_skeleton.dart';
|
||||
import 'package:billing/src/presentation/widgets/pending_invoices_section.dart';
|
||||
|
||||
/// Page listing all invoices awaiting client approval.
|
||||
class PendingInvoicesPage extends StatelessWidget {
|
||||
/// Creates a [PendingInvoicesPage].
|
||||
const PendingInvoicesPage({super.key});
|
||||
|
||||
@override
|
||||
@@ -44,7 +46,7 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
100, // Bottom padding for scroll clearance
|
||||
100,
|
||||
),
|
||||
itemCount: state.pendingInvoices.length,
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
@@ -87,6 +89,3 @@ class PendingInvoicesPage extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -6,23 +6,26 @@ import 'package:flutter/material.dart';
|
||||
class BillingHeader extends StatelessWidget {
|
||||
/// Creates a [BillingHeader].
|
||||
const BillingHeader({
|
||||
required this.currentBill,
|
||||
required this.savings,
|
||||
required this.currentBillCents,
|
||||
required this.savingsCents,
|
||||
required this.onBack,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The amount of the current bill.
|
||||
final double currentBill;
|
||||
/// The amount of the current bill in cents.
|
||||
final int currentBillCents;
|
||||
|
||||
/// The amount saved in the current period.
|
||||
final double savings;
|
||||
/// The savings amount in cents.
|
||||
final int savingsCents;
|
||||
|
||||
/// Callback when the back button is pressed.
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double billDollars = currentBillCents / 100.0;
|
||||
final double savingsDollars = savingsCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
@@ -54,10 +57,9 @@ class BillingHeader extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${currentBill.toStringAsFixed(2)}',
|
||||
'\$${billDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.display1b.copyWith(color: UiColors.white),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -79,7 +81,7 @@ class BillingHeader extends StatelessWidget {
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
t.client_billing.saved_amount(
|
||||
amount: savings.toStringAsFixed(0),
|
||||
amount: savingsDollars.toStringAsFixed(0),
|
||||
),
|
||||
style: UiTypography.footnote2b.copyWith(
|
||||
color: UiColors.foreground,
|
||||
|
||||
@@ -5,87 +5,91 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import '../../blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
import '../../blocs/billing_bloc.dart';
|
||||
import '../../blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/shift_completion_review/shift_completion_review_state.dart';
|
||||
|
||||
/// Action buttons (approve / flag) at the bottom of the review page.
|
||||
class CompletionReviewActions extends StatelessWidget {
|
||||
/// Creates a [CompletionReviewActions].
|
||||
const CompletionReviewActions({required this.invoiceId, super.key});
|
||||
|
||||
/// The invoice ID to act upon.
|
||||
final String invoiceId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<ShiftCompletionReviewBloc>.value(
|
||||
value: Modular.get<ShiftCompletionReviewBloc>(),
|
||||
return BlocProvider<ShiftCompletionReviewBloc>(
|
||||
create: (_) => Modular.get<ShiftCompletionReviewBloc>(),
|
||||
child:
|
||||
BlocConsumer<ShiftCompletionReviewBloc, ShiftCompletionReviewState>(
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
listener: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
if (state.status == ShiftCompletionReviewStatus.success) {
|
||||
final String message = state.message == 'approved'
|
||||
? t.client_billing.approved_success
|
||||
: t.client_billing.flagged_success;
|
||||
final UiSnackbarType type = state.message == 'approved'
|
||||
? UiSnackbarType.success
|
||||
: UiSnackbarType.warning;
|
||||
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
UiSnackbar.show(context, message: message, type: type);
|
||||
Modular.get<BillingBloc>().add(const BillingLoadStarted());
|
||||
Modular.to.toAwaitingApproval();
|
||||
} else if (state.status == ShiftCompletionReviewStatus.failure) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage ?? t.errors.generic.unknown,
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, ShiftCompletionReviewState state) {
|
||||
final bool isLoading =
|
||||
state.status == ShiftCompletionReviewStatus.loading;
|
||||
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
),
|
||||
return Row(
|
||||
spacing: UiConstants.space2,
|
||||
children: <Widget>[
|
||||
Expanded(
|
||||
child: UiButton.secondary(
|
||||
text: t.client_billing.actions.flag_review,
|
||||
leadingIcon: UiIcons.warning,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () => _showFlagDialog(context, state),
|
||||
size: UiButtonSize.large,
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: UiColors.destructive,
|
||||
side: BorderSide.none,
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: UiButton.primary(
|
||||
text: t.client_billing.actions.approve_pay,
|
||||
leadingIcon: isLoading ? null : UiIcons.checkCircle,
|
||||
isLoading: isLoading,
|
||||
onPressed: isLoading
|
||||
? null
|
||||
: () {
|
||||
BlocProvider.of<ShiftCompletionReviewBloc>(
|
||||
context,
|
||||
).add(ShiftCompletionReviewApproved(invoiceId));
|
||||
},
|
||||
size: UiButtonSize.large,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _showFlagDialog(BuildContext context, ShiftCompletionReviewState state) {
|
||||
void _showFlagDialog(
|
||||
BuildContext context, ShiftCompletionReviewState state) {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
|
||||
@@ -2,15 +2,18 @@ import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays the total invoice amount on the review page.
|
||||
class CompletionReviewAmount extends StatelessWidget {
|
||||
const CompletionReviewAmount({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewAmount].
|
||||
const CompletionReviewAmount({required this.amountCents, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice total in cents.
|
||||
final int amountCents;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double amountDollars = amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(UiConstants.space6),
|
||||
@@ -27,13 +30,9 @@ class CompletionReviewAmount extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Displays invoice metadata (date, vendor) on the review page.
|
||||
class CompletionReviewInfo extends StatelessWidget {
|
||||
const CompletionReviewInfo({required this.invoice, super.key});
|
||||
/// Creates a [CompletionReviewInfo].
|
||||
const CompletionReviewInfo({
|
||||
required this.dateLabel,
|
||||
this.vendorName,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// Formatted date string.
|
||||
final String dateLabel;
|
||||
|
||||
/// Vendor name, if available.
|
||||
final String? vendorName;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -14,12 +22,9 @@ class CompletionReviewInfo extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: UiConstants.space1,
|
||||
children: <Widget>[
|
||||
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||
_buildInfoRow(
|
||||
UiIcons.clock,
|
||||
'${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}',
|
||||
),
|
||||
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||
_buildInfoRow(UiIcons.calendar, dateLabel),
|
||||
if (vendorName != null)
|
||||
_buildInfoRow(UiIcons.building, vendorName!),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,126 +1,18 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/billing_invoice_model.dart';
|
||||
|
||||
/// Card showing a single worker's details in the completion review.
|
||||
///
|
||||
/// Currently unused -- the V2 Invoice entity does not include per-worker
|
||||
/// breakdown data. This widget is retained as a placeholder for when the
|
||||
/// backend adds worker-level invoice detail endpoints.
|
||||
class CompletionReviewWorkerCard extends StatelessWidget {
|
||||
const CompletionReviewWorkerCard({required this.worker, super.key});
|
||||
|
||||
final BillingWorkerRecord worker;
|
||||
/// Creates a [CompletionReviewWorkerCard].
|
||||
const CompletionReviewWorkerCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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.withValues(alpha: 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: <Widget>[
|
||||
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: () {}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
// Placeholder until V2 API provides worker-level invoice data.
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing the history of paid invoices.
|
||||
class InvoiceHistorySection extends StatelessWidget {
|
||||
@@ -9,7 +10,7 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
const InvoiceHistorySection({required this.invoices, super.key});
|
||||
|
||||
/// The list of historical invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -36,10 +37,10 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: invoices.asMap().entries.map((
|
||||
MapEntry<int, BillingInvoice> entry,
|
||||
MapEntry<int, Invoice> entry,
|
||||
) {
|
||||
final int index = entry.key;
|
||||
final BillingInvoice invoice = entry.value;
|
||||
final Invoice invoice = entry.value;
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (index > 0)
|
||||
@@ -58,10 +59,18 @@ class InvoiceHistorySection extends StatelessWidget {
|
||||
class _InvoiceItem extends StatelessWidget {
|
||||
const _InvoiceItem({required this.invoice});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('MMM d, yyyy');
|
||||
final String dateLabel = invoice.paymentDate != null
|
||||
? formatter.format(invoice.paymentDate!)
|
||||
: invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
@@ -86,11 +95,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.body1r.textPrimary),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.body1r.textPrimary,
|
||||
),
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,7 +107,7 @@ class _InvoiceItem extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||
),
|
||||
_StatusBadge(status: invoice.status),
|
||||
@@ -113,11 +122,11 @@ class _InvoiceItem extends StatelessWidget {
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
const _StatusBadge({required this.status});
|
||||
|
||||
final String status;
|
||||
final InvoiceStatus status;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool isPaid = status.toUpperCase() == 'PAID';
|
||||
final bool isPaid = status == InvoiceStatus.paid;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space1 + 2,
|
||||
|
||||
@@ -3,8 +3,9 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the current payment method.
|
||||
class PaymentMethodCard extends StatelessWidget {
|
||||
@@ -15,8 +16,8 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final List<BusinessBankAccount> accounts = state.bankAccounts;
|
||||
final BusinessBankAccount? account =
|
||||
final List<BillingAccount> accounts = state.bankAccounts;
|
||||
final BillingAccount? account =
|
||||
accounts.isNotEmpty ? accounts.first : null;
|
||||
|
||||
if (account == null) {
|
||||
@@ -24,11 +25,10 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
final String bankLabel =
|
||||
account.bankName.isNotEmpty == true ? account.bankName : '----';
|
||||
account.bankName.isNotEmpty ? account.bankName : '----';
|
||||
final String last4 =
|
||||
account.last4.isNotEmpty == true ? account.last4 : '----';
|
||||
account.last4?.isNotEmpty == true ? account.last4! : '----';
|
||||
final bool isPrimary = account.isPrimary;
|
||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -87,11 +87,11 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
'•••• $last4',
|
||||
'\u2022\u2022\u2022\u2022 $last4',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
t.client_billing.expires(date: expiryLabel),
|
||||
account.accountType.name.toUpperCase(),
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
@@ -121,13 +121,4 @@ class PaymentMethodCard extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _formatExpiry(DateTime? expiryTime) {
|
||||
if (expiryTime == null) {
|
||||
return 'N/A';
|
||||
}
|
||||
final String month = expiryTime.month.toString().padLeft(2, '0');
|
||||
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
|
||||
return '$month/$year';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ 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:intl/intl.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Section showing a banner for invoices awaiting approval.
|
||||
class PendingInvoicesSection extends StatelessWidget {
|
||||
@@ -12,7 +12,7 @@ class PendingInvoicesSection extends StatelessWidget {
|
||||
const PendingInvoicesSection({required this.invoices, super.key});
|
||||
|
||||
/// The list of pending invoices.
|
||||
final List<BillingInvoice> invoices;
|
||||
final List<Invoice> invoices;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -93,10 +93,17 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
/// Creates a [PendingInvoiceCard].
|
||||
const PendingInvoiceCard({required this.invoice, super.key});
|
||||
|
||||
final BillingInvoice invoice;
|
||||
/// The invoice to display.
|
||||
final Invoice invoice;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||
final String dateLabel = invoice.dueDate != null
|
||||
? formatter.format(invoice.dueDate!)
|
||||
: 'N/A';
|
||||
final double amountDollars = invoice.amountCents / 100.0;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
@@ -108,42 +115,33 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||
Text(
|
||||
invoice.invoiceNumber,
|
||||
style: UiTypography.headline4b.textPrimary,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.mapPin,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.locationAddress,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
if (invoice.vendorName != null) ...<Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.building,
|
||||
size: 16,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
Text(
|
||||
invoice.clientName,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text('•', style: UiTypography.footnote2r.textInactive),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
invoice.date,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
invoice.vendorName!,
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
],
|
||||
Text(dateLabel, style: UiTypography.footnote2r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
@@ -157,7 +155,7 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Text(
|
||||
t.client_billing.pending_badge.toUpperCase(),
|
||||
invoice.status.value.toUpperCase(),
|
||||
style: UiTypography.titleUppercase4b.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
@@ -168,40 +166,10 @@ class PendingInvoiceCard extends StatelessWidget {
|
||||
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)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.users,
|
||||
'${invoice.workersCount}',
|
||||
t.client_billing.stats.workers,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 1,
|
||||
height: 32,
|
||||
color: UiColors.border.withValues(alpha: 0.3),
|
||||
),
|
||||
Expanded(
|
||||
child: _buildStatItem(
|
||||
UiIcons.clock,
|
||||
invoice.totalHours.toStringAsFixed(1),
|
||||
t.client_billing.stats.hrs,
|
||||
),
|
||||
),
|
||||
],
|
||||
child: _buildStatItem(
|
||||
UiIcons.dollar,
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
t.client_billing.stats.total,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: UiColors.border),
|
||||
|
||||
@@ -3,10 +3,10 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
import 'package:billing/src/presentation/blocs/billing_bloc.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_event.dart';
|
||||
import 'package:billing/src/presentation/blocs/billing_state.dart';
|
||||
|
||||
/// Card showing the spending breakdown for the current period.
|
||||
class SpendingBreakdownCard extends StatefulWidget {
|
||||
@@ -37,10 +37,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<BillingBloc, BillingState>(
|
||||
builder: (BuildContext context, BillingState state) {
|
||||
final double total = state.spendingBreakdown.fold(
|
||||
0.0,
|
||||
(double sum, SpendingBreakdownItem item) => sum + item.amount,
|
||||
);
|
||||
final double totalDollars = state.spendTotalCents / 100.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space4),
|
||||
@@ -97,11 +94,12 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
),
|
||||
dividerColor: UiColors.transparent,
|
||||
onTap: (int index) {
|
||||
final BillingPeriod period =
|
||||
index == 0 ? BillingPeriod.week : BillingPeriod.month;
|
||||
ReadContext(context).read<BillingBloc>().add(
|
||||
BillingPeriodChanged(period),
|
||||
);
|
||||
final BillingPeriodTab tab = index == 0
|
||||
? BillingPeriodTab.week
|
||||
: BillingPeriodTab.month;
|
||||
ReadContext(context)
|
||||
.read<BillingBloc>()
|
||||
.add(BillingPeriodChanged(tab));
|
||||
},
|
||||
tabs: <Widget>[
|
||||
Tab(text: t.client_billing.week),
|
||||
@@ -112,8 +110,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
...state.spendingBreakdown.map(
|
||||
(SpendingBreakdownItem item) => _buildBreakdownRow(item),
|
||||
...state.spendBreakdown.map(
|
||||
(SpendItem item) => _buildBreakdownRow(item),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: UiConstants.space2),
|
||||
@@ -127,7 +125,7 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
Text(
|
||||
'\$${total.toStringAsFixed(2)}',
|
||||
'\$${totalDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2b.textPrimary,
|
||||
),
|
||||
],
|
||||
@@ -139,7 +137,8 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBreakdownRow(SpendingBreakdownItem item) {
|
||||
Widget _buildBreakdownRow(SpendItem item) {
|
||||
final double amountDollars = item.amountCents / 100.0;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
||||
child: Row(
|
||||
@@ -151,14 +150,14 @@ class _SpendingBreakdownCardState extends State<SpendingBreakdownCard>
|
||||
children: <Widget>[
|
||||
Text(item.category, style: UiTypography.body2r.textPrimary),
|
||||
Text(
|
||||
t.client_billing.hours(count: item.hours),
|
||||
'${item.percentage.toStringAsFixed(1)}%',
|
||||
style: UiTypography.footnote2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'\$${item.amount.toStringAsFixed(2)}',
|
||||
'\$${amountDollars.toStringAsFixed(2)}',
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user