Add vendor selection and rates to order creation flow

Introduces a Vendor entity and integrates vendor selection into the one-time order creation and order editing flows. Vendor-specific rates are now displayed and used for position roles, and UI components have been updated to allow users to select a vendor and see corresponding rates. Mock vendor data is used for demonstration purposes.
This commit is contained in:
Achintha Isuru
2026-01-23 12:42:26 -05:00
parent 4e8373b2e5
commit 45d6710183
8 changed files with 344 additions and 222 deletions

View File

@@ -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';

View File

@@ -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<String, double> rates;
@override
List<Object?> get props => <Object?>[id, name, rates];
}

View File

@@ -9,15 +9,70 @@ import 'one_time_order_state.dart';
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState> {
OneTimeOrderBloc(this._createOneTimeOrderUseCase)
: super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
on<OneTimeOrderVendorChanged>(_onVendorChanged);
on<OneTimeOrderDateChanged>(_onDateChanged);
on<OneTimeOrderLocationChanged>(_onLocationChanged);
on<OneTimeOrderPositionAdded>(_onPositionAdded);
on<OneTimeOrderPositionRemoved>(_onPositionRemoved);
on<OneTimeOrderPositionUpdated>(_onPositionUpdated);
on<OneTimeOrderSubmitted>(_onSubmitted);
// Initial load of mock vendors
add(
const OneTimeOrderVendorsLoaded(<Vendor>[
Vendor(
id: 'v1',
name: 'Elite Staffing',
rates: <String, double>{
'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: <String, double>{
'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<OneTimeOrderState> emit,
) {
emit(
state.copyWith(
vendors: event.vendors,
selectedVendor: event.vendors.isNotEmpty ? event.vendors.first : null,
),
);
}
void _onVendorChanged(
OneTimeOrderVendorChanged event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(selectedVendor: event.vendor));
}
void _onDateChanged(
OneTimeOrderDateChanged event,
Emitter<OneTimeOrderState> emit,
@@ -41,8 +96,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState> {
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<OneTimeOrderEvent, OneTimeOrderState> {
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));

View File

@@ -8,6 +8,22 @@ abstract class OneTimeOrderEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
class OneTimeOrderVendorsLoaded extends OneTimeOrderEvent {
const OneTimeOrderVendorsLoaded(this.vendors);
final List<Vendor> vendors;
@override
List<Object?> get props => <Object?>[vendors];
}
class OneTimeOrderVendorChanged extends OneTimeOrderEvent {
const OneTimeOrderVendorChanged(this.vendor);
final Vendor vendor;
@override
List<Object?> get props => <Object?>[vendor];
}
class OneTimeOrderDateChanged extends OneTimeOrderEvent {
const OneTimeOrderDateChanged(this.date);
final DateTime date;

View File

@@ -10,6 +10,8 @@ class OneTimeOrderState extends Equatable {
required this.positions,
this.status = OneTimeOrderStatus.initial,
this.errorMessage,
this.vendors = const <Vendor>[],
this.selectedVendor,
});
factory OneTimeOrderState.initial() {
@@ -19,6 +21,7 @@ class OneTimeOrderState extends Equatable {
positions: const <OneTimeOrderPosition>[
OneTimeOrderPosition(role: '', count: 1, startTime: '', endTime: ''),
],
vendors: const <Vendor>[],
);
}
final DateTime date;
@@ -26,6 +29,8 @@ class OneTimeOrderState extends Equatable {
final List<OneTimeOrderPosition> positions;
final OneTimeOrderStatus status;
final String? errorMessage;
final List<Vendor> vendors;
final Vendor? selectedVendor;
OneTimeOrderState copyWith({
DateTime? date,
@@ -33,6 +38,8 @@ class OneTimeOrderState extends Equatable {
List<OneTimeOrderPosition>? positions,
OneTimeOrderStatus? status,
String? errorMessage,
List<Vendor>? 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,
];
}

View File

@@ -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:
<String>[
'Server',
'Bartender',
'Cook',
'Busser',
'Host',
'Barista',
'Dishwasher',
'Event Staff',
].map((String role) {
// Mock rates for UI matching
final int rate = _getMockRate(role);
<String>{
...(vendor?.rates.keys ?? <String>[]),
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<String>(
value: role,
child: Text(
'$role - \$$rate/hr',
label,
style: UiTypography.body2r.textPrimary,
),
);
@@ -203,13 +206,13 @@ class OneTimeOrderPositionCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
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: <Widget>[
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: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
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<int>>[
DropdownMenuItem<int>(
value: 0,
items: <int>[0, 15, 30, 45, 60].map((int mins) {
return DropdownMenuItem<int>(
value: mins,
child: Text(
t.client_create_order.one_time.no_break,
mins == 0 ? 'No Break' : '$mins mins',
style: UiTypography.body2r.textPrimary,
),
),
DropdownMenuItem<int>(
value: 10,
child: Text(
'10 ${t.client_create_order.one_time.paid_break}',
style: UiTypography.body2r.textPrimary,
),
),
DropdownMenuItem<int>(
value: 15,
child: Text(
'15 ${t.client_create_order.one_time.paid_break}',
style: UiTypography.body2r.textPrimary,
),
),
DropdownMenuItem<int>(
value: 30,
child: Text(
'30 ${t.client_create_order.one_time.unpaid_break}',
style: UiTypography.body2r.textPrimary,
),
),
DropdownMenuItem<int>(
value: 45,
child: Text(
'45 ${t.client_create_order.one_time.unpaid_break}',
style: UiTypography.body2r.textPrimary,
),
),
DropdownMenuItem<int>(
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<String> 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: <Widget>[
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: <Widget>[
Text(
value.isEmpty ? '--:--' : value,
style: UiTypography.body2r.textPrimary,
),
const Icon(
UiIcons.clock,
size: 14,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
}

View File

@@ -35,6 +35,47 @@ class OneTimeOrderView extends StatelessWidget {
);
}
if (state.vendors.isEmpty &&
state.status != OneTimeOrderStatus.loading) {
return Scaffold(
backgroundColor: UiColors.bgPrimary,
body: Column(
children: <Widget>[
OneTimeOrderHeader(
title: labels.title,
subtitle: labels.subtitle,
onBack: () => Modular.to.pop(),
),
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
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<Vendor>(
isExpanded: true,
value: state.selectedVendor,
icon: const Icon(
UiIcons.chevronDown,
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (Vendor? vendor) {
if (vendor != null) {
BlocProvider.of<OneTimeOrderBloc>(
context,
).add(OneTimeOrderVendorChanged(vendor));
}
},
items: state.vendors.map((Vendor vendor) {
return DropdownMenuItem<Vendor>(
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<OneTimeOrderBloc>(
context,

View File

@@ -643,6 +643,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
late List<Map<String, dynamic>> _positions;
List<Vendor> _vendors = const <Vendor>[];
Vendor? _selectedVendor;
@override
void initState() {
super.initState();
@@ -661,6 +664,39 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
'location': null,
},
];
// Mock vendors initialization
_vendors = const <Vendor>[
Vendor(
id: 'v1',
name: 'Elite Staffing',
rates: <String, double>{
'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: <String, double>{
'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<Vendor>(
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<Vendor>(
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: <String>[
'Server',
'Bartender',
'Cook',
'Busser',
'Host',
'Barista',
'Dishwasher',
'Event Staff',
if (pos['role'] != null &&
pos['role'].toString().isNotEmpty &&
!<String>[
...(_selectedVendor?.rates.keys.toList() ??
<String>[
'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<String, dynamic> 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),
),
],