Merge branch '208-p0-auth-05-get-started-screen' of https://github.com/Oloodi/krow-workforce into 208-p0-auth-05-get-started-screen

This commit is contained in:
Achintha Isuru
2026-01-25 17:01:38 -05:00
28 changed files with 21403 additions and 19444 deletions

View File

@@ -118,6 +118,7 @@ class ClientCreateOrderRepositoryImpl
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(position.lunchBreak))
.totalValue(totalValue)
.execute();
}
@@ -148,6 +149,17 @@ class ClientCreateOrderRepositoryImpl
return total;
}
dc.BreakDuration _breakDurationFromValue(String value) {
switch (value) {
case 'MIN_15':
return dc.BreakDuration.MIN_15;
case 'MIN_30':
return dc.BreakDuration.MIN_30;
default:
return dc.BreakDuration.NO_BREAK;
}
}
DateTime _parseTime(DateTime date, String time) {
if (time.trim().isEmpty) {
throw Exception('Shift time is missing.');

View File

@@ -230,7 +230,7 @@ class OneTimeOrderPositionCard extends StatelessWidget {
border: Border.all(color: UiColors.border),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
child: DropdownButton<String>(
isExpanded: true,
value: position.lunchBreak,
icon: const Icon(
@@ -238,16 +238,23 @@ class OneTimeOrderPositionCard extends StatelessWidget {
size: 18,
color: UiColors.iconSecondary,
),
onChanged: (int? val) {
onChanged: (String? val) {
if (val != null) {
onUpdated(position.copyWith(lunchBreak: val));
}
},
items: <int>[0, 15, 30, 45, 60].map((int mins) {
return DropdownMenuItem<int>(
value: mins,
items: <String>['NO_BREAK', 'MIN_15', 'MIN_30'].map((
String value,
) {
final String label = switch (value) {
'NO_BREAK' => 'No Break',
'MIN_15' => '15 min',
_ => '30 min',
};
return DropdownMenuItem<String>(
value: value,
child: Text(
mins == 0 ? 'No Break' : '$mins mins',
label,
style: UiTypography.body2r.textPrimary,
),
);

View File

@@ -5,6 +5,7 @@ import 'package:krow_data_connect/krow_data_connect.dart';
import 'src/data/repositories_impl/home_repository_impl.dart';
import 'src/domain/repositories/home_repository_interface.dart';
import 'src/domain/usecases/get_dashboard_data_usecase.dart';
import 'src/domain/usecases/get_recent_reorders_usecase.dart';
import 'src/domain/usecases/get_user_session_data_usecase.dart';
import 'src/presentation/blocs/client_home_bloc.dart';
import 'src/presentation/pages/client_home_page.dart';
@@ -24,17 +25,22 @@ class ClientHomeModule extends Module {
void binds(Injector i) {
// Repositories
i.addLazySingleton<HomeRepositoryInterface>(
() => HomeRepositoryImpl(i.get<HomeRepositoryMock>()),
() => HomeRepositoryImpl(
i.get<HomeRepositoryMock>(),
ExampleConnector.instance,
),
);
// UseCases
i.addLazySingleton(GetDashboardDataUseCase.new);
i.addLazySingleton(GetRecentReordersUseCase.new);
i.addLazySingleton(GetUserSessionDataUseCase.new);
// BLoCs
i.add<ClientHomeBloc>(
() => ClientHomeBloc(
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
getRecentReordersUseCase: i.get<GetRecentReordersUseCase>(),
getUserSessionDataUseCase: i.get<GetUserSessionDataUseCase>(),
),
);

View File

@@ -1,3 +1,4 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/home_repository_interface.dart';
@@ -8,11 +9,12 @@ import '../../domain/repositories/home_repository_interface.dart';
/// domain layer and the data source (in this case, a mock from data_connect).
class HomeRepositoryImpl implements HomeRepositoryInterface {
final HomeRepositoryMock _mock;
final ExampleConnector _dataConnect;
/// Creates a [HomeRepositoryImpl].
///
/// Requires a [HomeRepositoryMock] to perform data operations.
HomeRepositoryImpl(this._mock);
HomeRepositoryImpl(this._mock, this._dataConnect);
@override
Future<HomeDashboardData> getDashboardData() {
@@ -27,4 +29,62 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
photoUrl: photoUrl,
);
}
@override
Future<List<ReorderItem>> getRecentReorders() async {
final String? businessId = ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
}
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _toTimestamp(start);
final fdc.Timestamp endTimestamp = _toTimestamp(now);
final fdc.QueryResult<
ListShiftRolesByBusinessDateRangeCompletedOrdersData,
ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result =
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
).execute();
print(
'Home reorder: completed shiftRoles=${result.data.shiftRoles.length}',
);
return result.data.shiftRoles.map((
ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) {
print(
'Home reorder item: orderId=${shiftRole.shift.order.id} '
'shiftId=${shiftRole.shiftId} roleId=${shiftRole.roleId} '
'orderType=${shiftRole.shift.order.orderType.stringValue} '
'hours=${shiftRole.hours} count=${shiftRole.count}',
);
final String location =
shiftRole.shift.location ??
shiftRole.shift.locationAddress ??
'';
final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem(
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
location: location,
hourlyRate: shiftRole.role.costPerHour,
hours: shiftRole.hours ?? 0,
workers: shiftRole.count,
type: type,
);
}).toList();
}
fdc.Timestamp _toTimestamp(DateTime date) {
final int millis = date.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return fdc.Timestamp(nanos, seconds);
}
}

View File

@@ -25,4 +25,7 @@ abstract interface class HomeRepositoryInterface {
/// Fetches the user's session data (business name and photo).
UserSessionData getUserSessionData();
/// Fetches recently completed shift roles for reorder suggestions.
Future<List<ReorderItem>> getRecentReorders();
}

View File

@@ -0,0 +1,16 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/home_repository_interface.dart';
/// Use case to fetch recent completed shift roles for reorder suggestions.
class GetRecentReordersUseCase implements NoInputUseCase<List<ReorderItem>> {
final HomeRepositoryInterface _repository;
/// Creates a [GetRecentReordersUseCase].
GetRecentReordersUseCase(this._repository);
@override
Future<List<ReorderItem>> call() {
return _repository.getRecentReorders();
}
}

View File

@@ -2,6 +2,7 @@ import 'package:client_home/src/domain/repositories/home_repository_interface.da
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_dashboard_data_usecase.dart';
import '../../domain/usecases/get_recent_reorders_usecase.dart';
import '../../domain/usecases/get_user_session_data_usecase.dart';
import 'client_home_event.dart';
import 'client_home_state.dart';
@@ -9,12 +10,15 @@ import 'client_home_state.dart';
/// BLoC responsible for managing the state and business logic of the client home dashboard.
class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
final GetDashboardDataUseCase _getDashboardDataUseCase;
final GetRecentReordersUseCase _getRecentReordersUseCase;
final GetUserSessionDataUseCase _getUserSessionDataUseCase;
ClientHomeBloc({
required GetDashboardDataUseCase getDashboardDataUseCase,
required GetRecentReordersUseCase getRecentReordersUseCase,
required GetUserSessionDataUseCase getUserSessionDataUseCase,
}) : _getDashboardDataUseCase = getDashboardDataUseCase,
_getRecentReordersUseCase = getRecentReordersUseCase,
_getUserSessionDataUseCase = getUserSessionDataUseCase,
super(const ClientHomeState()) {
on<ClientHomeStarted>(_onStarted);
@@ -35,11 +39,13 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState> {
// Get dashboard data
final HomeDashboardData data = await _getDashboardDataUseCase();
final List<ReorderItem> reorderItems = await _getRecentReordersUseCase();
emit(
state.copyWith(
status: ClientHomeStatus.success,
dashboardData: data,
reorderItems: reorderItems,
businessName: sessionData.businessName,
photoUrl: sessionData.photoUrl,
),

View File

@@ -12,6 +12,7 @@ class ClientHomeState extends Equatable {
final bool isEditMode;
final String? errorMessage;
final HomeDashboardData dashboardData;
final List<ReorderItem> reorderItems;
final String businessName;
final String? photoUrl;
@@ -19,9 +20,11 @@ class ClientHomeState extends Equatable {
this.status = ClientHomeStatus.initial,
this.widgetOrder = const <String>[
'actions',
'reorder',
],
this.widgetVisibility = const <String, bool>{
'actions': true,
'reorder': true,
},
this.isEditMode = false,
this.errorMessage,
@@ -33,6 +36,7 @@ class ClientHomeState extends Equatable {
totalNeeded: 10,
totalFilled: 8,
),
this.reorderItems = const <ReorderItem>[],
this.businessName = 'Your Company',
this.photoUrl,
});
@@ -44,6 +48,7 @@ class ClientHomeState extends Equatable {
bool? isEditMode,
String? errorMessage,
HomeDashboardData? dashboardData,
List<ReorderItem>? reorderItems,
String? businessName,
String? photoUrl,
}) {
@@ -54,6 +59,7 @@ class ClientHomeState extends Equatable {
isEditMode: isEditMode ?? this.isEditMode,
errorMessage: errorMessage ?? this.errorMessage,
dashboardData: dashboardData ?? this.dashboardData,
reorderItems: reorderItems ?? this.reorderItems,
businessName: businessName ?? this.businessName,
photoUrl: photoUrl ?? this.photoUrl,
);
@@ -67,6 +73,7 @@ class ClientHomeState extends Equatable {
isEditMode,
errorMessage,
dashboardData,
reorderItems,
businessName,
photoUrl,
];

View File

@@ -64,6 +64,7 @@ class DashboardWidgetBuilder extends StatelessWidget {
);
case 'reorder':
return ReorderWidget(
orders: state.reorderItems,
onReorderPressed: (Map<String, dynamic> data) {
ClientHomeSheets.showOrderFormSheet(
context,

View File

@@ -1,46 +1,28 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// A widget that allows clients to reorder recent shifts.
class ReorderWidget extends StatelessWidget {
/// Recent completed orders for reorder.
final List<ReorderItem> orders;
/// Callback when a reorder button is pressed.
final Function(Map<String, dynamic> shiftData) onReorderPressed;
/// Creates a [ReorderWidget].
const ReorderWidget({super.key, required this.onReorderPressed});
const ReorderWidget({
super.key,
required this.orders,
required this.onReorderPressed,
});
@override
Widget build(BuildContext context) {
final TranslationsClientHomeReorderEn i18n = t.client_home.reorder;
// Mock recent orders
final List<Map<String, Object>> recentOrders = <Map<String, Object>>[
<String, Object>{
'title': 'Server',
'location': 'Downtown Restaurant',
'hourlyRate': 18.0,
'hours': 6,
'workers': 3,
'type': 'One Day',
},
<String, Object>{
'title': 'Bartender',
'location': 'Rooftop Bar',
'hourlyRate': 22.0,
'hours': 7,
'workers': 2,
'type': 'One Day',
},
<String, Object>{
'title': 'Event Staff',
'location': 'Convention Center',
'hourlyRate': 20.0,
'hours': 10,
'workers': 5,
'type': 'Multi-Day',
},
];
final List<ReorderItem> recentOrders = orders;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -60,11 +42,9 @@ class ReorderWidget extends StatelessWidget {
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space3),
itemBuilder: (BuildContext context, int index) {
final Map<String, Object> order = recentOrders[index];
final ReorderItem order = recentOrders[index];
final double totalCost =
(order['hourlyRate'] as double) *
(order['hours'] as int) *
(order['workers'] as int);
order.hourlyRate * order.hours * order.workers;
return Container(
width: 260,
@@ -110,12 +90,12 @@ class ReorderWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
order['title'] as String,
order.title,
style: UiTypography.body2b,
overflow: TextOverflow.ellipsis,
),
Text(
order['location'] as String,
order.location,
style:
UiTypography.footnote1r.textSecondary,
overflow: TextOverflow.ellipsis,
@@ -135,9 +115,9 @@ class ReorderWidget extends StatelessWidget {
),
Text(
i18n.per_hr(
amount: order['hourlyRate'].toString(),
amount: order.hourlyRate.toString(),
) +
' · ${order['hours']}h',
' · ${order.hours}h',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -149,7 +129,7 @@ class ReorderWidget extends StatelessWidget {
children: <Widget>[
_Badge(
icon: UiIcons.success,
text: order['type'] as String,
text: order.type,
color: const Color(0xFF2563EB),
bg: const Color(0xFF2563EB),
textColor: UiColors.white,
@@ -157,7 +137,7 @@ class ReorderWidget extends StatelessWidget {
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order['workers']}',
text: '${order.workers}',
color: const Color(0xFF334155),
bg: const Color(0xFFF1F5F9),
textColor: const Color(0xFF334155),
@@ -169,7 +149,15 @@ class ReorderWidget extends StatelessWidget {
height: 28,
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => onReorderPressed(order),
onPressed: () => onReorderPressed(<String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
}),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,

View File

@@ -1,6 +1,27 @@
import 'package:design_system/design_system.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
class _RoleOption {
const _RoleOption({
required this.id,
required this.name,
required this.costPerHour,
});
final String id;
final String name;
final double costPerHour;
}
class _VendorOption {
const _VendorOption({required this.id, required this.name});
final String id;
final String name;
}
/// A bottom sheet form for creating or reordering shifts.
///
@@ -34,63 +55,18 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
late List<Map<String, dynamic>> _positions;
final List<String> _roles = <String>[
'Server',
'Bartender',
'Cook',
'Busser',
'Host',
'Barista',
'Dishwasher',
'Event Staff',
'Warehouse Worker',
'Retail Associate',
];
// Vendor options
final List<Map<String, dynamic>> _vendors = <Map<String, dynamic>>[
<String, dynamic>{
'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,
},
},
<String, dynamic>{
'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 dc.ExampleConnector _dataConnect = dc.ExampleConnector.instance;
List<_VendorOption> _vendors = const <_VendorOption>[];
List<_RoleOption> _roles = const <_RoleOption>[];
String? _selectedVendorId;
final List<int> _lunchBreakOptions = <int>[0, 30, 45, 60];
@override
void initState() {
super.initState();
// Initialize date controller
final DateTime tomorrow = DateTime.now().add(const Duration(days: 1));
final String initialDate = widget.initialData?['date'] ??
tomorrow.toIso8601String().split('T')[0];
// Initialize date controller (always today for reorder sheet)
final DateTime today = DateTime.now();
final String initialDate = today.toIso8601String().split('T')[0];
_dateController = TextEditingController(text: initialDate);
// Initialize location controller
@@ -100,21 +76,27 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
'',
);
// Initialize vendor selection
_selectedVendorId = _vendors.first['id'];
// Initialize positions
_positions = <Map<String, dynamic>>[
<String, dynamic>{
'role': widget.initialData?['title'] ?? widget.initialData?['role'] ?? '',
'count': widget.initialData?['workersNeeded'] ?? widget.initialData?['workers_needed'] ?? 1,
'start_time': widget.initialData?['startTime'] ?? widget.initialData?['start_time'] ?? '09:00',
'end_time': widget.initialData?['endTime'] ?? widget.initialData?['end_time'] ?? '17:00',
'hourly_rate': widget.initialData?['hourlyRate']?.toDouble() ?? 18.0,
'lunch_break': 0,
'roleId': widget.initialData?['roleId'] ?? '',
'roleName': widget.initialData?['title'] ?? widget.initialData?['role'] ?? '',
'count': widget.initialData?['workersNeeded'] ??
widget.initialData?['workers_needed'] ??
1,
'start_time': widget.initialData?['startTime'] ??
widget.initialData?['start_time'] ??
'09:00',
'end_time': widget.initialData?['endTime'] ??
widget.initialData?['end_time'] ??
'17:00',
'lunch_break': 'NO_BREAK',
'location': null,
},
];
_loadVendors();
_loadOrderDetails();
}
@override
@@ -127,12 +109,12 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
void _addPosition() {
setState(() {
_positions.add(<String, dynamic>{
'role': '',
'roleId': '',
'roleName': '',
'count': 1,
'start_time': '09:00',
'end_time': '17:00',
'hourly_rate': 18.0,
'lunch_break': 0,
'lunch_break': 'NO_BREAK',
'location': null,
});
});
@@ -162,14 +144,27 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
hours = endH - startH;
if (hours < 0) hours += 24;
} catch (_) {}
final double rate = pos['hourly_rate'] ?? 18.0;
final String roleId = pos['roleId']?.toString() ?? '';
final double rate = _rateForRole(roleId);
total += hours * rate * (pos['count'] as int);
}
return total;
}
String _getShiftType() {
final String? type = widget.initialData?['type']?.toString();
if (type != null && type.isNotEmpty) {
switch (type) {
case 'PERMANENT':
return 'Long Term';
case 'RECURRING':
return 'Multi-Day';
case 'RAPID':
return 'Rapid';
case 'ONE_TIME':
return 'One-Time Order';
}
}
// Determine shift type based on initial data
final dynamic initialData = widget.initialData;
if (initialData != null) {
@@ -183,14 +178,304 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
return 'One-Time Order';
}
void _handleSubmit() {
final Map<String, dynamic> formData = <String, dynamic>{
'date': _dateController.text,
'location': _globalLocationController.text,
'positions': _positions,
'total_cost': _calculateTotalCost(),
};
widget.onSubmit(formData);
Future<void> _handleSubmit() async {
await _submitNewOrder();
}
Future<void> _submitNewOrder() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return;
}
final DateTime date = DateTime.parse(_dateController.text);
final fdc.Timestamp orderTimestamp = _toTimestamp(date);
final dc.OrderType orderType =
_orderTypeFromValue(widget.initialData?['type']?.toString());
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables>
orderResult = await _dataConnect
.createOrder(
businessId: businessId,
orderType: orderType,
)
.vendorId(_selectedVendorId)
.location(_globalLocationController.text)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.execute();
final String? orderId = orderResult.data?.order_insert.id;
if (orderId == null) {
return;
}
final int workersNeeded = _positions.fold<int>(
0,
(int sum, Map<String, dynamic> pos) => sum + (pos['count'] as int),
);
final String shiftTitle =
'Shift 1 ${DateFormat('yyyy-MM-dd').format(date)}';
final double shiftCost = _calculateTotalCost();
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables>
shiftResult = await _dataConnect
.createShift(title: shiftTitle, orderId: orderId)
.date(orderTimestamp)
.location(_globalLocationController.text)
.locationAddress(_globalLocationController.text)
.status(dc.ShiftStatus.PENDING)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String? shiftId = shiftResult.data?.shift_insert.id;
if (shiftId == null) {
return;
}
for (final Map<String, dynamic> pos in _positions) {
final String roleId = pos['roleId']?.toString() ?? '';
if (roleId.isEmpty) {
continue;
}
final DateTime start = _parseTime(date, pos['start_time'].toString());
final DateTime end = _parseTime(date, pos['end_time'].toString());
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final int count = pos['count'] as int;
final double rate = _rateForRole(roleId);
final double totalValue = rate * hours * count;
final String lunchBreak = pos['lunch_break'] as String;
await _dataConnect
.createShiftRole(
shiftId: shiftId,
roleId: roleId,
count: count,
)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(lunchBreak))
.totalValue(totalValue)
.execute();
}
await _dataConnect
.updateOrder(id: orderId)
.shifts(fdc.AnyValue(<String>[shiftId]))
.execute();
widget.onSubmit(<String, dynamic>{
'orderId': orderId,
});
}
Future<void> _loadVendors() async {
try {
final fdc.QueryResult<dc.ListVendorsData, void> result =
await _dataConnect.listVendors().execute();
final List<_VendorOption> vendors = result.data.vendors
.map(
(dc.ListVendorsVendors vendor) =>
_VendorOption(id: vendor.id, name: vendor.companyName),
)
.toList();
if (!mounted) return;
setState(() {
_vendors = vendors;
final String? current = _selectedVendorId;
if (current == null ||
!vendors.any((_VendorOption v) => v.id == current)) {
_selectedVendorId = vendors.isNotEmpty ? vendors.first.id : null;
}
});
if (_selectedVendorId != null) {
await _loadRolesForVendor(_selectedVendorId!);
}
} catch (_) {
if (!mounted) return;
setState(() {
_vendors = const <_VendorOption>[];
_roles = const <_RoleOption>[];
});
}
}
Future<void> _loadRolesForVendor(String vendorId) async {
try {
final fdc.QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
result =
await _dataConnect.listRolesByVendorId(vendorId: vendorId).execute();
final List<_RoleOption> roles = result.data.roles
.map(
(dc.ListRolesByVendorIdRoles role) => _RoleOption(
id: role.id,
name: role.name,
costPerHour: role.costPerHour,
),
)
.toList();
if (!mounted) return;
setState(() => _roles = roles);
} catch (_) {
if (!mounted) return;
setState(() => _roles = const <_RoleOption>[]);
}
}
Future<void> _loadOrderDetails() async {
final String? orderId = widget.initialData?['orderId']?.toString();
if (orderId == null || orderId.isEmpty) {
return;
}
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return;
}
try {
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndOrderData,
dc.ListShiftRolesByBusinessAndOrderVariables> result = await _dataConnect
.listShiftRolesByBusinessAndOrder(
businessId: businessId,
orderId: orderId,
)
.execute();
final List<dc.ListShiftRolesByBusinessAndOrderShiftRoles> shiftRoles =
result.data.shiftRoles;
if (shiftRoles.isEmpty) {
return;
}
final dc.ListShiftRolesByBusinessAndOrderShiftRolesShift firstShift =
shiftRoles.first.shift;
_globalLocationController.text = firstShift.order.location ??
firstShift.locationAddress ??
firstShift.location ??
_globalLocationController.text;
final String? vendorId = firstShift.order.vendorId;
if (mounted) {
setState(() {
_selectedVendorId = vendorId;
});
}
if (vendorId != null && vendorId.isNotEmpty) {
await _loadRolesForVendor(vendorId);
}
final List<Map<String, dynamic>> positions =
shiftRoles.map((dc.ListShiftRolesByBusinessAndOrderShiftRoles role) {
return <String, dynamic>{
'roleId': role.roleId,
'roleName': role.role.name,
'count': role.count,
'start_time': _formatTimeForField(role.startTime),
'end_time': _formatTimeForField(role.endTime),
'lunch_break': _breakValueFromDuration(role.breakType),
'location': null,
};
}).toList();
if (!mounted) return;
setState(() {
_positions = positions;
});
} catch (_) {
// Keep defaults on failure.
}
}
String _formatTimeForField(fdc.Timestamp? value) {
if (value == null) return '';
try {
return DateFormat('HH:mm').format(value.toDateTime());
} catch (_) {
return '';
}
}
String _breakValueFromDuration(dc.EnumValue<dc.BreakDuration>? breakType) {
final dc.BreakDuration? value =
breakType is dc.Known<dc.BreakDuration> ? breakType.value : null;
switch (value) {
case dc.BreakDuration.MIN_15:
return 'MIN_15';
case dc.BreakDuration.MIN_30:
return 'MIN_30';
case dc.BreakDuration.NO_BREAK:
case null:
return 'NO_BREAK';
}
}
dc.BreakDuration _breakDurationFromValue(String value) {
switch (value) {
case 'MIN_15':
return dc.BreakDuration.MIN_15;
case 'MIN_30':
return dc.BreakDuration.MIN_30;
default:
return dc.BreakDuration.NO_BREAK;
}
}
dc.OrderType _orderTypeFromValue(String? value) {
switch (value) {
case 'PERMANENT':
return dc.OrderType.PERMANENT;
case 'RECURRING':
return dc.OrderType.RECURRING;
case 'RAPID':
return dc.OrderType.RAPID;
case 'ONE_TIME':
default:
return dc.OrderType.ONE_TIME;
}
}
_RoleOption? _roleById(String roleId) {
for (final _RoleOption role in _roles) {
if (role.id == roleId) {
return role;
}
}
return null;
}
double _rateForRole(String roleId) {
return _roleById(roleId)?.costPerHour ?? 0;
}
DateTime _parseTime(DateTime date, String time) {
DateTime parsed;
try {
parsed = DateFormat.Hm().parse(time);
} catch (_) {
parsed = DateFormat.jm().parse(time);
}
return DateTime(
date.year,
date.month,
date.day,
parsed.hour,
parsed.minute,
);
}
fdc.Timestamp _toTimestamp(DateTime date) {
final int millis = date.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return fdc.Timestamp(nanos, seconds);
}
@override
@@ -425,19 +710,18 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
color: UiColors.iconSecondary,
),
style: UiTypography.body2r.textPrimary,
items: _vendors
.map(
(Map<String, dynamic> vendor) => DropdownMenuItem<String>(
value: vendor['id'],
child: Text(vendor['name']),
),
)
.toList(),
items: _vendors.map((_VendorOption vendor) {
return DropdownMenuItem<String>(
value: vendor.id,
child: Text(vendor.name),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
_selectedVendorId = newValue;
});
_loadRolesForVendor(newValue);
}
},
),
@@ -561,19 +845,32 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
_buildDropdownField(
hint: 'Select role',
value: pos['role'],
items: _roles,
itemBuilder: (dynamic role) {
final Map<String, dynamic>? vendor = _vendors.firstWhere(
(Map<String, dynamic> v) => v['id'] == _selectedVendorId,
orElse: () => _vendors.first,
);
final Map<String, dynamic>? rates = vendor?['rates'] as Map<String, dynamic>?;
final double? rate = rates?[role];
if (rate == null) return role.toString();
return '$role - \$${rate.toStringAsFixed(0)}/hr';
value: pos['roleId'],
items: <String>[
..._roles.map((_RoleOption role) => role.id),
if (pos['roleId'] != null &&
pos['roleId'].toString().isNotEmpty &&
!_roles.any(
(_RoleOption role) => role.id == pos['roleId'].toString(),
))
pos['roleId'].toString(),
],
itemBuilder: (dynamic roleId) {
final _RoleOption? role = _roleById(roleId.toString());
if (role == null) {
final String fallback = pos['roleName']?.toString() ?? '';
return fallback.isEmpty ? roleId.toString() : fallback;
}
return '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}/hr';
},
onChanged: (dynamic val) {
final String roleId = val?.toString() ?? '';
final _RoleOption? role = _roleById(roleId);
setState(() {
_positions[index]['roleId'] = roleId;
_positions[index]['roleName'] = role?.name ?? '';
});
},
onChanged: (dynamic val) => _updatePosition(index, 'role', val),
),
const SizedBox(height: UiConstants.space3),
@@ -592,10 +889,16 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
_buildDropdownField(
hint: 'None',
value: pos['lunch_break'],
items: _lunchBreakOptions,
itemBuilder: (dynamic minutes) {
if (minutes == 0) return 'None';
return '$minutes min';
items: <String>['NO_BREAK', 'MIN_15', 'MIN_30'],
itemBuilder: (dynamic value) {
switch (value.toString()) {
case 'MIN_15':
return '15 min';
case 'MIN_30':
return '30 min';
default:
return 'No Break';
}
},
onChanged: (dynamic val) => _updatePosition(index, 'lunch_break', val),
),

View File

@@ -42,11 +42,6 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
'Your Company';
return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) {
print(
'ViewOrders shiftRole: shiftId=${shiftRole.shiftId} roleId=${shiftRole.roleId} '
'startTime=${shiftRole.startTime?.toJson()} endTime=${shiftRole.endTime?.toJson()} '
'hours=${shiftRole.hours} totalValue=${shiftRole.totalValue}',
);
final DateTime? shiftDate = shiftRole.shift.date?.toDateTime();
final String dateStr = shiftDate == null
? ''
@@ -58,10 +53,18 @@ class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
final double hours = shiftRole.hours ?? 0;
final double totalValue = shiftRole.totalValue ?? 0;
final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours);
final String status = filled >= workersNeeded ? 'filled' : 'open';
// final String status = filled >= workersNeeded ? 'filled' : 'open';
final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
print(
'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} '
'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
);
return domain.OrderItem(
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
clientName: businessName,
status: status,

View File

@@ -133,6 +133,7 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
filled >= order.workersNeeded ? 'filled' : order.status;
return OrderItem(
id: order.id,
orderId: order.orderId,
title: order.title,
clientName: order.clientName,
status: status,
@@ -191,16 +192,18 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
return ordersOnDate
.where(
(OrderItem s) =>
<String>['open', 'filled', 'confirmed'].contains(s.status),
// TODO(orders): move PENDING to its own tab once available.
<String>['OPEN', 'FILLED', 'CONFIRMED', 'PENDING']
.contains(s.status),
)
.toList();
} else if (state.filterTab == 'active') {
return ordersOnDate
.where((OrderItem s) => s.status == 'in_progress')
.where((OrderItem s) => s.status == 'IN_PROGRESS')
.toList();
} else if (state.filterTab == 'completed') {
return ordersOnDate
.where((OrderItem s) => s.status == 'completed')
.where((OrderItem s) => s.status == 'COMPLETED')
.toList();
}
return <OrderItem>[];
@@ -217,11 +220,11 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
if (category == 'active') {
return ordersOnDate
.where((OrderItem s) => s.status == 'in_progress')
.where((OrderItem s) => s.status == 'IN_PROGRESS')
.length;
} else if (category == 'completed') {
return ordersOnDate
.where((OrderItem s) => s.status == 'completed')
.where((OrderItem s) => s.status == 'COMPLETED')
.length;
}
return 0;
@@ -238,7 +241,9 @@ class ViewOrdersCubit extends Cubit<ViewOrdersState> {
return ordersOnDate
.where(
(OrderItem s) =>
<String>['open', 'filled', 'confirmed'].contains(s.status),
// TODO(orders): move PENDING to its own tab once available.
<String>['OPEN', 'FILLED', 'CONFIRMED', 'PENDING']
.contains(s.status),
)
.length;
}

View File

@@ -1,7 +1,10 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
/// A rich card displaying details of a client order/shift.
@@ -34,16 +37,16 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
/// Returns the semantic color for the given status.
Color _getStatusColor({required String status}) {
switch (status) {
case 'open':
case 'OPEN':
return UiColors.primary;
case 'filled':
case 'confirmed':
case 'FILLED':
case 'CONFIRMED':
return UiColors.textSuccess;
case 'in_progress':
case 'IN_PROGRESS':
return UiColors.textWarning;
case 'completed':
case 'COMPLETED':
return UiColors.primary;
case 'cancelled':
case 'CANCELED':
return UiColors.destructive;
default:
return UiColors.textSecondary;
@@ -53,17 +56,17 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
/// Returns the localized label for the given status.
String _getStatusLabel({required String status}) {
switch (status) {
case 'open':
case 'OPEN':
return t.client_view_orders.card.open;
case 'filled':
case 'FILLED':
return t.client_view_orders.card.filled;
case 'confirmed':
case 'CONFIRMED':
return t.client_view_orders.card.confirmed;
case 'in_progress':
case 'IN_PROGRESS':
return t.client_view_orders.card.in_progress;
case 'completed':
case 'COMPLETED':
return t.client_view_orders.card.completed;
case 'cancelled':
case 'CANCELED':
return t.client_view_orders.card.cancelled;
default:
return status.toUpperCase();
@@ -619,6 +622,25 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
}
}
class _RoleOption {
const _RoleOption({
required this.id,
required this.name,
required this.costPerHour,
});
final String id;
final String name;
final double costPerHour;
}
class _ShiftRoleKey {
const _ShiftRoleKey({required this.shiftId, required this.roleId});
final String shiftId;
final String roleId;
}
/// A sophisticated bottom sheet for editing an existing order,
/// following the Unified Order Flow prototype and matching OneTimeOrderView.
class _OrderEditSheet extends StatefulWidget {
@@ -639,8 +661,15 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
late List<Map<String, dynamic>> _positions;
final dc.ExampleConnector _dataConnect = dc.ExampleConnector.instance;
final firebase.FirebaseAuth _firebaseAuth = firebase.FirebaseAuth.instance;
List<Vendor> _vendors = const <Vendor>[];
Vendor? _selectedVendor;
List<_RoleOption> _roles = const <_RoleOption>[];
String? _shiftId;
List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[];
@override
void initState() {
@@ -652,47 +681,19 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
_positions = <Map<String, dynamic>>[
<String, dynamic>{
'role': widget.order.title,
'shiftId': null,
'roleId': '',
'roleName': '',
'originalRoleId': null,
'count': widget.order.workersNeeded,
'start_time': widget.order.startTime,
'end_time': widget.order.endTime,
'lunch_break': 0,
'lunch_break': 'NO_BREAK',
'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;
_loadOrderDetails();
}
@override
@@ -702,16 +703,396 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
super.dispose();
}
Future<void> _loadOrderDetails() async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
return;
}
if (widget.order.orderId.isEmpty) {
return;
}
try {
final QueryResult<
dc.ListShiftRolesByBusinessAndOrderData,
dc.ListShiftRolesByBusinessAndOrderVariables> result = await _dataConnect
.listShiftRolesByBusinessAndOrder(
businessId: businessId,
orderId: widget.order.orderId,
)
.execute();
final List<dc.ListShiftRolesByBusinessAndOrderShiftRoles> shiftRoles =
result.data.shiftRoles;
if (shiftRoles.isEmpty) {
return;
}
final dc.ListShiftRolesByBusinessAndOrderShiftRolesShift firstShift =
shiftRoles.first.shift;
final DateTime? orderDate = firstShift.order.date?.toDateTime();
final String dateText = orderDate == null
? widget.order.date
: DateFormat('yyyy-MM-dd').format(orderDate);
final String location = firstShift.order.location ??
firstShift.locationAddress ??
firstShift.location ??
widget.order.locationAddress;
_dateController.text = dateText;
_globalLocationController.text = location;
_shiftId = shiftRoles.first.shiftId;
final List<Map<String, dynamic>> positions =
shiftRoles.map((dc.ListShiftRolesByBusinessAndOrderShiftRoles role) {
return <String, dynamic>{
'shiftId': role.shiftId,
'roleId': role.roleId,
'roleName': role.role.name,
'originalRoleId': role.roleId,
'count': role.count,
'start_time': _formatTimeForField(role.startTime),
'end_time': _formatTimeForField(role.endTime),
'lunch_break': _breakValueFromDuration(role.breakType),
'location': null,
};
}).toList();
if (positions.isEmpty) {
positions.add(_emptyPosition());
}
final List<_ShiftRoleKey> originalShiftRoles =
shiftRoles
.map(
(dc.ListShiftRolesByBusinessAndOrderShiftRoles role) =>
_ShiftRoleKey(shiftId: role.shiftId, roleId: role.roleId),
)
.toList();
await _loadVendorsAndSelect(firstShift.order.vendorId);
if (mounted) {
setState(() {
_positions = positions;
_originalShiftRoles = originalShiftRoles;
});
}
} catch (_) {
// Keep current state on failure.
}
}
Future<void> _loadVendorsAndSelect(String? selectedVendorId) async {
try {
final QueryResult<dc.ListVendorsData, void> result =
await _dataConnect.listVendors().execute();
final List<Vendor> vendors = result.data.vendors
.map(
(dc.ListVendorsVendors vendor) => Vendor(
id: vendor.id,
name: vendor.companyName,
rates: const <String, double>{},
),
)
.toList();
Vendor? selectedVendor;
if (selectedVendorId != null && selectedVendorId.isNotEmpty) {
for (final Vendor vendor in vendors) {
if (vendor.id == selectedVendorId) {
selectedVendor = vendor;
break;
}
}
}
selectedVendor ??= vendors.isNotEmpty ? vendors.first : null;
if (mounted) {
setState(() {
_vendors = vendors;
_selectedVendor = selectedVendor;
});
}
if (selectedVendor != null) {
await _loadRolesForVendor(selectedVendor.id);
}
} catch (_) {
if (mounted) {
setState(() {
_vendors = const <Vendor>[];
_selectedVendor = null;
_roles = const <_RoleOption>[];
});
}
}
}
Future<void> _loadRolesForVendor(String vendorId) async {
try {
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
result = await _dataConnect
.listRolesByVendorId(vendorId: vendorId)
.execute();
final List<_RoleOption> roles = result.data.roles
.map(
(dc.ListRolesByVendorIdRoles role) => _RoleOption(
id: role.id,
name: role.name,
costPerHour: role.costPerHour,
),
)
.toList();
if (mounted) {
setState(() => _roles = roles);
}
} catch (_) {
if (mounted) {
setState(() => _roles = const <_RoleOption>[]);
}
}
}
Map<String, dynamic> _emptyPosition() {
return <String, dynamic>{
'shiftId': _shiftId,
'roleId': '',
'roleName': '',
'originalRoleId': null,
'count': 1,
'start_time': '09:00',
'end_time': '17:00',
'lunch_break': 'NO_BREAK',
'location': null,
};
}
String _formatTimeForField(Timestamp? value) {
if (value == null) return '';
try {
return DateFormat('HH:mm').format(value.toDateTime());
} catch (_) {
return '';
}
}
String _breakValueFromDuration(dc.EnumValue<dc.BreakDuration>? breakType) {
final dc.BreakDuration? value =
breakType is dc.Known<dc.BreakDuration> ? breakType.value : null;
switch (value) {
case dc.BreakDuration.MIN_15:
return 'MIN_15';
case dc.BreakDuration.MIN_30:
return 'MIN_30';
case dc.BreakDuration.NO_BREAK:
case null:
return 'NO_BREAK';
}
}
dc.BreakDuration _breakDurationFromValue(String value) {
switch (value) {
case 'MIN_15':
return dc.BreakDuration.MIN_15;
case 'MIN_30':
return dc.BreakDuration.MIN_30;
default:
return dc.BreakDuration.NO_BREAK;
}
}
_RoleOption? _roleById(String roleId) {
for (final _RoleOption role in _roles) {
if (role.id == roleId) {
return role;
}
}
return null;
}
double _rateForRole(String roleId) {
return _roleById(roleId)?.costPerHour ?? 0;
}
DateTime _parseDate(String value) {
try {
return DateFormat('yyyy-MM-dd').parse(value);
} catch (_) {
return DateTime.now();
}
}
DateTime _parseTime(DateTime date, String time) {
if (time.trim().isEmpty) {
throw Exception('Shift time is missing.');
}
DateTime parsed;
try {
parsed = DateFormat.Hm().parse(time);
} catch (_) {
parsed = DateFormat.jm().parse(time);
}
return DateTime(
date.year,
date.month,
date.day,
parsed.hour,
parsed.minute,
);
}
Timestamp _toTimestamp(DateTime date) {
final int millis = date.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return Timestamp(nanos, seconds);
}
double _calculateTotalCost() {
double total = 0;
for (final Map<String, dynamic> pos in _positions) {
final String roleId = pos['roleId']?.toString() ?? '';
if (roleId.isEmpty) {
continue;
}
final DateTime date = _parseDate(_dateController.text);
final DateTime start = _parseTime(date, pos['start_time'].toString());
final DateTime end = _parseTime(date, pos['end_time'].toString());
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = _rateForRole(roleId);
final int count = pos['count'] as int;
total += rate * hours * count;
}
return total;
}
Future<void> _saveOrderChanges() async {
if (_shiftId == null || _shiftId!.isEmpty) {
return;
}
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
return;
}
final DateTime orderDate = _parseDate(_dateController.text);
final String location = _globalLocationController.text;
int totalWorkers = 0;
double shiftCost = 0;
final List<_ShiftRoleKey> remainingOriginal =
List<_ShiftRoleKey>.from(_originalShiftRoles);
for (final Map<String, dynamic> pos in _positions) {
final String roleId = pos['roleId']?.toString() ?? '';
if (roleId.isEmpty) {
continue;
}
final String shiftId = pos['shiftId']?.toString() ?? _shiftId!;
final int count = pos['count'] as int;
final DateTime start = _parseTime(orderDate, pos['start_time'].toString());
final DateTime end = _parseTime(orderDate, pos['end_time'].toString());
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = _rateForRole(roleId);
final double totalValue = rate * hours * count;
final String lunchBreak = pos['lunch_break'] as String;
totalWorkers += count;
shiftCost += totalValue;
final String? originalRoleId = pos['originalRoleId']?.toString();
remainingOriginal.removeWhere(
(_ShiftRoleKey key) =>
key.shiftId == shiftId && key.roleId == originalRoleId,
);
if (originalRoleId != null && originalRoleId.isNotEmpty) {
if (originalRoleId != roleId) {
await _dataConnect
.deleteShiftRole(shiftId: shiftId, roleId: originalRoleId)
.execute();
await _dataConnect
.createShiftRole(
shiftId: shiftId,
roleId: roleId,
count: count,
)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(lunchBreak))
.totalValue(totalValue)
.execute();
} else {
await _dataConnect
.updateShiftRole(shiftId: shiftId, roleId: roleId)
.count(count)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(lunchBreak))
.totalValue(totalValue)
.execute();
}
} else {
await _dataConnect
.createShiftRole(
shiftId: shiftId,
roleId: roleId,
count: count,
)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(lunchBreak))
.totalValue(totalValue)
.execute();
}
}
for (final _ShiftRoleKey key in remainingOriginal) {
await _dataConnect
.deleteShiftRole(shiftId: key.shiftId, roleId: key.roleId)
.execute();
}
await _dataConnect
.updateOrder(id: widget.order.orderId)
.vendorId(_selectedVendor?.id)
.location(location)
.date(_toTimestamp(orderDate))
.execute();
await _dataConnect
.updateShift(id: _shiftId!)
.title('shift 1 ${DateFormat('yyyy-MM-dd').format(orderDate)}')
.date(_toTimestamp(orderDate))
.location(location)
.locationAddress(location)
.workersNeeded(totalWorkers)
.cost(shiftCost)
.durationDays(1)
.execute();
}
void _addPosition() {
setState(() {
_positions.add(<String, dynamic>{
'role': '',
'count': 1,
'start_time': '09:00',
'end_time': '17:00',
'lunch_break': 0,
'location': null,
});
_positions.add(_emptyPosition());
});
}
@@ -725,10 +1106,6 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
setState(() => _positions[index][key] = value);
}
double _calculateTotalCost() {
return widget.order.totalValue;
}
@override
Widget build(BuildContext context) {
if (_isLoading && _showReview) {
@@ -781,6 +1158,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
onChanged: (Vendor? vendor) {
if (vendor != null) {
setState(() => _selectedVendor = vendor);
_loadRolesForVendor(vendor.id);
}
},
items: _vendors.map((Vendor vendor) {
@@ -956,30 +1334,32 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
_buildDropdownField(
hint: 'Select role',
value: pos['role'],
value: pos['roleId'],
items: <String>[
...(_selectedVendor?.rates.keys.toList() ??
<String>[
'Server',
'Bartender',
'Cook',
'Busser',
'Host',
'Barista',
'Dishwasher',
'Event Staff',
]),
if (pos['role'] != null &&
pos['role'].toString().isNotEmpty &&
!(_selectedVendor?.rates.keys.contains(pos['role']) ?? false))
pos['role'].toString(),
..._roles.map((_RoleOption role) => role.id),
if (pos['roleId'] != null &&
pos['roleId'].toString().isNotEmpty &&
!_roles.any(
(_RoleOption role) => role.id == pos['roleId'].toString(),
))
pos['roleId'].toString(),
],
itemBuilder: (dynamic role) {
final double? rate = _selectedVendor?.rates[role];
if (rate == null) return role.toString();
return '$role - \$${rate.toStringAsFixed(0)}/hr';
itemBuilder: (dynamic roleId) {
final _RoleOption? role = _roleById(roleId.toString());
if (role == null) {
final String fallback = pos['roleName']?.toString() ?? '';
return fallback.isEmpty ? roleId.toString() : fallback;
}
return '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}/hr';
},
onChanged: (dynamic val) {
final String roleId = val?.toString() ?? '';
final _RoleOption? role = _roleById(roleId);
setState(() {
_positions[index]['roleId'] = roleId;
_positions[index]['roleName'] = role?.name ?? '';
});
},
onChanged: (dynamic val) => _updatePosition(index, 'role', val),
),
const SizedBox(height: UiConstants.space3),
@@ -1117,10 +1497,16 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
_buildDropdownField(
hint: 'No break',
value: pos['lunch_break'],
items: <int>[0, 15, 30, 45, 60],
items: <String>['NO_BREAK', 'MIN_15', 'MIN_30'],
itemBuilder: (dynamic val) {
if (val == 0) return 'No break';
return '$val min';
switch (val.toString()) {
case 'MIN_15':
return '15 min';
case 'MIN_30':
return '30 min';
default:
return 'No break';
}
},
onChanged: (dynamic val) =>
_updatePosition(index, 'lunch_break', val),
@@ -1379,7 +1765,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
text: 'Confirm & Save',
onPressed: () async {
setState(() => _isLoading = true);
await Future<void>.delayed(const Duration(seconds: 1));
await _saveOrderChanges();
if (mounted) Navigator.pop(context);
},
),
@@ -1413,8 +1799,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
}
Widget _buildReviewPositionCard(Map<String, dynamic> pos) {
final double rate =
_selectedVendor?.rates[pos['role']] ?? widget.order.hourlyRate;
final String roleId = pos['roleId']?.toString() ?? '';
final _RoleOption? role = _roleById(roleId);
final double rate = role?.costPerHour ?? 0;
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -1433,9 +1820,9 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
pos['role'].toString().isEmpty
(role?.name ?? pos['roleName']?.toString() ?? '').isEmpty
? 'Position'
: pos['role'].toString(),
: (role?.name ?? pos['roleName']?.toString() ?? ''),
style: UiTypography.body2b.textPrimary,
),
Text(