diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index f4d6110b..aead3421 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -19,6 +19,7 @@ export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; export 'src/entities/business/hub_department.dart'; export 'src/entities/business/biz_contract.dart'; +export 'src/entities/business/vendor.dart'; // Events & Shifts export 'src/entities/events/event.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart new file mode 100644 index 00000000..19d8bf98 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/vendor.dart @@ -0,0 +1,15 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a staffing vendor. +class Vendor extends Equatable { + const Vendor({required this.id, required this.name, required this.rates}); + + final String id; + final String name; + + /// A map of role names to hourly rates. + final Map rates; + + @override + List get props => [id, name, rates]; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart index 8ea45002..a4a0d193 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_bloc.dart @@ -9,15 +9,70 @@ import 'one_time_order_state.dart'; class OneTimeOrderBloc extends Bloc { OneTimeOrderBloc(this._createOneTimeOrderUseCase) : super(OneTimeOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); on(_onDateChanged); on(_onLocationChanged); on(_onPositionAdded); on(_onPositionRemoved); on(_onPositionUpdated); on(_onSubmitted); + + // Initial load of mock vendors + add( + const OneTimeOrderVendorsLoaded([ + Vendor( + id: 'v1', + name: 'Elite Staffing', + rates: { + 'Server': 25.0, + 'Bartender': 30.0, + 'Cook': 28.0, + 'Busser': 18.0, + 'Host': 20.0, + 'Barista': 22.0, + 'Dishwasher': 17.0, + 'Event Staff': 19.0, + }, + ), + Vendor( + id: 'v2', + name: 'Premier Workforce', + rates: { + 'Server': 22.0, + 'Bartender': 28.0, + 'Cook': 25.0, + 'Busser': 16.0, + 'Host': 18.0, + 'Barista': 20.0, + 'Dishwasher': 15.0, + 'Event Staff': 18.0, + }, + ), + ]), + ); } final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; + void _onVendorsLoaded( + OneTimeOrderVendorsLoaded event, + Emitter emit, + ) { + emit( + state.copyWith( + vendors: event.vendors, + selectedVendor: event.vendors.isNotEmpty ? event.vendors.first : null, + ), + ); + } + + void _onVendorChanged( + OneTimeOrderVendorChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedVendor: event.vendor)); + } + void _onDateChanged( OneTimeOrderDateChanged event, Emitter emit, @@ -41,8 +96,8 @@ class OneTimeOrderBloc extends Bloc { const OneTimeOrderPosition( role: '', count: 1, - startTime: '', - endTime: '', + startTime: '09:00', + endTime: '17:00', ), ); emit(state.copyWith(positions: newPositions)); @@ -80,6 +135,7 @@ class OneTimeOrderBloc extends Bloc { date: state.date, location: state.location, positions: state.positions, + // In a real app, we'd pass the vendorId here ); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); emit(state.copyWith(status: OneTimeOrderStatus.success)); diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart index 749bbb2e..ec9d4fcd 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_event.dart @@ -8,6 +8,22 @@ abstract class OneTimeOrderEvent extends Equatable { List get props => []; } +class OneTimeOrderVendorsLoaded extends OneTimeOrderEvent { + const OneTimeOrderVendorsLoaded(this.vendors); + final List vendors; + + @override + List get props => [vendors]; +} + +class OneTimeOrderVendorChanged extends OneTimeOrderEvent { + const OneTimeOrderVendorChanged(this.vendor); + final Vendor vendor; + + @override + List get props => [vendor]; +} + class OneTimeOrderDateChanged extends OneTimeOrderEvent { const OneTimeOrderDateChanged(this.date); final DateTime date; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart index 2f286262..03aee2fa 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/one_time_order_state.dart @@ -10,6 +10,8 @@ class OneTimeOrderState extends Equatable { required this.positions, this.status = OneTimeOrderStatus.initial, this.errorMessage, + this.vendors = const [], + this.selectedVendor, }); factory OneTimeOrderState.initial() { @@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable { positions: const [ OneTimeOrderPosition(role: '', count: 1, startTime: '', endTime: ''), ], + vendors: const [], ); } final DateTime date; @@ -26,6 +29,8 @@ class OneTimeOrderState extends Equatable { final List positions; final OneTimeOrderStatus status; final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; OneTimeOrderState copyWith({ DateTime? date, @@ -33,6 +38,8 @@ class OneTimeOrderState extends Equatable { List? positions, OneTimeOrderStatus? status, String? errorMessage, + List? vendors, + Vendor? selectedVendor, }) { return OneTimeOrderState( date: date ?? this.date, @@ -40,6 +47,8 @@ class OneTimeOrderState extends Equatable { positions: positions ?? this.positions, status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, ); } @@ -50,5 +59,7 @@ class OneTimeOrderState extends Equatable { positions, status, errorMessage, + vendors, + selectedVendor, ]; } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart index ec2797ac..4af5d168 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_position_card.dart @@ -19,6 +19,7 @@ class OneTimeOrderPositionCard extends StatelessWidget { required this.startLabel, required this.endLabel, required this.lunchLabel, + this.vendor, super.key, }); @@ -55,6 +56,9 @@ class OneTimeOrderPositionCard extends StatelessWidget { /// Label for the lunch break. final String lunchLabel; + /// The current selected vendor to determine rates. + final Vendor? vendor; + @override Widget build(BuildContext context) { return Container( @@ -115,22 +119,21 @@ class OneTimeOrderPositionCard extends StatelessWidget { } }, items: - [ - 'Server', - 'Bartender', - 'Cook', - 'Busser', - 'Host', - 'Barista', - 'Dishwasher', - 'Event Staff', - ].map((String role) { - // Mock rates for UI matching - final int rate = _getMockRate(role); + { + ...(vendor?.rates.keys ?? []), + if (position.role.isNotEmpty && + !(vendor?.rates.keys.contains(position.role) ?? + false)) + position.role, + }.map((String role) { + final double? rate = vendor?.rates[role]; + final String label = rate == null + ? role + : '$role - \$${rate.toStringAsFixed(0)}/hr'; return DropdownMenuItem( value: role, child: Text( - '$role - \$$rate/hr', + label, style: UiTypography.body2r.textPrimary, ), ); @@ -203,13 +206,13 @@ class OneTimeOrderPositionCard extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ GestureDetector( - onTap: () => onUpdated( - position.copyWith( - count: (position.count > 1) - ? position.count - 1 - : 1, - ), - ), + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, child: const Icon(UiIcons.minus, size: 12), ), Text( @@ -217,9 +220,11 @@ class OneTimeOrderPositionCard extends StatelessWidget { style: UiTypography.body2b.textPrimary, ), GestureDetector( - onTap: () => onUpdated( - position.copyWith(count: position.count + 1), - ), + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, child: const Icon(UiIcons.add, size: 12), ), ], @@ -232,76 +237,12 @@ class OneTimeOrderPositionCard extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), - // Optional Location Override - if (position.location == null) - GestureDetector( - onTap: () => onUpdated(position.copyWith(location: '')), - child: Row( - children: [ - const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), - const SizedBox(width: UiConstants.space1), - Text( - t.client_create_order.one_time.different_location, - style: UiTypography.footnote1m.copyWith( - color: UiColors.primary, - ), - ), - ], - ), - ) - else - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 14, - color: UiColors.iconSecondary, - ), - const SizedBox(width: UiConstants.space1), - Text( - t - .client_create_order - .one_time - .different_location_title, - style: UiTypography.footnote1m.textSecondary, - ), - ], - ), - GestureDetector( - onTap: () => onUpdated(position.copyWith(location: null)), - child: const Icon( - UiIcons.close, - size: 14, - color: UiColors.destructive, - ), - ), - ], - ), - const SizedBox(height: UiConstants.space2), - _PositionLocationInput( - value: position.location ?? '', - onChanged: (String val) => - onUpdated(position.copyWith(location: val)), - hintText: - t.client_create_order.one_time.different_location_hint, - ), - ], - ), - - const SizedBox(height: UiConstants.space3), - // Lunch Break Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), const SizedBox(height: UiConstants.space1), Container( - height: 44, padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, decoration: BoxDecoration( borderRadius: UiConstants.radiusMd, border: Border.all(color: UiColors.border), @@ -320,50 +261,15 @@ class OneTimeOrderPositionCard extends StatelessWidget { onUpdated(position.copyWith(lunchBreak: val)); } }, - items: >[ - DropdownMenuItem( - value: 0, + items: [0, 15, 30, 45, 60].map((int mins) { + return DropdownMenuItem( + value: mins, child: Text( - t.client_create_order.one_time.no_break, + mins == 0 ? 'No Break' : '$mins mins', style: UiTypography.body2r.textPrimary, ), - ), - DropdownMenuItem( - value: 10, - child: Text( - '10 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 15, - child: Text( - '15 ${t.client_create_order.one_time.paid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 30, - child: Text( - '30 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 45, - child: Text( - '45 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - DropdownMenuItem( - value: 60, - child: Text( - '60 ${t.client_create_order.one_time.unpaid_break}', - style: UiTypography.body2r.textPrimary, - ), - ), - ], + ); + }).toList(), ), ), ), @@ -378,83 +284,37 @@ class OneTimeOrderPositionCard extends StatelessWidget { required String value, required VoidCallback onTap, }) { - return UiTextField( - label: label, - controller: TextEditingController(text: value), - readOnly: true, - onTap: onTap, - hintText: '--:--', - ); - } - - int _getMockRate(String role) { - switch (role) { - case 'Server': - return 18; - case 'Bartender': - return 22; - case 'Cook': - return 20; - case 'Busser': - return 16; - case 'Host': - return 17; - case 'Barista': - return 16; - case 'Dishwasher': - return 15; - case 'Event Staff': - return 20; - default: - return 15; - } - } -} - -class _PositionLocationInput extends StatefulWidget { - const _PositionLocationInput({ - required this.value, - required this.hintText, - required this.onChanged, - }); - - final String value; - final String hintText; - final ValueChanged onChanged; - - @override - State<_PositionLocationInput> createState() => _PositionLocationInputState(); -} - -class _PositionLocationInputState extends State<_PositionLocationInput> { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = TextEditingController(text: widget.value); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - @override - void didUpdateWidget(_PositionLocationInput oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.value != _controller.text) { - _controller.text = widget.value; - } - } - - @override - Widget build(BuildContext context) { - return UiTextField( - controller: _controller, - onChanged: widget.onChanged, - hintText: widget.hintText, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + GestureDetector( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + decoration: BoxDecoration( + borderRadius: UiConstants.radiusSm, + border: Border.all(color: UiColors.border), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value.isEmpty ? '--:--' : value, + style: UiTypography.body2r.textPrimary, + ), + const Icon( + UiIcons.clock, + size: 14, + color: UiColors.iconSecondary, + ), + ], + ), + ), + ), + ], ); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index b8909ac6..19a27567 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -35,6 +35,47 @@ class OneTimeOrderView extends StatelessWidget { ); } + if (state.vendors.isEmpty && + state.status != OneTimeOrderStatus.loading) { + return Scaffold( + backgroundColor: UiColors.bgPrimary, + body: Column( + children: [ + OneTimeOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.pop(), + ), + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + UiIcons.search, + size: 64, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No Vendors Available', + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + Text( + 'There are no staffing vendors associated with your account.', + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ), + ); + } + return Scaffold( backgroundColor: UiColors.bgPrimary, body: Column( @@ -88,6 +129,47 @@ class _OneTimeOrderForm extends StatelessWidget { ), const SizedBox(height: UiConstants.space4), + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: state.selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + BlocProvider.of( + context, + ).add(OneTimeOrderVendorChanged(vendor)); + } + }, + items: state.vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + OneTimeOrderDatePicker( label: labels.date_label, value: state.date, @@ -133,6 +215,7 @@ class _OneTimeOrderForm extends StatelessWidget { startLabel: labels.start_label, endLabel: labels.end_label, lunchLabel: labels.lunch_break_label, + vendor: state.selectedVendor, onUpdated: (OneTimeOrderPosition updated) { BlocProvider.of( context, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 7c6beb78..8753ecd0 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -643,6 +643,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { late List> _positions; + List _vendors = const []; + Vendor? _selectedVendor; + @override void initState() { super.initState(); @@ -661,6 +664,39 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'location': null, }, ]; + + // Mock vendors initialization + _vendors = const [ + Vendor( + id: 'v1', + name: 'Elite Staffing', + rates: { + 'Server': 25.0, + 'Bartender': 30.0, + 'Cook': 28.0, + 'Busser': 18.0, + 'Host': 20.0, + 'Barista': 22.0, + 'Dishwasher': 17.0, + 'Event Staff': 19.0, + }, + ), + Vendor( + id: 'v2', + name: 'Premier Workforce', + rates: { + 'Server': 22.0, + 'Bartender': 28.0, + 'Cook': 25.0, + 'Busser': 16.0, + 'Host': 18.0, + 'Barista': 20.0, + 'Dishwasher': 15.0, + 'Event Staff': 18.0, + }, + ), + ]; + _selectedVendor = _vendors.first; } @override @@ -707,7 +743,10 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { hours = endH - startH; if (hours < 0) hours += 24; } catch (_) {} - total += hours * widget.order.hourlyRate * (pos['count'] as int); + + final double rate = + _selectedVendor?.rates[pos['role']] ?? widget.order.hourlyRate; + total += hours * rate * (pos['count'] as int); } return total; } @@ -741,6 +780,45 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ), const SizedBox(height: UiConstants.space4), + _buildSectionHeader('VENDOR'), + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space3, + ), + height: 48, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: _selectedVendor, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (Vendor? vendor) { + if (vendor != null) { + setState(() => _selectedVendor = vendor); + } + }, + items: _vendors.map((Vendor vendor) { + return DropdownMenuItem( + value: vendor, + child: Text( + vendor.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + _buildSectionHeader('DATE'), UiTextField( controller: _dateController, @@ -902,17 +980,8 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { hint: 'Select role', value: pos['role'], items: [ - 'Server', - 'Bartender', - 'Cook', - 'Busser', - 'Host', - 'Barista', - 'Dishwasher', - 'Event Staff', - if (pos['role'] != null && - pos['role'].toString().isNotEmpty && - ![ + ...(_selectedVendor?.rates.keys.toList() ?? + [ 'Server', 'Bartender', 'Cook', @@ -921,9 +990,17 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { 'Barista', 'Dishwasher', 'Event Staff', - ].contains(pos['role'])) + ]), + if (pos['role'] != null && + pos['role'].toString().isNotEmpty && + !(_selectedVendor?.rates.keys.contains(pos['role']) ?? false)) pos['role'].toString(), ], + itemBuilder: (dynamic role) { + final double? rate = _selectedVendor?.rates[role]; + if (rate == null) return role.toString(); + return '$role - \$${rate.toStringAsFixed(0)}/hr'; + }, onChanged: (dynamic val) => _updatePosition(index, 'role', val), ), @@ -1358,6 +1435,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { } Widget _buildReviewPositionCard(Map pos) { + final double rate = + _selectedVendor?.rates[pos['role']] ?? widget.order.hourlyRate; + return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), @@ -1387,7 +1467,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> { ], ), Text( - '\$${widget.order.hourlyRate.round()}/hr', + '\$${rate.round()}/hr', style: UiTypography.body2b.copyWith(color: UiColors.primary), ), ],