diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index d3b2ac2a..c604550c 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -37,6 +37,10 @@ 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/permanent_order.dart'; +export 'src/entities/orders/permanent_order_position.dart'; export 'src/entities/orders/order_item.dart'; // Skills & Certs diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart new file mode 100644 index 00000000..f7712bc4 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -0,0 +1,96 @@ +import 'package:equatable/equatable.dart'; +import 'permanent_order_position.dart'; + +/// Represents a permanent staffing request spanning a date range. +class PermanentOrder extends Equatable { + const PermanentOrder({ + required this.startDate, + required this.permanentDays, + required this.location, + required this.positions, + this.hub, + this.eventName, + this.vendorId, + this.roleRates = const {}, + }); + + /// Start date for the permanent schedule. + final DateTime startDate; + + /// Days of the week to repeat on (e.g., ["SUN", "MON", ...]). + final List permanentDays; + + /// 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 PermanentOrderHubDetails? 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, + permanentDays, + location, + positions, + hub, + eventName, + vendorId, + roleRates, + ]; +} + +/// Minimal hub details used during permanent order creation. +class PermanentOrderHubDetails extends Equatable { + const PermanentOrderHubDetails({ + 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/permanent_order_position.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart new file mode 100644 index 00000000..fb4d1e1b --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order_position.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a specific position requirement within a [PermanentOrder]. +class PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + 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. + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + String? location, + }) { + return PermanentOrderPosition( + 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/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..a99521d6 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,14 @@ 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_permanent_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/permanent_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 +37,16 @@ class ClientCreateOrderModule extends Module { // UseCases i.addLazySingleton(GetOrderTypesUseCase.new); i.addLazySingleton(CreateOneTimeOrderUseCase.new); + i.addLazySingleton(CreatePermanentOrderUseCase.new); + i.addLazySingleton(CreateRecurringOrderUseCase.new); i.addLazySingleton(CreateRapidOrderUseCase.new); // BLoCs i.add(ClientCreateOrderBloc.new); i.add(RapidOrderBloc.new); i.add(OneTimeOrderBloc.new); + i.add(PermanentOrderBloc.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..757aff1f 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,16 +33,21 @@ 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', // descriptionKey: 'client_create_order.types.permanent_desc', // ), + domain.OrderType( + id: 'permanent', + titleKey: 'client_create_order.types.permanent', + descriptionKey: 'client_create_order.types.permanent_desc', + ), ]); } @@ -100,7 +105,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .state(hub.state) .street(hub.street) .country(hub.country) - .status(dc.ShiftStatus.PENDING) + .status(dc.ShiftStatus.CONFIRMED) .workersNeeded(workersNeeded) .filled(0) .durationDays(1) @@ -139,6 +144,246 @@ 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; + + // NOTE: Recurring orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + final DateTime effectiveEndDate = + order.endDate.isAfter(maxEndDate) ? maxEndDate : order.endDate; + + final Set selectedDays = Set.from(order.recurringDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.RecurringOrderPosition position) => sum + position.count, + ); + final double shiftCost = _calculateRecurringShiftCost(order); + + final List shiftIds = []; + for (DateTime day = orderDateOnly; + !day.isAfter(effectiveEndDate); + day = day.add(const Duration(days: 1))) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final fdc.Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .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.CONFIRMED) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.RecurringOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, 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(shiftIds)) + .execute(); + }); + } + + @override + Future createPermanentOrder(domain.PermanentOrder 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.PermanentOrderHubDetails? 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.OperationResult orderResult = + await _service.connector + .createOrder( + businessId: businessId, + orderType: dc.OrderType.PERMANENT, + teamHubId: hub.id, + ) + .vendorId(vendorId) + .eventName(order.eventName) + .status(dc.OrderStatus.POSTED) + .date(orderTimestamp) + .startDate(startTimestamp) + .permanentDays(order.permanentDays) + .execute(); + + final String orderId = orderResult.data.order_insert.id; + + // NOTE: Permanent orders are limited to 30 days of generated shifts. + // Future shifts beyond 30 days should be created by a scheduled job. + final DateTime maxEndDate = orderDateOnly.add(const Duration(days: 29)); + + final Set selectedDays = Set.from(order.permanentDays); + final int workersNeeded = order.positions.fold( + 0, + (int sum, domain.PermanentOrderPosition position) => sum + position.count, + ); + final double shiftCost = _calculatePermanentShiftCost(order); + + final List shiftIds = []; + for (DateTime day = orderDateOnly; + !day.isAfter(maxEndDate); + day = day.add(const Duration(days: 1))) { + final String dayLabel = _weekdayLabel(day); + if (!selectedDays.contains(dayLabel)) { + continue; + } + + final String shiftTitle = 'Shift ${_formatDate(day)}'; + final fdc.Timestamp dayTimestamp = _service.toTimestamp( + DateTime(day.year, day.month, day.day), + ); + + final fdc.OperationResult shiftResult = + await _service.connector + .createShift(title: shiftTitle, orderId: orderId) + .date(dayTimestamp) + .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.CONFIRMED) + .workersNeeded(workersNeeded) + .filled(0) + .durationDays(1) + .cost(shiftCost) + .execute(); + + final String shiftId = shiftResult.data.shift_insert.id; + shiftIds.add(shiftId); + + for (final domain.PermanentOrderPosition position in order.positions) { + final DateTime start = _parseTime(day, position.startTime); + final DateTime end = _parseTime(day, 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(shiftIds)) + .execute(); + }); + } + @override Future createRapidOrder(String description) async { // TO-DO: connect IA and return array with the information. @@ -159,6 +404,54 @@ 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; + } + + double _calculatePermanentShiftCost(domain.PermanentOrder order) { + double total = 0; + for (final domain.PermanentOrderPosition 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; + } + + String _weekdayLabel(DateTime date) { + switch (date.weekday) { + case DateTime.monday: + return 'MON'; + case DateTime.tuesday: + return 'TUE'; + case DateTime.wednesday: + return 'WED'; + case DateTime.thursday: + return 'THU'; + case DateTime.friday: + return 'FRI'; + case DateTime.saturday: + return 'SAT'; + case DateTime.sunday: + default: + return 'SUN'; + } + } + dc.BreakDuration _breakDurationFromValue(String value) { switch (value) { case 'MIN_10': diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart new file mode 100644 index 00000000..0c0d5736 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/arguments/permanent_order_arguments.dart @@ -0,0 +1,6 @@ +import 'package:krow_domain/krow_domain.dart'; + +class PermanentOrderArguments { + const PermanentOrderArguments({required this.order}); + final PermanentOrder order; +} 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..0fe29f6b 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,12 @@ 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 permanent staffing order with specific details. + Future createPermanentOrder(PermanentOrder 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_permanent_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart new file mode 100644 index 00000000..b3afda92 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -0,0 +1,16 @@ +import 'package:krow_core/core.dart'; +import '../arguments/permanent_order_arguments.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Use case for creating a permanent staffing order. +class CreatePermanentOrderUseCase + implements UseCase { + /// Creates a [CreatePermanentOrderUseCase]. + const CreatePermanentOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(PermanentOrderArguments input) { + return _repository.createPermanentOrder(input.order); + } +} 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/permanent_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart new file mode 100644 index 00000000..731a8018 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart @@ -0,0 +1,338 @@ +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/permanent_order_arguments.dart'; +import '../../domain/usecases/create_permanent_order_usecase.dart'; +import 'permanent_order_event.dart'; +import 'permanent_order_state.dart'; + +/// BLoC for managing the permanent order creation form. +class PermanentOrderBloc extends Bloc + with BlocErrorHandler, SafeBloc { + PermanentOrderBloc(this._createPermanentOrderUseCase, this._service) + : super(PermanentOrderState.initial()) { + on(_onVendorsLoaded); + on(_onVendorChanged); + on(_onHubsLoaded); + on(_onHubChanged); + on(_onEventNameChanged); + on(_onStartDateChanged); + on(_onDayToggled); + on(_onPositionAdded); + on(_onPositionRemoved); + on(_onPositionUpdated); + on(_onSubmitted); + + _loadVendors(); + _loadHubs(); + } + + final CreatePermanentOrderUseCase _createPermanentOrderUseCase; + final dc.DataConnectService _service; + + static const List _dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + 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 PermanentOrderVendorsLoaded([])), + ); + + if (vendors != null) { + add(PermanentOrderVendorsLoaded(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) => PermanentOrderRoleOption( + 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) => PermanentOrderHubOption( + 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 PermanentOrderHubsLoaded([])), + ); + + if (hubs != null) { + add(PermanentOrderHubsLoaded(hubs)); + } + } + + Future _onVendorsLoaded( + PermanentOrderVendorsLoaded 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( + PermanentOrderVendorChanged event, + Emitter emit, + ) async { + emit(state.copyWith(selectedVendor: event.vendor)); + await _loadRolesForVendor(event.vendor.id, emit); + } + + void _onHubsLoaded( + PermanentOrderHubsLoaded event, + Emitter emit, + ) { + final PermanentOrderHubOption? selectedHub = + event.hubs.isNotEmpty ? event.hubs.first : null; + emit( + state.copyWith( + hubs: event.hubs, + selectedHub: selectedHub, + location: selectedHub?.name ?? '', + ), + ); + } + + void _onHubChanged( + PermanentOrderHubChanged event, + Emitter emit, + ) { + emit( + state.copyWith( + selectedHub: event.hub, + location: event.hub.name, + ), + ); + } + + void _onEventNameChanged( + PermanentOrderEventNameChanged event, + Emitter emit, + ) { + emit(state.copyWith(eventName: event.eventName)); + } + + void _onStartDateChanged( + PermanentOrderStartDateChanged event, + Emitter emit, + ) { + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.permanentDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + permanentDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + void _onDayToggled( + PermanentOrderDayToggled event, + Emitter emit, + ) { + final List days = List.from(state.permanentDays); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit(state.copyWith(permanentDays: _sortDays(days), autoSelectedDayIndex: autoIndex)); + } + + void _onPositionAdded( + PermanentOrderPositionAdded event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions)..add( + const PermanentOrderPosition( + role: '', + count: 1, + startTime: '09:00', + endTime: '17:00', + ), + ); + emit(state.copyWith(positions: newPositions)); + } + + void _onPositionRemoved( + PermanentOrderPositionRemoved 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( + PermanentOrderPositionUpdated event, + Emitter emit, + ) { + final List newPositions = + List.from(state.positions); + newPositions[event.index] = event.position; + emit(state.copyWith(positions: newPositions)); + } + + Future _onSubmitted( + PermanentOrderSubmitted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PermanentOrderStatus.loading)); + await handleError( + emit: emit, + action: () async { + final Map roleRates = { + for (final PermanentOrderRoleOption role in state.roles) + role.id: role.costPerHour, + }; + final PermanentOrderHubOption? selectedHub = state.selectedHub; + if (selectedHub == null) { + throw domain.OrderMissingHubException(); + } + final domain.PermanentOrder order = domain.PermanentOrder( + startDate: state.startDate, + permanentDays: state.permanentDays, + location: selectedHub.name, + positions: state.positions + .map( + (PermanentOrderPosition p) => domain.PermanentOrderPosition( + role: p.role, + count: p.count, + startTime: p.startTime, + endTime: p.endTime, + lunchBreak: p.lunchBreak ?? 'NO_BREAK', + location: null, + ), + ) + .toList(), + hub: domain.PermanentOrderHubDetails( + 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 _createPermanentOrderUseCase( + PermanentOrderArguments(order: order), + ); + emit(state.copyWith(status: PermanentOrderStatus.success)); + }, + onError: (String errorKey) => state.copyWith( + status: PermanentOrderStatus.failure, + errorMessage: errorKey, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart new file mode 100644 index 00000000..bcf98127 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_event.dart @@ -0,0 +1,100 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart' show Vendor; +import 'permanent_order_state.dart'; + +abstract class PermanentOrderEvent extends Equatable { + const PermanentOrderEvent(); + + @override + List get props => []; +} + +class PermanentOrderVendorsLoaded extends PermanentOrderEvent { + const PermanentOrderVendorsLoaded(this.vendors); + + final List vendors; + + @override + List get props => [vendors]; +} + +class PermanentOrderVendorChanged extends PermanentOrderEvent { + const PermanentOrderVendorChanged(this.vendor); + + final Vendor vendor; + + @override + List get props => [vendor]; +} + +class PermanentOrderHubsLoaded extends PermanentOrderEvent { + const PermanentOrderHubsLoaded(this.hubs); + + final List hubs; + + @override + List get props => [hubs]; +} + +class PermanentOrderHubChanged extends PermanentOrderEvent { + const PermanentOrderHubChanged(this.hub); + + final PermanentOrderHubOption hub; + + @override + List get props => [hub]; +} + +class PermanentOrderEventNameChanged extends PermanentOrderEvent { + const PermanentOrderEventNameChanged(this.eventName); + + final String eventName; + + @override + List get props => [eventName]; +} + +class PermanentOrderStartDateChanged extends PermanentOrderEvent { + const PermanentOrderStartDateChanged(this.date); + + final DateTime date; + + @override + List get props => [date]; +} + +class PermanentOrderDayToggled extends PermanentOrderEvent { + const PermanentOrderDayToggled(this.dayIndex); + + final int dayIndex; + + @override + List get props => [dayIndex]; +} + +class PermanentOrderPositionAdded extends PermanentOrderEvent { + const PermanentOrderPositionAdded(); +} + +class PermanentOrderPositionRemoved extends PermanentOrderEvent { + const PermanentOrderPositionRemoved(this.index); + + final int index; + + @override + List get props => [index]; +} + +class PermanentOrderPositionUpdated extends PermanentOrderEvent { + const PermanentOrderPositionUpdated(this.index, this.position); + + final int index; + final PermanentOrderPosition position; + + @override + List get props => [index, position]; +} + +class PermanentOrderSubmitted extends PermanentOrderEvent { + const PermanentOrderSubmitted(); +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart new file mode 100644 index 00000000..38dc743e --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_state.dart @@ -0,0 +1,221 @@ +import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; + +enum PermanentOrderStatus { initial, loading, success, failure } + +class PermanentOrderState extends Equatable { + const PermanentOrderState({ + required this.startDate, + required this.permanentDays, + required this.location, + required this.eventName, + required this.positions, + required this.autoSelectedDayIndex, + this.status = PermanentOrderStatus.initial, + this.errorMessage, + this.vendors = const [], + this.selectedVendor, + this.hubs = const [], + this.selectedHub, + this.roles = const [], + }); + + factory PermanentOrderState.initial() { + final DateTime now = DateTime.now(); + final DateTime start = DateTime(now.year, now.month, now.day); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return PermanentOrderState( + startDate: start, + permanentDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + PermanentOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + vendors: const [], + hubs: const [], + roles: const [], + ); + } + + final DateTime startDate; + final List permanentDays; + final String location; + final String eventName; + final List positions; + final int? autoSelectedDayIndex; + final PermanentOrderStatus status; + final String? errorMessage; + final List vendors; + final Vendor? selectedVendor; + final List hubs; + final PermanentOrderHubOption? selectedHub; + final List roles; + + PermanentOrderState copyWith({ + DateTime? startDate, + List? permanentDays, + String? location, + String? eventName, + List? positions, + int? autoSelectedDayIndex, + PermanentOrderStatus? status, + String? errorMessage, + List? vendors, + Vendor? selectedVendor, + List? hubs, + PermanentOrderHubOption? selectedHub, + List? roles, + }) { + return PermanentOrderState( + startDate: startDate ?? this.startDate, + permanentDays: permanentDays ?? this.permanentDays, + location: location ?? this.location, + eventName: eventName ?? this.eventName, + positions: positions ?? this.positions, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + 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 { + return eventName.isNotEmpty && + selectedVendor != null && + selectedHub != null && + positions.isNotEmpty && + permanentDays.isNotEmpty && + positions.every( + (PermanentOrderPosition p) => + p.role.isNotEmpty && + p.count > 0 && + p.startTime.isNotEmpty && + p.endTime.isNotEmpty, + ); + } + + @override + List get props => [ + startDate, + permanentDays, + location, + eventName, + positions, + autoSelectedDayIndex, + status, + errorMessage, + vendors, + selectedVendor, + hubs, + selectedHub, + roles, + ]; +} + +class PermanentOrderHubOption extends Equatable { + const PermanentOrderHubOption({ + 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 PermanentOrderRoleOption extends Equatable { + const PermanentOrderRoleOption({ + 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 PermanentOrderPosition extends Equatable { + const PermanentOrderPosition({ + 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; + + PermanentOrderPosition copyWith({ + String? role, + int? count, + String? startTime, + String? endTime, + String? lunchBreak, + }) { + return PermanentOrderPosition( + 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/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..b94ed6c1 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart @@ -0,0 +1,356 @@ +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 = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + + 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; + } + final int newDayIndex = event.date.weekday % 7; + final int? autoIndex = state.autoSelectedDayIndex; + List days = List.from(state.recurringDays); + if (autoIndex != null) { + final String oldDay = _dayLabels[autoIndex]; + days.remove(oldDay); + final String newDay = _dayLabels[newDayIndex]; + if (!days.contains(newDay)) { + days.add(newDay); + } + days = _sortDays(days); + } + emit( + state.copyWith( + startDate: event.date, + endDate: endDate, + recurringDays: days, + autoSelectedDayIndex: autoIndex == null ? null : newDayIndex, + ), + ); + } + + 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); + final String label = _dayLabels[event.dayIndex]; + int? autoIndex = state.autoSelectedDayIndex; + if (days.contains(label)) { + days.remove(label); + if (autoIndex == event.dayIndex) { + autoIndex = null; + } + } else { + days.add(label); + } + emit(state.copyWith(recurringDays: _sortDays(days), autoSelectedDayIndex: autoIndex)); + } + + 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 domain.RecurringOrder order = domain.RecurringOrder( + startDate: state.startDate, + endDate: state.endDate, + recurringDays: state.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, + ), + ); + } + + static List _sortDays(List days) { + days.sort( + (String a, String b) => + _dayLabels.indexOf(a).compareTo(_dayLabels.indexOf(b)), + ); + return days; + } +} 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..626beae8 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_state.dart @@ -0,0 +1,229 @@ +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, + required this.autoSelectedDayIndex, + 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); + final List dayLabels = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + final int weekdayIndex = now.weekday % 7; + return RecurringOrderState( + startDate: start, + endDate: start.add(const Duration(days: 7)), + recurringDays: [dayLabels[weekdayIndex]], + location: '', + eventName: '', + positions: const [ + RecurringOrderPosition(role: '', count: 1, startTime: '', endTime: ''), + ], + autoSelectedDayIndex: weekdayIndex, + 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 int? autoSelectedDayIndex; + 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, + int? autoSelectedDayIndex, + 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, + autoSelectedDayIndex: autoSelectedDayIndex ?? this.autoSelectedDayIndex, + 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, + autoSelectedDayIndex, + 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/permanent_order_page.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart index 9986095b..cdcc26e3 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/pages/permanent_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/permanent_order_bloc.dart'; +import '../widgets/permanent_order/permanent_order_view.dart'; -/// Permanent Order Page - Long-term staffing placement. -/// Placeholder for future implementation. +/// Page for creating a permanent staffing order. class PermanentOrderPage extends StatelessWidget { /// Creates a [PermanentOrderPage]. const PermanentOrderPage({super.key}); @override Widget build(BuildContext context) { - final TranslationsClientCreateOrderPermanentEn labels = - t.client_create_order.permanent; - - return Scaffold( - appBar: UiAppBar( - title: labels.title, - onLeadingPressed: () => Modular.to.navigate(ClientPaths.createOrder), - ), - 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 PermanentOrderView(), ); } } 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/permanent_order/permanent_order_date_picker.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_date_picker.dart new file mode 100644 index 00000000..7fe41016 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_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 permanent order form. +class PermanentOrderDatePicker extends StatefulWidget { + /// Creates a [PermanentOrderDatePicker]. + const PermanentOrderDatePicker({ + 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() => + _PermanentOrderDatePickerState(); +} + +class _PermanentOrderDatePickerState 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(PermanentOrderDatePicker 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/permanent_order/permanent_order_event_name_input.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_event_name_input.dart new file mode 100644 index 00000000..4eb0baa4 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_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 permanent order form. +class PermanentOrderEventNameInput extends StatefulWidget { + const PermanentOrderEventNameInput({ + required this.label, + required this.value, + required this.onChanged, + super.key, + }); + + final String label; + final String value; + final ValueChanged onChanged; + + @override + State createState() => + _PermanentOrderEventNameInputState(); +} + +class _PermanentOrderEventNameInputState + extends State { + late final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: widget.value); + } + + @override + void didUpdateWidget(PermanentOrderEventNameInput 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/permanent_order/permanent_order_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart new file mode 100644 index 00000000..8943f5f1 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_header.dart @@ -0,0 +1,71 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A header widget for the permanent order flow with a colored background. +class PermanentOrderHeader extends StatelessWidget { + /// Creates a [PermanentOrderHeader]. + const PermanentOrderHeader({ + 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/permanent_order/permanent_order_position_card.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_position_card.dart new file mode 100644 index 00000000..eea6cb1a --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_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/permanent_order_state.dart'; + +/// A card widget for editing a specific position in a permanent order. +class PermanentOrderPositionCard extends StatelessWidget { + /// Creates a [PermanentOrderPositionCard]. + const PermanentOrderPositionCard({ + 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 PermanentOrderPosition 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( + (PermanentOrderRoleOption role) => DropdownMenuItem( + value: role.id, + child: Text( + '${role.name} - \$${role.costPerHour.toStringAsFixed(0)}', + style: UiTypography.body2r.textPrimary, + ), + ), + ) + .toList(); + + final bool hasSelected = roles.any((PermanentOrderRoleOption 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/permanent_order/permanent_order_section_header.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_section_header.dart new file mode 100644 index 00000000..21d47825 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_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 permanent order form. +class PermanentOrderSectionHeader extends StatelessWidget { + /// Creates a [PermanentOrderSectionHeader]. + const PermanentOrderSectionHeader({ + 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/permanent_order/permanent_order_success_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_success_view.dart new file mode 100644 index 00000000..a4b72cbc --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_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 permanent order has been successfully created. +class PermanentOrderSuccessView extends StatelessWidget { + /// Creates a [PermanentOrderSuccessView]. + const PermanentOrderSuccessView({ + 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/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart new file mode 100644 index 00000000..888bd150 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -0,0 +1,400 @@ +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 'package:krow_domain/krow_domain.dart' show Vendor; +import '../../blocs/permanent_order_bloc.dart'; +import '../../blocs/permanent_order_event.dart'; +import '../../blocs/permanent_order_state.dart'; +import 'permanent_order_date_picker.dart'; +import 'permanent_order_event_name_input.dart'; +import 'permanent_order_header.dart'; +import 'permanent_order_position_card.dart'; +import 'permanent_order_section_header.dart'; +import 'permanent_order_success_view.dart'; + +/// The main content of the Permanent Order page. +class PermanentOrderView extends StatelessWidget { + /// Creates a [PermanentOrderView]. + const PermanentOrderView({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + final TranslationsClientCreateOrderOneTimeEn oneTimeLabels = + t.client_create_order.one_time; + + return BlocConsumer( + listener: (BuildContext context, PermanentOrderState state) { + if (state.status == PermanentOrderStatus.failure && + state.errorMessage != null) { + final String message = translateErrorKey(state.errorMessage!); + UiSnackbar.show( + context, + message: message, + type: UiSnackbarType.error, + margin: const EdgeInsets.only(bottom: 140, left: 16, right: 16), + ); + } + }, + builder: (BuildContext context, PermanentOrderState state) { + if (state.status == PermanentOrderStatus.success) { + return PermanentOrderSuccessView( + 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 != PermanentOrderStatus.loading) { + return Scaffold( + body: Column( + children: [ + PermanentOrderHeader( + 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: [ + PermanentOrderHeader( + title: labels.title, + subtitle: labels.subtitle, + onBack: () => Modular.to.navigate(ClientPaths.createOrder), + ), + Expanded( + child: Stack( + children: [ + _PermanentOrderForm(state: state), + if (state.status == PermanentOrderStatus.loading) + const Center(child: CircularProgressIndicator()), + ], + ), + ), + _BottomActionButton( + label: state.status == PermanentOrderStatus.loading + ? oneTimeLabels.creating + : oneTimeLabels.create_order, + isLoading: state.status == PermanentOrderStatus.loading, + onPressed: state.isValid + ? () => BlocProvider.of( + context, + ).add(const PermanentOrderSubmitted()) + : null, + ), + ], + ), + ); + }, + ); + } +} + +class _PermanentOrderForm extends StatelessWidget { + const _PermanentOrderForm({required this.state}); + final PermanentOrderState state; + + @override + Widget build(BuildContext context) { + final TranslationsClientCreateOrderPermanentEn labels = + t.client_create_order.permanent; + 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), + + PermanentOrderEventNameInput( + label: 'ORDER NAME', + value: state.eventName, + onChanged: (String value) => BlocProvider.of( + context, + ).add(PermanentOrderEventNameChanged(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(PermanentOrderVendorChanged(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), + + PermanentOrderDatePicker( + label: 'Start Date', + value: state.startDate, + onChanged: (DateTime date) => BlocProvider.of( + context, + ).add(PermanentOrderStartDateChanged(date)), + ), + const SizedBox(height: UiConstants.space4), + + Text('Permanent Days', style: UiTypography.footnote2r.textSecondary), + const SizedBox(height: UiConstants.space2), + _PermanentDaysSelector( + selectedDays: state.permanentDays, + onToggle: (int dayIndex) => BlocProvider.of( + context, + ).add(PermanentOrderDayToggled(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: (PermanentOrderHubOption? hub) { + if (hub != null) { + BlocProvider.of( + context, + ).add(PermanentOrderHubChanged(hub)); + } + }, + items: state.hubs.map((PermanentOrderHubOption hub) { + return DropdownMenuItem( + value: hub, + child: Text( + hub.name, + style: UiTypography.body2m.textPrimary, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: UiConstants.space6), + + PermanentOrderSectionHeader( + title: oneTimeLabels.positions_title, + actionLabel: oneTimeLabels.add_position, + onAction: () => BlocProvider.of( + context, + ).add(const PermanentOrderPositionAdded()), + ), + const SizedBox(height: UiConstants.space3), + + // Positions List + ...state.positions.asMap().entries.map(( + MapEntry entry, + ) { + final int index = entry.key; + final PermanentOrderPosition position = entry.value; + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space3), + child: PermanentOrderPositionCard( + 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: (PermanentOrderPosition updated) { + BlocProvider.of( + context, + ).add(PermanentOrderPositionUpdated(index, updated)); + }, + onRemoved: () { + BlocProvider.of( + context, + ).add(PermanentOrderPositionRemoved(index)); + }, + ), + ); + }), + ], + ); + } +} + +class _PermanentDaysSelector extends StatelessWidget { + const _PermanentDaysSelector({ + required this.selectedDays, + required this.onToggle, + }); + + final List selectedDays; + final ValueChanged onToggle; + + @override + Widget build(BuildContext context) { + const List labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[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( + labelsShort[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/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..89a20519 --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -0,0 +1,411 @@ +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 labelsShort = [ + 'S', + 'M', + 'T', + 'W', + 'T', + 'F', + 'S', + ]; + const List labelsLong = [ + 'SUN', + 'MON', + 'TUE', + 'WED', + 'THU', + 'FRI', + 'SAT', + ]; + return Wrap( + spacing: UiConstants.space2, + children: List.generate(labelsShort.length, (int index) { + final bool isSelected = selectedDays.contains(labelsLong[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( + labelsShort[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/application/queries.gql b/backend/dataconnect/connector/application/queries.gql index 4b7db9af..4a6d6396 100644 --- a/backend/dataconnect/connector/application/queries.gql +++ b/backend/dataconnect/connector/application/queries.gql @@ -543,6 +543,36 @@ query listAcceptedApplicationsByShiftRoleKey( } } +query listOverlappingAcceptedApplicationsByStaff( + $staffId: UUID! + $newStart: Timestamp! + $newEnd: Timestamp! + $offset: Int + $limit: Int +) @auth(level: USER) { + applications( + where: { + staffId: { eq: $staffId } + status: { in: [ CONFIRMED, CHECKED_IN, CHECKED_OUT, LATE ] } + shiftRole: { + startTime: { lt: $newEnd } + endTime: { gt: $newStart } + } + } + offset: $offset + limit: $limit + orderBy: { appliedAt: ASC } + ) { + id + shiftId + roleId + checkInTime + checkOutTime + staff { id fullName email phone photoUrl } + shiftRole { startTime endTime } + } +} + #getting staffs of an shiftrole status for orders of the day view client query listAcceptedApplicationsByBusinessForDay( $businessId: UUID! diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 32423968..95eebf54 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,9 +15,9 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any + $recurringDays: [String!] $permanentStartDate: Timestamp - $permanentDays: Any + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String @@ -64,8 +64,8 @@ mutation updateOrder( $shifts: Any $requested: Int $teamHubId: UUID! - $recurringDays: Any - $permanentDays: Any + $recurringDays: [String!] + $permanentDays: [String!] $notes: String $detectedConflicts: Any $poReference: String diff --git a/backend/dataconnect/connector/shiftRole/queries.gql b/backend/dataconnect/connector/shiftRole/queries.gql index ffba13ae..07720bf0 100644 --- a/backend/dataconnect/connector/shiftRole/queries.gql +++ b/backend/dataconnect/connector/shiftRole/queries.gql @@ -354,7 +354,7 @@ query listShiftRolesByBusinessAndDateRange( locationAddress title status - order { id eventName } + order { id eventName orderType } } } } diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 1c815e60..5ab05abb 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -52,10 +52,10 @@ 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") + permanentDays: [String!] detectedConflicts: Any @col(dataType:"jsonb") notes: String diff --git a/internal/launchpad/package-lock.json b/internal/launchpad/package-lock.json new file mode 100644 index 00000000..86416bc9 --- /dev/null +++ b/internal/launchpad/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "launchpad", + "lockfileVersion": 3, + "requires": true, + "packages": {} +}