From bba4054143a16270438ea7bfd07aa3a0cac42ac4 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Fri, 27 Feb 2026 21:32:12 -0500 Subject: [PATCH] feat: Implement hub and role matching for order creation and remove payment, savings, and export sections from the billing page. --- .../src/presentation/pages/billing_page.dart | 95 ++--------- .../widgets/invoice_history_section.dart | 36 +---- .../widgets/pending_invoices_section.dart | 9 +- .../client_create_order_repository_impl.dart | 150 +++++++++++++++++- 4 files changed, 159 insertions(+), 131 deletions(-) diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 01d44775..20b2f0ef 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -9,7 +9,6 @@ import '../blocs/billing_bloc.dart'; import '../blocs/billing_event.dart'; import '../blocs/billing_state.dart'; import '../widgets/invoice_history_section.dart'; -import '../widgets/payment_method_card.dart'; import '../widgets/pending_invoices_section.dart'; import '../widgets/spending_breakdown_card.dart'; @@ -106,14 +105,14 @@ class _BillingViewState extends State { ), title: Text( t.client_billing.title, - style: UiTypography.headline3b.copyWith(color: UiColors.white), + style: UiTypography.headline3b.copyWith( + color: UiColors.white, + ), ), centerTitle: false, flexibleSpace: FlexibleSpaceBar( background: Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space8, - ), + padding: const EdgeInsets.only(bottom: UiConstants.space8), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ @@ -224,90 +223,16 @@ class _BillingViewState extends State { if (state.pendingInvoices.isNotEmpty) ...[ PendingInvoicesSection(invoices: state.pendingInvoices), ], - const PaymentMethodCard(), + // const PaymentMethodCard(), const SpendingBreakdownCard(), - _buildSavingsCard(state.savings), if (state.invoiceHistory.isNotEmpty) InvoiceHistorySection(invoices: state.invoiceHistory), - - _buildExportButton(), - const SizedBox(height: UiConstants.space12), + const SizedBox(height: UiConstants.space16), ], ), ); } - 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( @@ -361,11 +286,15 @@ class _InvoicesReadyBanner extends StatelessWidget { children: [ Text( t.client_billing.invoices_ready_title, - style: UiTypography.body1b.copyWith(color: UiColors.success), + style: UiTypography.body1b.copyWith( + color: UiColors.success, + ), ), Text( t.client_billing.invoices_ready_subtitle, - style: UiTypography.footnote2r.copyWith(color: UiColors.success), + style: UiTypography.footnote2r.copyWith( + color: UiColors.success, + ), ), ], ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index 6102aa4c..55096618 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -14,33 +14,11 @@ class InvoiceHistorySection extends StatelessWidget { @override Widget build(BuildContext context) { return Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_billing.invoice_history, - style: UiTypography.title2b.textPrimary, - ), - 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), - ], - ), - ), - ], + Text( + t.client_billing.invoice_history, + style: UiTypography.title2b.textPrimary, ), const SizedBox(height: UiConstants.space3), Container( @@ -129,12 +107,6 @@ class _InvoiceItem extends StatelessWidget { _StatusBadge(status: invoice.status), ], ), - const SizedBox(width: UiConstants.space4), - Icon( - UiIcons.download, - size: 20, - color: UiColors.iconSecondary.withOpacity(0.3), - ), ], ), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 2905f6b8..9a36922f 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -21,18 +21,11 @@ class PendingInvoicesSection extends StatelessWidget { return GestureDetector( onTap: () => Modular.to.toAwaitingApproval(), child: Container( - padding: const EdgeInsets.all(UiConstants.space5), + padding: const EdgeInsets.all(UiConstants.space4), 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: [ diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 215e054f..2891e30a 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -382,29 +382,82 @@ class ClientCreateOrderRepositoryImpl ); final RapidOrderParsedData data = response.parsed; + // Fetch Business ID + final String businessId = await _service.getBusinessId(); + + // 1. Hub Matching + final OperationResult< + dc.ListTeamHubsByOwnerIdData, + dc.ListTeamHubsByOwnerIdVariables + > + hubResult = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + final List hubs = hubResult.data.teamHubs; + + final dc.ListTeamHubsByOwnerIdTeamHubs? bestHub = _findBestHub( + hubs, + data.locationHint, + ); + + // 2. Roles Matching + // We fetch vendors to get the first one as a context for role matching. + final OperationResult vendorResult = + await _service.connector.listVendors().execute(); + final List vendors = vendorResult.data.vendors; + + String? selectedVendorId; + List availableRoles = + []; + + if (vendors.isNotEmpty) { + selectedVendorId = vendors.first.id; + final OperationResult< + dc.ListRolesByVendorIdData, + dc.ListRolesByVendorIdVariables + > + roleResult = await _service.connector + .listRolesByVendorId(vendorId: selectedVendorId) + .execute(); + availableRoles = roleResult.data.roles; + } + final DateTime startAt = DateTime.tryParse(data.startAt ?? '') ?? DateTime.now(); final DateTime endAt = DateTime.tryParse(data.endAt ?? '') ?? startAt.add(const Duration(hours: 8)); - final String startTimeStr = DateFormat('hh:mm a').format(startAt); - final String endTimeStr = DateFormat('hh:mm a').format(endAt); + final String startTimeStr = DateFormat('hh:mm a').format(startAt.toLocal()); + final String endTimeStr = DateFormat('hh:mm a').format(endAt.toLocal()); return domain.OneTimeOrder( date: startAt, - location: data.locationHint ?? '', + location: bestHub?.hubName ?? data.locationHint ?? '', eventName: data.notes ?? '', - hub: data.locationHint != null + vendorId: selectedVendorId, + hub: bestHub != null ? domain.OneTimeOrderHubDetails( - id: '', - name: data.locationHint!, - address: '', + id: bestHub.id, + name: bestHub.hubName, + address: bestHub.address, + placeId: bestHub.placeId, + latitude: bestHub.latitude ?? 0, + longitude: bestHub.longitude ?? 0, + city: bestHub.city, + state: bestHub.state, + street: bestHub.street, + country: bestHub.country, + zipCode: bestHub.zipCode, ) : null, positions: data.positions.map((RapidOrderPosition p) { + final dc.ListRolesByVendorIdRoles? matchedRole = _findBestRole( + availableRoles, + p.role, + ); return domain.OneTimeOrderPosition( - role: p.role, + role: matchedRole?.id ?? p.role, count: p.count, startTime: startTimeStr, endTime: endTimeStr, @@ -656,4 +709,85 @@ class ClientCreateOrderRepositoryImpl } return domain.OrderType.oneTime; } + + dc.ListTeamHubsByOwnerIdTeamHubs? _findBestHub( + List hubs, + String? hint, + ) { + if (hint == null || hint.isEmpty || hubs.isEmpty) return null; + final String normalizedHint = hint.toLowerCase(); + + dc.ListTeamHubsByOwnerIdTeamHubs? bestMatch; + double highestScore = -1; + + for (final dc.ListTeamHubsByOwnerIdTeamHubs hub in hubs) { + final String name = hub.hubName.toLowerCase(); + final String address = hub.address.toLowerCase(); + + double score = 0; + if (name == normalizedHint || address == normalizedHint) { + score = 100; + } else if (name.contains(normalizedHint) || + address.contains(normalizedHint)) { + score = 80; + } else if (normalizedHint.contains(name) || + normalizedHint.contains(address)) { + score = 60; + } else { + final List hintWords = normalizedHint.split(RegExp(r'\s+')); + final List hubWords = ('$name $address').split(RegExp(r'\s+')); + int overlap = 0; + for (final String word in hintWords) { + if (word.length > 2 && hubWords.contains(word)) overlap++; + } + score = overlap * 10.0; + } + + if (score > highestScore) { + highestScore = score; + bestMatch = hub; + } + } + + return (highestScore >= 10) ? bestMatch : null; + } + + dc.ListRolesByVendorIdRoles? _findBestRole( + List roles, + String? hint, + ) { + if (hint == null || hint.isEmpty || roles.isEmpty) return null; + final String normalizedHint = hint.toLowerCase(); + + dc.ListRolesByVendorIdRoles? bestMatch; + double highestScore = -1; + + for (final dc.ListRolesByVendorIdRoles role in roles) { + final String name = role.name.toLowerCase(); + + double score = 0; + if (name == normalizedHint) { + score = 100; + } else if (name.contains(normalizedHint)) { + score = 80; + } else if (normalizedHint.contains(name)) { + score = 60; + } else { + final List hintWords = normalizedHint.split(RegExp(r'\s+')); + final List roleWords = name.split(RegExp(r'\s+')); + int overlap = 0; + for (final String word in hintWords) { + if (word.length > 2 && roleWords.contains(word)) overlap++; + } + score = overlap * 10.0; + } + + if (score > highestScore) { + highestScore = score; + bestMatch = role; + } + } + + return (highestScore >= 10) ? bestMatch : null; + } }