From 85c8a09d9e68aeefa28ae2ee8f3a280b8774f4c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Tue, 17 Feb 2026 16:24:59 -0500 Subject: [PATCH] first version of recurring order --- .../packages/domain/lib/krow_domain.dart | 2 + .../src/entities/orders/recurring_order.dart | 101 +++++ .../orders/recurring_order_position.dart | 60 +++ .../lib/src/create_order_module.dart | 4 + .../client_create_order_repository_impl.dart | 123 +++++- .../arguments/recurring_order_arguments.dart | 6 + ...ent_create_order_repository_interface.dart | 3 + .../create_recurring_order_usecase.dart | 16 + .../blocs/recurring_order_bloc.dart | 328 +++++++++++++++ .../blocs/recurring_order_event.dart | 109 +++++ .../blocs/recurring_order_state.dart | 213 ++++++++++ .../pages/recurring_order_page.dart | 35 +- .../recurring_order_date_picker.dart | 74 ++++ .../recurring_order_event_name_input.dart | 56 +++ .../recurring_order_header.dart | 71 ++++ .../recurring_order_position_card.dart | 345 +++++++++++++++ .../recurring_order_section_header.dart | 52 +++ .../recurring_order_success_view.dart | 104 +++++ .../recurring_order/recurring_order_view.dart | 394 ++++++++++++++++++ .../dataconnect/connector/order/mutations.gql | 4 +- backend/dataconnect/dataconnect.yaml | 4 +- backend/dataconnect/schema/order.gql | 2 +- 22 files changed, 2068 insertions(+), 38 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart create mode 100644 apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index bbe513ae..a3e3ca0f 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -37,6 +37,8 @@ export 'src/adapters/shifts/break/break_adapter.dart'; export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/one_time_order.dart'; export 'src/entities/orders/one_time_order_position.dart'; +export 'src/entities/orders/recurring_order.dart'; +export 'src/entities/orders/recurring_order_position.dart'; export 'src/entities/orders/order_item.dart'; // Skills & Certs diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart new file mode 100644 index 00000000..f11b63ec --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -0,0 +1,101 @@ +import 'package:equatable/equatable.dart'; +import 'recurring_order_position.dart'; + +/// Represents a recurring staffing request spanning a date range. +class RecurringOrder extends Equatable { + const RecurringOrder({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.roleRates = const {}, + }); + + /// Start date for the recurring schedule. + final DateTime startDate; + + /// End date for the recurring schedule. + final DateTime endDate; + + /// Days of the week to repeat on (e.g., ["S", "M", ...]). + final List recurringDays; + + /// The primary location where the work will take place. + final String location; + + /// The list of positions and headcounts required for this order. + final List positions; + + /// Selected hub details for this order. + final RecurringOrderHubDetails? hub; + + /// Optional order name. + final String? eventName; + + /// Selected vendor id for this order. + final String? vendorId; + + /// Role hourly rates keyed by role id. + final Map roleRates; + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + positions, + hub, + eventName, + vendorId, + roleRates, + ]; +} + +/// Minimal hub details used during recurring order creation. +class RecurringOrderHubDetails extends Equatable { + const RecurringOrderHubDetails({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart new file mode 100644 index 00000000..9fdc2161 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [RecurringOrder]. +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak = 'NO_BREAK', + this.location, + }); + + /// The job role or title required. + final String role; + + /// The number of workers required for this position. + final int count; + + /// The scheduled start time (e.g., "09:00 AM"). + final String startTime; + + /// The scheduled end time (e.g., "05:00 PM"). + final String endTime; + + /// The break duration enum value (e.g., NO_BREAK, MIN_15, MIN_30). + final String lunchBreak; + + /// Optional specific location for this position, if different from the order's main location. + final String? location; + + @override + List get props => [ + role, + count, + startTime, + endTime, + lunchBreak, + location, + ]; + + /// Creates a copy of this position with the given fields replaced. + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + location: location ?? this.location, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart index 0e2624e2..85443a13 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/create_order_module.dart @@ -5,10 +5,12 @@ import 'package:krow_data_connect/krow_data_connect.dart'; import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/usecases/create_one_time_order_usecase.dart'; +import 'domain/usecases/create_recurring_order_usecase.dart'; import 'domain/usecases/create_rapid_order_usecase.dart'; import 'domain/usecases/get_order_types_usecase.dart'; import 'presentation/blocs/client_create_order_bloc.dart'; import 'presentation/blocs/one_time_order_bloc.dart'; +import 'presentation/blocs/recurring_order_bloc.dart'; import 'presentation/blocs/rapid_order_bloc.dart'; import 'presentation/pages/create_order_page.dart'; import 'presentation/pages/one_time_order_page.dart'; @@ -33,12 +35,14 @@ class ClientCreateOrderModule extends Module { // UseCases i.addLazySingleton(GetOrderTypesUseCase.new); i.addLazySingleton(CreateOneTimeOrderUseCase.new); + i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRapidOrderUseCase.new); // BLoCs i.add(ClientCreateOrderBloc.new); i.add(RapidOrderBloc.new); i.add(OneTimeOrderBloc.new); + i.add(RecurringOrderBloc.new); } @override diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index d5c90dea..3ed4a088 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -33,11 +33,11 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte // titleKey: 'client_create_order.types.rapid', // descriptionKey: 'client_create_order.types.rapid_desc', // ), - // domain.OrderType( - // id: 'recurring', - // titleKey: 'client_create_order.types.recurring', - // descriptionKey: 'client_create_order.types.recurring_desc', - // ), + domain.OrderType( + id: 'recurring', + titleKey: 'client_create_order.types.recurring', + descriptionKey: 'client_create_order.types.recurring_desc', + ), // domain.OrderType( // id: 'permanent', // titleKey: 'client_create_order.types.permanent', @@ -139,6 +139,105 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte }); } + @override + Future createRecurringOrder(domain.RecurringOrder order) async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + final String? vendorId = order.vendorId; + if (vendorId == null || vendorId.isEmpty) { + throw Exception('Vendor is missing.'); + } + final domain.RecurringOrderHubDetails? hub = order.hub; + if (hub == null || hub.id.isEmpty) { + throw Exception('Hub is missing.'); + } + + final DateTime orderDateOnly = DateTime( + order.startDate.year, + order.startDate.month, + order.startDate.day, + ); + final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly); + final fdc.Timestamp startTimestamp = orderTimestamp; + final fdc.Timestamp endTimestamp = _service.toTimestamp(order.endDate); + + final fdc.OperationResult orderResult = + await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.RECURRING, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .endDate(endTimestamp) + .recurringDays(order.recurringDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.RecurringOrderPosition position) => sum + position.count, + ); + final String shiftTitle = 'Shift 1 ${_formatDate(order.startDate)}'; + final double shiftCost = _calculateRecurringShiftCost(order); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(orderTimestamp) + .location(hub.name) + .locationAddress(hub.address) + .latitude(hub.latitude) + .longitude(hub.longitude) + .placeId(hub.placeId) + .city(hub.city) + .state(hub.state) + .street(hub.street) + .country(hub.country) + .status(dc.ShiftStatus.PENDING) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + 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 = order.roleRates[position.role] ?? 0; + final double totalValue = rate * hours * position.count; + + await _service.connector + .createShiftRole( + shiftId: shiftId, + roleId: position.role, + count: position.count, + ) + .startTime(_service.toTimestamp(start)) + .endTime(_service.toTimestamp(normalizedEnd)) + .hours(hours) + .breakType(_breakDurationFromValue(position.lunchBreak)) + .isBreakPaid(_isBreakPaid(position.lunchBreak)) + .totalValue(totalValue) + .execute(); + } + + await _service.connector + .updateOrder(id: orderId, teamHubId: hub.id) + .shifts(fdc.AnyValue([shiftId])) + .execute(); + }); + } + @override Future createRapidOrder(String description) async { // TO-DO: connect IA and return array with the information. @@ -159,6 +258,20 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte return total; } + double _calculateRecurringShiftCost(domain.RecurringOrder order) { + double total = 0; + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(order.startDate, position.startTime); + final DateTime end = _parseTime(order.startDate, position.endTime); + 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 = order.roleRates[position.role] ?? 0; + total += rate * hours * position.count; + } + return total; + } + dc.BreakDuration _breakDurationFromValue(String value) { switch (value) { case 'MIN_10': diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart new file mode 100644 index 00000000..8c0c3d99 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/recurring_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class RecurringOrderArguments { + const RecurringOrderArguments({required this.order}); + final RecurringOrder order; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart index 9f2fd567..3ec67087 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/repositories/client_create_order_repository_interface.dart @@ -17,6 +17,9 @@ abstract interface class ClientCreateOrderRepositoryInterface { /// [order] contains the date, location, and required positions. Future createOneTimeOrder(OneTimeOrder order); + /// Submits a recurring staffing order with specific details. + Future createRecurringOrder(RecurringOrder order); + /// Submits a rapid (urgent) staffing order via a text description. /// /// [description] is the text message (or transcribed voice) describing the need. diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart new file mode 100644 index 00000000..f24c5841 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; +import '../arguments/recurring_order_arguments.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a recurring staffing order. +class CreateRecurringOrderUseCase + implements UseCase { + /// Creates a [CreateRecurringOrderUseCase]. + const CreateRecurringOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(RecurringOrderArguments input) { + return _repository.createRecurringOrder(input.order); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart new file mode 100644 index 00000000..e58178b9 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart @@ -0,0 +1,328 @@ +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/arguments/recurring_order_arguments.dart'; +import '../../domain/usecases/create_recurring_order_usecase.dart'; +import 'recurring_order_event.dart'; +import 'recurring_order_state.dart'; + +/// BLoC for managing the recurring order creation form. +class RecurringOrderBloc extends Bloc + with BlocErrorHandler, SafeBloc { + RecurringOrderBloc(this._createRecurringOrderUseCase, this._service) + : super(RecurringOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onEndDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + + _loadVendors(); + _loadHubs(); + } + + final CreateRecurringOrderUseCase _createRecurringOrderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + + Future _loadVendors() async { + final List? vendors = await handleErrorWithResult( + action: () async { + final QueryResult result = + await _service.connector.listVendors().execute(); + return result.data.vendors + .map( + (dc.ListVendorsVendors vendor) => domain.Vendor( + id: vendor.id, + name: vendor.companyName, + rates: const {}, + ), + ) + .toList(); + }, + onError: (_) => add(const RecurringOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(RecurringOrderVendorsLoaded(vendors)); + } + } + + Future _loadRolesForVendor( + String vendorId, + Emitter emit, + ) async { + final List? roles = await handleErrorWithResult( + action: () async { + final QueryResult + result = await _service.connector + .listRolesByVendorId(vendorId: vendorId) + .execute(); + return result.data.roles + .map( + (dc.ListRolesByVendorIdRoles role) => RecurringOrderRoleOption( + id: role.id, + name: role.name, + costPerHour: role.costPerHour, + ), + ) + .toList(); + }, + onError: (_) => emit(state.copyWith(roles: const [])), + ); + + if (roles != null) { + emit(state.copyWith(roles: roles)); + } + } + + Future _loadHubs() async { + final List? hubs = await handleErrorWithResult( + action: () async { + final String businessId = await _service.getBusinessId(); + final QueryResult + result = await _service.connector + .listTeamHubsByOwnerId(ownerId: businessId) + .execute(); + return result.data.teamHubs + .map( + (dc.ListTeamHubsByOwnerIdTeamHubs hub) => RecurringOrderHubOption( + id: hub.id, + name: hub.hubName, + address: hub.address, + placeId: hub.placeId, + latitude: hub.latitude, + longitude: hub.longitude, + city: hub.city, + state: hub.state, + street: hub.street, + country: hub.country, + zipCode: hub.zipCode, + ), + ) + .toList(); + }, + onError: (_) => add(const RecurringOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(RecurringOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + RecurringOrderVendorsLoaded event, + Emitter emit, + ) async { + final domain.Vendor? selectedVendor = + event.vendors.isNotEmpty ? event.vendors.first : null; + emit( + state.copyWith( + vendors: event.vendors, + selectedVendor: selectedVendor, + ), + ); + if (selectedVendor != null) { + await _loadRolesForVendor(selectedVendor.id, emit); + } + } + + Future _onVendorChanged( + RecurringOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + void _onHubsLoaded( + RecurringOrderHubsLoaded event, + Emitter emit, + ) { + final RecurringOrderHubOption? selectedHub = + event.hubs.isNotEmpty ? event.hubs.first : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + } + + void _onHubChanged( + RecurringOrderHubChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + selectedHub: event.hub, + location: event.hub.name, + ), + ); + } + + void _onEventNameChanged( + RecurringOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + RecurringOrderStartDateChanged event, + Emitter emit, + ) { + DateTime endDate = state.endDate; + if (endDate.isBefore(event.date)) { + endDate = event.date; + } + emit(state.copyWith(startDate: event.date, endDate: endDate)); + } + + void _onEndDateChanged( + RecurringOrderEndDateChanged event, + Emitter emit, + ) { + DateTime startDate = state.startDate; + if (event.date.isBefore(startDate)) { + startDate = event.date; + } + emit(state.copyWith(endDate: event.date, startDate: startDate)); + } + + void _onDayToggled( + RecurringOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.recurringDays); + if (days.contains(event.dayIndex)) { + days.remove(event.dayIndex); + } else { + days.add(event.dayIndex); + days.sort(); + } + emit(state.copyWith(recurringDays: days)); + } + + void _onPositionAdded( + RecurringOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const RecurringOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + RecurringOrderPositionRemoved event, + Emitter emit, + ) { + if (state.positions.length > 1) { + final List newPositions = + List.from(state.positions) + ..removeAt(event.index); + emit(state.copyWith(positions: newPositions)); + } + } + + void _onPositionUpdated( + RecurringOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + RecurringOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: RecurringOrderStatus.loading)); + await handleError( + emit: emit, + action: () async { + final Map roleRates = { + for (final RecurringOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final RecurringOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw domain.OrderMissingHubException(); + } + final List recurringDays = state.recurringDays + .map((int index) => _dayLabels[index]) + .toList(); + final domain.RecurringOrder order = domain.RecurringOrder( + startDate: state.startDate, + endDate: state.endDate, + recurringDays: recurringDays, + location: selectedHub.name, + positions: state.positions + .map( + (RecurringOrderPosition p) => domain.RecurringOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.RecurringOrderHubDetails( + id: selectedHub.id, + name: selectedHub.name, + address: selectedHub.address, + placeId: selectedHub.placeId, + latitude: selectedHub.latitude, + longitude: selectedHub.longitude, + city: selectedHub.city, + state: selectedHub.state, + street: selectedHub.street, + country: selectedHub.country, + zipCode: selectedHub.zipCode, + ), + eventName: state.eventName, + vendorId: state.selectedVendor?.id, + roleRates: roleRates, + ); + await _createRecurringOrderUseCase( + RecurringOrderArguments(order: order), + ); + emit(state.copyWith(status: RecurringOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: RecurringOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart new file mode 100644 index 00000000..3803153a --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_event.dart @@ -0,0 +1,109 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'recurring_order_state.dart'; + +abstract class RecurringOrderEvent extends Equatable { + const RecurringOrderEvent(); + + @override + List get props => []; +} + +class RecurringOrderVendorsLoaded extends RecurringOrderEvent { + const RecurringOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class RecurringOrderVendorChanged extends RecurringOrderEvent { + const RecurringOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class RecurringOrderHubsLoaded extends RecurringOrderEvent { + const RecurringOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class RecurringOrderHubChanged extends RecurringOrderEvent { + const RecurringOrderHubChanged(this.hub); + + final RecurringOrderHubOption hub; + + @override + List get props => [hub]; +} + +class RecurringOrderEventNameChanged extends RecurringOrderEvent { + const RecurringOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class RecurringOrderStartDateChanged extends RecurringOrderEvent { + const RecurringOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderEndDateChanged extends RecurringOrderEvent { + const RecurringOrderEndDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class RecurringOrderDayToggled extends RecurringOrderEvent { + const RecurringOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class RecurringOrderPositionAdded extends RecurringOrderEvent { + const RecurringOrderPositionAdded(); +} + +class RecurringOrderPositionRemoved extends RecurringOrderEvent { + const RecurringOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class RecurringOrderPositionUpdated extends RecurringOrderEvent { + const RecurringOrderPositionUpdated(this.index, this.position); + + final int index; + final RecurringOrderPosition position; + + @override + List get props => [index, position]; +} + +class RecurringOrderSubmitted extends RecurringOrderEvent { + const RecurringOrderSubmitted(); +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart new file mode 100644 index 00000000..f76009c1 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart @@ -0,0 +1,213 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum RecurringOrderStatus { initial, loading, success, failure } + +class RecurringOrderState extends Equatable { + const RecurringOrderState({ + required this.startDate, + required this.endDate, + required this.recurringDays, + required this.location, + required this.eventName, + required this.positions, + this.status = RecurringOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + }); + + factory RecurringOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + return RecurringOrderState( + startDate: start, + endDate: start.add(const Duration(days: 7)), + recurringDays: const [], + location: '', + eventName: '', + positions: const [ + RecurringOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + vendors: const [], + hubs: const [], + roles: const [], + ); + } + + final DateTime startDate; + final DateTime endDate; + final List recurringDays; + final String location; + final String eventName; + final List positions; + final RecurringOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final RecurringOrderHubOption? selectedHub; + final List roles; + + RecurringOrderState copyWith({ + DateTime? startDate, + DateTime? endDate, + List? recurringDays, + String? location, + String? eventName, + List? positions, + RecurringOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + RecurringOrderHubOption? selectedHub, + List? roles, + }) { + return RecurringOrderState( + startDate: startDate ?? this.startDate, + endDate: endDate ?? this.endDate, + recurringDays: recurringDays ?? this.recurringDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + vendors: vendors ?? this.vendors, + selectedVendor: selectedVendor ?? this.selectedVendor, + hubs: hubs ?? this.hubs, + selectedHub: selectedHub ?? this.selectedHub, + roles: roles ?? this.roles, + ); + } + + bool get isValid { + final bool datesValid = !endDate.isBefore(startDate); + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + recurringDays.isNotEmpty && + datesValid && + positions.every( + (RecurringOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + endDate, + recurringDays, + location, + eventName, + positions, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + ]; +} + +class RecurringOrderHubOption extends Equatable { + const RecurringOrderHubOption({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +class RecurringOrderRoleOption extends Equatable { + const RecurringOrderRoleOption({ + required this.id, + required this.name, + required this.costPerHour, + }); + + final String id; + final String name; + final double costPerHour; + + @override + List get props => [id, name, costPerHour]; +} + +class RecurringOrderPosition extends Equatable { + const RecurringOrderPosition({ + required this.role, + required this.count, + required this.startTime, + required this.endTime, + this.lunchBreak, + }); + + final String role; + final int count; + final String startTime; + final String endTime; + final String? lunchBreak; + + RecurringOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return RecurringOrderPosition( + role: role ?? this.role, + count: count ?? this.count, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + lunchBreak: lunchBreak ?? this.lunchBreak, + ); + } + + @override + List get props => [role, count, startTime, endTime, lunchBreak]; +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart index a649ea9b..009c4d64 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -1,40 +1,19 @@ -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; +import '../blocs/recurring_order_bloc.dart'; +import '../widgets/recurring_order/recurring_order_view.dart'; -/// Recurring Order Page - Ongoing weekly/monthly coverage. -/// Placeholder for future implementation. +/// Page for creating a recurring staffing order. class RecurringOrderPage extends StatelessWidget { /// Creates a [RecurringOrderPage]. const RecurringOrderPage({super.key}); @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderRecurringEn labels = - t.client_create_order.recurring; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.toClientHome(), - ), - body: Center( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space6), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - labels.subtitle, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - ], - ), - ), - ), + return BlocProvider( + create: (BuildContext context) => Modular.get(), + child: const RecurringOrderView(), ); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart new file mode 100644 index 00000000..f9b7df68 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_date_picker.dart @@ -0,0 +1,74 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// A date picker field for the recurring order form. +class RecurringOrderDatePicker extends StatefulWidget { + /// Creates a [RecurringOrderDatePicker]. + const RecurringOrderDatePicker({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + /// The label text to display above the field. + final String label; + + /// The currently selected date. + final DateTime value; + + /// Callback when a new date is selected. + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderDatePickerState(); +} + +class _RecurringOrderDatePickerState extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: DateFormat('yyyy-MM-dd').format(widget.value), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(RecurringOrderDatePicker oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != oldWidget.value) { + _controller.text = DateFormat('yyyy-MM-dd').format(widget.value); + } + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + readOnly: true, + prefixIcon: UiIcons.calendar, + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: widget.value, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) { + widget.onChanged(picked); + } + }, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart new file mode 100644 index 00000000..22d7cae9 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_event_name_input.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A text input for the order name in the recurring order form. +class RecurringOrderEventNameInput extends StatefulWidget { + const RecurringOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _RecurringOrderEventNameInputState(); +} + +class _RecurringOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(RecurringOrderEventNameInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.value != _controller.text) { + _controller.text = widget.value; + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTextField( + label: widget.label, + controller: _controller, + onChanged: widget.onChanged, + hintText: 'Order name', + prefixIcon: UiIcons.briefcase, + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart new file mode 100644 index 00000000..5913b205 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the recurring order flow with a colored background. +class RecurringOrderHeader extends StatelessWidget { + /// Creates a [RecurringOrderHeader]. + const RecurringOrderHeader({ + required this.title, + required this.subtitle, + required this.onBack, + super.key, + }); + + /// The title of the page. + final String title; + + /// The subtitle or description. + final String subtitle; + + /// Callback when the back button is pressed. + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + top: MediaQuery.of(context).padding.top + UiConstants.space5, + bottom: UiConstants.space5, + left: UiConstants.space5, + right: UiConstants.space5, + ), + color: UiColors.primary, + child: Row( + children: [ + GestureDetector( + onTap: onBack, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withValues(alpha: 0.2), + borderRadius: UiConstants.radiusMd, + ), + child: const Icon( + UiIcons.chevronLeft, + color: UiColors.white, + size: 24, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: UiTypography.headline3m.copyWith(color: UiColors.white), + ), + Text( + subtitle, + style: UiTypography.footnote2r.copyWith( + color: UiColors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart new file mode 100644 index 00000000..f6b94670 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_position_card.dart @@ -0,0 +1,345 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import '../../blocs/recurring_order_state.dart'; + +/// A card widget for editing a specific position in a recurring order. +class RecurringOrderPositionCard extends StatelessWidget { + /// Creates a [RecurringOrderPositionCard]. + const RecurringOrderPositionCard({ + required this.index, + required this.position, + required this.isRemovable, + required this.onUpdated, + required this.onRemoved, + required this.positionLabel, + required this.roleLabel, + required this.workersLabel, + required this.startLabel, + required this.endLabel, + required this.lunchLabel, + required this.roles, + super.key, + }); + + /// The index of the position in the list. + final int index; + + /// The position entity data. + final RecurringOrderPosition position; + + /// Whether this position can be removed (usually if there's more than one). + final bool isRemovable; + + /// Callback when the position data is updated. + final ValueChanged onUpdated; + + /// Callback when the position is removed. + final VoidCallback onRemoved; + + /// Label for positions (e.g., "Position"). + final String positionLabel; + + /// Label for the role selection. + final String roleLabel; + + /// Label for the worker count. + final String workersLabel; + + /// Label for the start time. + final String startLabel; + + /// Label for the end time. + final String endLabel; + + /// Label for the lunch break. + final String lunchLabel; + + /// Available roles for the selected vendor. + final List roles; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '$positionLabel #${index + 1}', + style: UiTypography.footnote1m.textSecondary, + ), + if (isRemovable) + GestureDetector( + onTap: onRemoved, + child: Text( + t.client_create_order.one_time.remove, + style: UiTypography.footnote1m.copyWith( + color: UiColors.destructive, + ), + ), + ), + ], + ), + const SizedBox(height: UiConstants.space3), + + // Role (Dropdown) + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + hint: Text( + roleLabel, + style: UiTypography.body2r.textPlaceholder, + ), + value: position.role.isEmpty ? null : position.role, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(role: val)); + } + }, + items: _buildRoleItems(), + ), + ), + ), + const SizedBox(height: UiConstants.space3), + + // Start/End/Workers Row + Row( + children: [ + // Start Time + Expanded( + child: _buildTimeInput( + context: context, + label: startLabel, + value: position.startTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(startTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // End Time + Expanded( + child: _buildTimeInput( + context: context, + label: endLabel, + value: position.endTime, + onTap: () async { + final TimeOfDay? picked = await showTimePicker( + context: context, + initialTime: TimeOfDay.now(), + ); + if (picked != null && context.mounted) { + onUpdated( + position.copyWith(endTime: picked.format(context)), + ); + } + }, + ), + ), + const SizedBox(width: UiConstants.space2), + // Workers Count + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + workersLabel, + style: UiTypography.footnote2r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Container( + height: 40, + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusSm, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + GestureDetector( + onTap: () { + if (position.count > 1) { + onUpdated( + position.copyWith(count: position.count - 1), + ); + } + }, + child: const Icon(UiIcons.minus, size: 12), + ), + Text( + '${position.count}', + style: UiTypography.body2b.textPrimary, + ), + GestureDetector( + onTap: () { + onUpdated( + position.copyWith(count: position.count + 1), + ); + }, + child: const Icon(UiIcons.add, size: 12), + ), + ], + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: UiConstants.space4), + + // Lunch Break + Text(lunchLabel, style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space3), + height: 44, + decoration: BoxDecoration( + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.border), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: position.lunchBreak, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (String? val) { + if (val != null) { + onUpdated(position.copyWith(lunchBreak: val)); + } + }, + items: [ + 'NO_BREAK', + 'MIN_10', + 'MIN_15', + 'MIN_30', + 'MIN_45', + 'MIN_60', + ].map((String value) { + final String label = switch (value) { + 'NO_BREAK' => 'No Break', + 'MIN_10' => '10 min (Paid)', + 'MIN_15' => '15 min (Paid)', + 'MIN_30' => '30 min (Unpaid)', + 'MIN_45' => '45 min (Unpaid)', + 'MIN_60' => '60 min (Unpaid)', + _ => value, + }; + return DropdownMenuItem( + value: value, + child: Text( + label, + style: UiTypography.body2r.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + ], + ), + ); + } + + Widget _buildTimeInput({ + required BuildContext context, + required String label, + required String value, + required VoidCallback onTap, + }) { + 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, + ), + ], + ), + ), + ), + ], + ); + } + + List> _buildRoleItems() { + final List> items = roles + .map( + (RecurringOrderRoleOption role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((RecurringOrderRoleOption role) => role.id == position.role); + if (position.role.isNotEmpty && !hasSelected) { + items.add( + DropdownMenuItem( + value: position.role, + child: Text( + position.role, + style: UiTypography.body2r.textPrimary, + ), + ), + ); + } + + return items; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart new file mode 100644 index 00000000..85326cb6 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_section_header.dart @@ -0,0 +1,52 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for sections in the recurring order form. +class RecurringOrderSectionHeader extends StatelessWidget { + /// Creates a [RecurringOrderSectionHeader]. + const RecurringOrderSectionHeader({ + required this.title, + this.actionLabel, + this.onAction, + super.key, + }); + + /// The title text for the section. + final String title; + + /// Optional label for an action button on the right. + final String? actionLabel; + + /// Callback when the action button is tapped. + final VoidCallback? onAction; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title, style: UiTypography.headline4m.textPrimary), + if (actionLabel != null && onAction != null) + TextButton( + onPressed: onAction, + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(UiIcons.add, size: 16, color: UiColors.primary), + const SizedBox(width: UiConstants.space2), + Text( + actionLabel!, + style: UiTypography.body2m.primary, + ), + ], + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart new file mode 100644 index 00000000..3739c5ad --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_success_view.dart @@ -0,0 +1,104 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A view to display when a recurring order has been successfully created. +class RecurringOrderSuccessView extends StatelessWidget { + /// Creates a [RecurringOrderSuccessView]. + const RecurringOrderSuccessView({ + required this.title, + required this.message, + required this.buttonLabel, + required this.onDone, + super.key, + }); + + /// The title of the success message. + final String title; + + /// The body of the success message. + final String message; + + /// Label for the completion button. + final String buttonLabel; + + /// Callback when the completion button is tapped. + final VoidCallback onDone; + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + ), + ), + child: SafeArea( + child: Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space10), + padding: const EdgeInsets.all(UiConstants.space8), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg * 1.5, + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.2), + blurRadius: 20, + offset: const Offset(0, UiConstants.space2 + 2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: UiConstants.space16, + height: UiConstants.space16, + decoration: const BoxDecoration( + color: UiColors.accent, + shape: BoxShape.circle, + ), + child: const Center( + child: Icon( + UiIcons.check, + color: UiColors.black, + size: UiConstants.space8, + ), + ), + ), + const SizedBox(height: UiConstants.space6), + Text( + title, + style: UiTypography.headline2m.textPrimary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space3), + Text( + message, + textAlign: TextAlign.center, + style: UiTypography.body2r.textSecondary.copyWith( + height: 1.5, + ), + ), + const SizedBox(height: UiConstants.space8), + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: buttonLabel, + onPressed: onDone, + size: UiButtonSize.large, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart new file mode 100644 index 00000000..a5ab33eb --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -0,0 +1,394 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../../blocs/recurring_order_bloc.dart'; +import '../../blocs/recurring_order_event.dart'; +import '../../blocs/recurring_order_state.dart'; +import 'recurring_order_date_picker.dart'; +import 'recurring_order_event_name_input.dart'; +import 'recurring_order_header.dart'; +import 'recurring_order_position_card.dart'; +import 'recurring_order_section_header.dart'; +import 'recurring_order_success_view.dart'; + +/// The main content of the Recurring Order page. +class RecurringOrderView extends StatelessWidget { + /// Creates a [RecurringOrderView]. + const RecurringOrderView({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return BlocConsumer( + listener: (BuildContext context, RecurringOrderState state) { + if (state.status == RecurringOrderStatus.failure && + state.errorMessage != null) { + final String message = state.errorMessage == 'placeholder' + ? labels.placeholder + : translateErrorKey(state.errorMessage!); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + } + }, + builder: (BuildContext context, RecurringOrderState state) { + if (state.status == RecurringOrderStatus.success) { + return RecurringOrderSuccessView( + title: labels.title, + message: labels.subtitle, + buttonLabel: oneTimeLabels.back_to_orders, + onDone: () => Modular.to.pushNamedAndRemoveUntil( + ClientPaths.orders, + (_) => false, + arguments: { + 'initialDate': state.startDate.toIso8601String(), + }, + ), + ); + } + + if (state.vendors.isEmpty && + state.status != RecurringOrderStatus.loading) { + return Scaffold( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + 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( + body: Column( + children: [ + RecurringOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Stack( + children: [ + _RecurringOrderForm(state: state), + if (state.status == RecurringOrderStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: state.status == RecurringOrderStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: state.status == RecurringOrderStatus.loading, + onPressed: state.isValid + ? () => BlocProvider.of( + context, + ).add(const RecurringOrderSubmitted()) + : null, + ), + ], + ), + ); + }, + ); + } +} + +class _RecurringOrderForm extends StatelessWidget { + const _RecurringOrderForm({required this.state}); + final RecurringOrderState state; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderRecurringEn labels = + t.client_create_order.recurring; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return ListView( + padding: const EdgeInsets.all(UiConstants.space5), + children: [ + Text( + labels.title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderEventNameInput( + label: 'ORDER NAME', + value: state.eventName, + onChanged: (String value) => BlocProvider.of( + context, + ).add(RecurringOrderEventNameChanged(value)), + ), + const SizedBox(height: UiConstants.space4), + + // Vendor Selection + Text('SELECT VENDOR', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + 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(RecurringOrderVendorChanged(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), + + RecurringOrderDatePicker( + label: 'Start Date', + value: state.startDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(RecurringOrderStartDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + RecurringOrderDatePicker( + label: 'End Date', + value: state.endDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(RecurringOrderEndDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + Text('Recurring Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _RecurringDaysSelector( + selectedDays: state.recurringDays, + onToggle: (int dayIndex) => BlocProvider.of( + context, + ).add(RecurringOrderDayToggled(dayIndex)), + ), + const SizedBox(height: UiConstants.space4), + + Text('HUB', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + 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.selectedHub, + icon: const Icon( + UiIcons.chevronDown, + size: 18, + color: UiColors.iconSecondary, + ), + onChanged: (RecurringOrderHubOption? hub) { + if (hub != null) { + BlocProvider.of( + context, + ).add(RecurringOrderHubChanged(hub)); + } + }, + items: state.hubs.map((RecurringOrderHubOption hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + RecurringOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: () => BlocProvider.of( + context, + ).add(const RecurringOrderPositionAdded()), + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...state.positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final RecurringOrderPosition position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: RecurringOrderPositionCard( + index: index, + position: position, + isRemovable: state.positions.length > 1, + positionLabel: oneTimeLabels.positions_title, + roleLabel: oneTimeLabels.select_role, + workersLabel: oneTimeLabels.workers_label, + startLabel: oneTimeLabels.start_label, + endLabel: oneTimeLabels.end_label, + lunchLabel: oneTimeLabels.lunch_break_label, + roles: state.roles, + onUpdated: (RecurringOrderPosition updated) { + BlocProvider.of( + context, + ).add(RecurringOrderPositionUpdated(index, updated)); + }, + onRemoved: () { + BlocProvider.of( + context, + ).add(RecurringOrderPositionRemoved(index)); + }, + ), + ); + }), + ], + ); + } +} + +class _RecurringDaysSelector extends StatelessWidget { + const _RecurringDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labels = ['S', 'M', 'T', 'W', 'T', 'F', 'S']; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labels.length, (int index) { + final bool isSelected = selectedDays.contains(index); + return GestureDetector( + onTap: () => onToggle(index), + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + shape: BoxShape.circle, + border: Border.all(color: UiColors.border), + ), + alignment: Alignment.center, + child: Text( + labels[index], + style: UiTypography.body2m.copyWith( + color: isSelected ? UiColors.white : UiColors.textSecondary, + ), + ), + ), + ); + }), + ); + } +} + +class _BottomActionButton extends StatelessWidget { + const _BottomActionButton({ + required this.label, + required this.onPressed, + this.isLoading = false, + }); + final String label; + final VoidCallback? onPressed; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only( + left: UiConstants.space5, + right: UiConstants.space5, + top: UiConstants.space5, + bottom: MediaQuery.of(context).padding.bottom + UiConstants.space5, + ), + decoration: const BoxDecoration( + color: UiColors.white, + border: Border(top: BorderSide(color: UiColors.border)), + ), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: label, + onPressed: isLoading ? null : onPressed, + size: UiButtonSize.large, + ), + ), + ); + } +} diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 32423968..309fedb0 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,7 +15,7 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any + $recurringDays: [String!] $permanentStartDate: Timestamp $permanentDays: Any $notes: String @@ -64,7 +64,7 @@ mutation updateOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any + $recurringDays: [String!] $permanentDays: Any $notes: String $detectedConflicts: Any diff --git a/backend/dataconnect/dataconnect.yaml b/backend/dataconnect/dataconnect.yaml index 9e1775d6..39e01fdb 100644 --- a/backend/dataconnect/dataconnect.yaml +++ b/backend/dataconnect/dataconnect.yaml @@ -1,5 +1,5 @@ specVersion: "v1" -serviceId: "krow-workforce-db-validation" +serviceId: "krow-workforce-db" location: "us-central1" schema: source: "./schema" @@ -7,7 +7,7 @@ schema: postgresql: database: "krow_db" cloudSql: - instanceId: "krow-sql-validation" + instanceId: "krow-sql" # schemaValidation: "STRICT" # STRICT mode makes Postgres schema match Data Connect exactly. # schemaValidation: "COMPATIBLE" # COMPATIBLE mode makes Postgres schema compatible with Data Connect. connectorDirs: ["./connector"] diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 1c815e60..3863c081 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -52,7 +52,7 @@ type Order @table(name: "orders", key: ["id"]) { startDate: Timestamp #for recurring and permanent endDate: Timestamp #for recurring and permanent - recurringDays: Any @col(dataType: "jsonb") + recurringDays: [String!] poReference: String permanentDays: Any @col(dataType: "jsonb")