hub & manager issues

This commit is contained in:
2026-02-25 19:58:28 +05:30
parent 27754524f5
commit eeb8c28a61
53 changed files with 1571 additions and 245 deletions

61
apps/mobile/analyze2.txt Normal file
View File

@@ -0,0 +1,61 @@
┌─────────────────────────────────────────────────────────┐
│ A new version of Flutter is available! │
│ │
│ To update to the latest version, run "flutter upgrade". │
└─────────────────────────────────────────────────────────┘
Resolving dependencies...
Downloading packages...
_fe_analyzer_shared 91.0.0 (96.0.0 available)
analyzer 8.4.1 (10.2.0 available)
archive 3.6.1 (4.0.9 available)
bloc 8.1.4 (9.2.0 available)
bloc_test 9.1.7 (10.0.0 available)
build_runner 2.10.5 (2.11.1 available)
built_value 8.12.3 (8.12.4 available)
characters 1.4.0 (1.4.1 available)
code_assets 0.19.10 (1.0.0 available)
csv 6.0.0 (7.1.0 available)
dart_style 3.1.3 (3.1.5 available)
ffi 2.1.5 (2.2.0 available)
fl_chart 0.66.2 (1.1.1 available)
flutter_bloc 8.1.6 (9.1.1 available)
geolocator 10.1.1 (14.0.2 available)
geolocator_android 4.6.2 (5.0.2 available)
geolocator_web 2.2.1 (4.1.3 available)
get_it 7.7.0 (9.2.1 available)
google_fonts 7.0.2 (8.0.2 available)
google_maps_flutter_android 2.18.12 (2.19.1 available)
google_maps_flutter_ios 2.17.3 (2.17.5 available)
google_maps_flutter_web 0.5.14+3 (0.6.1 available)
googleapis_auth 1.6.0 (2.1.0 available)
grpc 3.2.4 (5.1.0 available)
hooks 0.20.5 (1.0.1 available)
image 4.3.0 (4.8.0 available)
json_annotation 4.9.0 (4.11.0 available)
lints 6.0.0 (6.1.0 available)
matcher 0.12.17 (0.12.18 available)
material_color_utilities 0.11.1 (0.13.0 available)
melos 7.3.0 (7.4.0 available)
meta 1.17.0 (1.18.1 available)
native_toolchain_c 0.17.2 (0.17.4 available)
objective_c 9.2.2 (9.3.0 available)
permission_handler 11.4.0 (12.0.1 available)
permission_handler_android 12.1.0 (13.0.1 available)
petitparser 7.0.1 (7.0.2 available)
protobuf 3.1.0 (6.0.0 available)
shared_preferences_android 2.4.18 (2.4.20 available)
slang 4.12.0 (4.12.1 available)
slang_build_runner 4.12.0 (4.12.1 available)
slang_flutter 4.12.0 (4.12.1 available)
source_span 1.10.1 (1.10.2 available)
test 1.26.3 (1.29.0 available)
test_api 0.7.7 (0.7.9 available)
test_core 0.6.12 (0.6.15 available)
url_launcher_ios 6.3.6 (6.4.1 available)
uuid 4.5.2 (4.5.3 available)
yaml_edit 2.2.3 (2.2.4 available)
Got dependencies!
49 packages have newer versions incompatible with dependency constraints.
Try `flutter pub outdated` for more information.
Analyzing mobile...

View File

@@ -135,6 +135,11 @@ extension ClientNavigator on IModularNavigator {
pushNamed(ClientPaths.settings); pushNamed(ClientPaths.settings);
} }
/// Pushes the edit profile page.
void toClientEditProfile() {
pushNamed('${ClientPaths.settings}/edit-profile');
}
// ========================================================================== // ==========================================================================
// HUBS MANAGEMENT // HUBS MANAGEMENT
// ========================================================================== // ==========================================================================
@@ -159,6 +164,9 @@ extension ClientNavigator on IModularNavigator {
return pushNamed<bool?>( return pushNamed<bool?>(
ClientPaths.editHub, ClientPaths.editHub,
arguments: <String, dynamic>{'hub': hub}, arguments: <String, dynamic>{'hub': hub},
// Some versions of Modular allow passing opaque here, but if not
// we'll handle transparency in the page itself which we already do.
// To ensure it's not opaque, we'll use push with a PageRouteBuilder if needed.
); );
} }

View File

@@ -208,6 +208,7 @@
"edit_profile": "Edit Profile", "edit_profile": "Edit Profile",
"hubs": "Hubs", "hubs": "Hubs",
"log_out": "Log Out", "log_out": "Log Out",
"log_out_confirmation": "Are you sure you want to log out?",
"quick_links": "Quick Links", "quick_links": "Quick Links",
"clock_in_hubs": "Clock-In Hubs", "clock_in_hubs": "Clock-In Hubs",
"billing_payments": "Billing & Payments" "billing_payments": "Billing & Payments"
@@ -254,6 +255,8 @@
"address_hint": "Full address", "address_hint": "Full address",
"cost_center_label": "Cost Center", "cost_center_label": "Cost Center",
"cost_center_hint": "eg: 1001, 1002", "cost_center_hint": "eg: 1001, 1002",
"name_required": "Name is required",
"address_required": "Address is required",
"create_button": "Create Hub" "create_button": "Create Hub"
}, },
"edit_hub": { "edit_hub": {
@@ -332,6 +335,9 @@
"date_hint": "Select date", "date_hint": "Select date",
"location_label": "Location", "location_label": "Location",
"location_hint": "Enter address", "location_hint": "Enter address",
"hub_manager_label": "Shift Contact",
"hub_manager_desc": "On-site manager or supervisor for this shift",
"hub_manager_hint": "Select Contact",
"positions_title": "Positions", "positions_title": "Positions",
"add_position": "Add Position", "add_position": "Add Position",
"position_number": "Position $number", "position_number": "Position $number",

View File

@@ -208,6 +208,7 @@
"edit_profile": "Editar Perfil", "edit_profile": "Editar Perfil",
"hubs": "Hubs", "hubs": "Hubs",
"log_out": "Cerrar sesi\u00f3n", "log_out": "Cerrar sesi\u00f3n",
"log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?",
"quick_links": "Enlaces r\u00e1pidos", "quick_links": "Enlaces r\u00e1pidos",
"clock_in_hubs": "Hubs de Marcaje", "clock_in_hubs": "Hubs de Marcaje",
"billing_payments": "Facturaci\u00f3n y Pagos" "billing_payments": "Facturaci\u00f3n y Pagos"
@@ -254,6 +255,8 @@
"address_hint": "Direcci\u00f3n completa", "address_hint": "Direcci\u00f3n completa",
"cost_center_label": "Centro de Costos", "cost_center_label": "Centro de Costos",
"cost_center_hint": "ej: 1001, 1002", "cost_center_hint": "ej: 1001, 1002",
"name_required": "Nombre es obligatorio",
"address_required": "La direcci\u00f3n es obligatoria",
"create_button": "Crear Hub" "create_button": "Crear Hub"
}, },
"nfc_dialog": { "nfc_dialog": {
@@ -332,6 +335,9 @@
"date_hint": "Seleccionar fecha", "date_hint": "Seleccionar fecha",
"location_label": "Ubicaci\u00f3n", "location_label": "Ubicaci\u00f3n",
"location_hint": "Ingresar direcci\u00f3n", "location_hint": "Ingresar direcci\u00f3n",
"hub_manager_label": "Contacto del Turno",
"hub_manager_desc": "Gerente o supervisor en el sitio para este turno",
"hub_manager_hint": "Seleccionar Contacto",
"positions_title": "Posiciones", "positions_title": "Posiciones",
"add_position": "A\u00f1adir Posici\u00f3n", "add_position": "A\u00f1adir Posici\u00f3n",
"position_number": "Posici\u00f3n $number", "position_number": "Posici\u00f3n $number",

View File

@@ -31,6 +31,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
address: h.address, address: h.address,
nfcTagId: null, nfcTagId: null,
status: h.isActive ? HubStatus.active : HubStatus.inactive, status: h.isActive ? HubStatus.active : HubStatus.inactive,
costCenter: null,
); );
}).toList(); }).toList();
}); });
@@ -79,6 +80,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
address: address, address: address,
nfcTagId: null, nfcTagId: null,
status: HubStatus.active, status: HubStatus.active,
costCenter: null,
); );
}); });
} }
@@ -136,6 +138,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
address: address ?? '', address: address ?? '',
nfcTagId: null, nfcTagId: null,
status: HubStatus.active, status: HubStatus.active,
costCenter: null,
); );
}); });
} }

View File

@@ -19,6 +19,7 @@ export 'src/entities/business/business_setting.dart';
export 'src/entities/business/hub.dart'; export 'src/entities/business/hub.dart';
export 'src/entities/business/hub_department.dart'; export 'src/entities/business/hub_department.dart';
export 'src/entities/business/vendor.dart'; export 'src/entities/business/vendor.dart';
export 'src/entities/business/cost_center.dart';
// Events & Assignments // Events & Assignments
export 'src/entities/events/event.dart'; export 'src/entities/events/event.dart';

View File

@@ -0,0 +1,22 @@
import 'package:equatable/equatable.dart';
/// Represents a financial cost center used for billing and tracking.
class CostCenter extends Equatable {
const CostCenter({
required this.id,
required this.name,
this.code,
});
/// Unique identifier.
final String id;
/// Display name of the cost center.
final String name;
/// Optional alphanumeric code associated with this cost center.
final String? code;
@override
List<Object?> get props => <Object?>[id, name, code];
}

View File

@@ -1,5 +1,7 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'cost_center.dart';
/// The status of a [Hub]. /// The status of a [Hub].
enum HubStatus { enum HubStatus {
/// Fully operational. /// Fully operational.
@@ -42,7 +44,7 @@ class Hub extends Equatable {
final HubStatus status; final HubStatus status;
/// Assigned cost center for this hub. /// Assigned cost center for this hub.
final String? costCenter; final CostCenter? costCenter;
@override @override
List<Object?> get props => <Object?>[id, businessId, name, address, nfcTagId, status, costCenter]; List<Object?> get props => <Object?>[id, businessId, name, address, nfcTagId, status, costCenter];

View File

@@ -13,6 +13,7 @@ class OneTimeOrder extends Equatable {
this.hub, this.hub,
this.eventName, this.eventName,
this.vendorId, this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{}, this.roleRates = const <String, double>{},
}); });
/// The specific date for the shift or event. /// The specific date for the shift or event.
@@ -33,6 +34,9 @@ class OneTimeOrder extends Equatable {
/// Selected vendor id for this order. /// Selected vendor id for this order.
final String? vendorId; final String? vendorId;
/// Optional hub manager id.
final String? hubManagerId;
/// Role hourly rates keyed by role id. /// Role hourly rates keyed by role id.
final Map<String, double> roleRates; final Map<String, double> roleRates;
@@ -44,6 +48,7 @@ class OneTimeOrder extends Equatable {
hub, hub,
eventName, eventName,
vendorId, vendorId,
hubManagerId,
roleRates, roleRates,
]; ];
} }

View File

@@ -27,6 +27,8 @@ class OrderItem extends Equatable {
this.hours = 0, this.hours = 0,
this.totalValue = 0, this.totalValue = 0,
this.confirmedApps = const <Map<String, dynamic>>[], this.confirmedApps = const <Map<String, dynamic>>[],
this.hubManagerId,
this.hubManagerName,
}); });
/// Unique identifier of the order. /// Unique identifier of the order.
@@ -83,6 +85,12 @@ class OrderItem extends Equatable {
/// List of confirmed worker applications. /// List of confirmed worker applications.
final List<Map<String, dynamic>> confirmedApps; final List<Map<String, dynamic>> confirmedApps;
/// Optional ID of the assigned hub manager.
final String? hubManagerId;
/// Optional Name of the assigned hub manager.
final String? hubManagerName;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
id, id,
@@ -103,5 +111,7 @@ class OrderItem extends Equatable {
totalValue, totalValue,
eventName, eventName,
confirmedApps, confirmedApps,
hubManagerId,
hubManagerName,
]; ];
} }

View File

@@ -11,6 +11,7 @@ class PermanentOrder extends Equatable {
this.hub, this.hub,
this.eventName, this.eventName,
this.vendorId, this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{}, this.roleRates = const <String, double>{},
}); });
@@ -23,6 +24,7 @@ class PermanentOrder extends Equatable {
final OneTimeOrderHubDetails? hub; final OneTimeOrderHubDetails? hub;
final String? eventName; final String? eventName;
final String? vendorId; final String? vendorId;
final String? hubManagerId;
final Map<String, double> roleRates; final Map<String, double> roleRates;
@override @override
@@ -33,6 +35,7 @@ class PermanentOrder extends Equatable {
hub, hub,
eventName, eventName,
vendorId, vendorId,
hubManagerId,
roleRates, roleRates,
]; ];
} }

View File

@@ -12,6 +12,7 @@ class RecurringOrder extends Equatable {
this.hub, this.hub,
this.eventName, this.eventName,
this.vendorId, this.vendorId,
this.hubManagerId,
this.roleRates = const <String, double>{}, this.roleRates = const <String, double>{},
}); });
@@ -39,6 +40,9 @@ class RecurringOrder extends Equatable {
/// Selected vendor id for this order. /// Selected vendor id for this order.
final String? vendorId; final String? vendorId;
/// Optional hub manager id.
final String? hubManagerId;
/// Role hourly rates keyed by role id. /// Role hourly rates keyed by role id.
final Map<String, double> roleRates; final Map<String, double> roleRates;
@@ -52,6 +56,7 @@ class RecurringOrder extends Equatable {
hub, hub,
eventName, eventName,
vendorId, vendorId,
hubManagerId,
roleRates, roleRates,
]; ];
} }

View File

@@ -1,5 +1,6 @@
library; library;
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart';
@@ -8,6 +9,7 @@ import 'src/domain/repositories/hub_repository_interface.dart';
import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
import 'src/domain/usecases/create_hub_usecase.dart'; import 'src/domain/usecases/create_hub_usecase.dart';
import 'src/domain/usecases/delete_hub_usecase.dart'; import 'src/domain/usecases/delete_hub_usecase.dart';
import 'src/domain/usecases/get_cost_centers_usecase.dart';
import 'src/domain/usecases/get_hubs_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart';
import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/domain/usecases/update_hub_usecase.dart';
import 'src/presentation/blocs/client_hubs_bloc.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart';
@@ -32,6 +34,7 @@ class ClientHubsModule extends Module {
// UseCases // UseCases
i.addLazySingleton(GetHubsUseCase.new); i.addLazySingleton(GetHubsUseCase.new);
i.addLazySingleton(GetCostCentersUseCase.new);
i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(CreateHubUseCase.new);
i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new);
i.addLazySingleton(AssignNfcTagUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new);
@@ -61,6 +64,18 @@ class ClientHubsModule extends Module {
); );
r.child( r.child(
ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub), ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub),
transition: TransitionType.custom,
customTransition: CustomTransition(
opaque: false,
transitionBuilder: (
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(opacity: animation, child: child);
},
),
child: (_) { child: (_) {
final Map<String, dynamic> data = r.args.data as Map<String, dynamic>; final Map<String, dynamic> data = r.args.data as Map<String, dynamic>;
return EditHubPage( return EditHubPage(

View File

@@ -24,6 +24,17 @@ class HubRepositoryImpl implements HubRepositoryInterface {
return _connectorRepository.getHubs(businessId: businessId); return _connectorRepository.getHubs(businessId: businessId);
} }
@override
Future<List<CostCenter>> getCostCenters() async {
// Mocking cost centers for now since the backend is not yet ready.
return <CostCenter>[
const CostCenter(id: 'cc-001', name: 'Kitchen', code: '1001'),
const CostCenter(id: 'cc-002', name: 'Front Desk', code: '1002'),
const CostCenter(id: 'cc-003', name: 'Waitstaff', code: '1003'),
const CostCenter(id: 'cc-004', name: 'Management', code: '1004'),
];
}
@override @override
Future<Hub> createHub({ Future<Hub> createHub({
required String name, required String name,
@@ -36,7 +47,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street, String? street,
String? country, String? country,
String? zipCode, String? zipCode,
String? costCenter, String? costCenterId,
}) async { }) async {
final String businessId = await _service.getBusinessId(); final String businessId = await _service.getBusinessId();
return _connectorRepository.createHub( return _connectorRepository.createHub(
@@ -80,7 +91,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? street, String? street,
String? country, String? country,
String? zipCode, String? zipCode,
String? costCenter, String? costCenterId,
}) async { }) async {
final String businessId = await _service.getBusinessId(); final String businessId = await _service.getBusinessId();
return _connectorRepository.updateHub( return _connectorRepository.updateHub(

View File

@@ -19,7 +19,7 @@ class CreateHubArguments extends UseCaseArgument {
this.street, this.street,
this.country, this.country,
this.zipCode, this.zipCode,
this.costCenter, this.costCenterId,
}); });
/// The name of the hub. /// The name of the hub.
final String name; final String name;
@@ -37,7 +37,7 @@ class CreateHubArguments extends UseCaseArgument {
final String? zipCode; final String? zipCode;
/// The cost center of the hub. /// The cost center of the hub.
final String? costCenter; final String? costCenterId;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
@@ -51,6 +51,6 @@ class CreateHubArguments extends UseCaseArgument {
street, street,
country, country,
zipCode, zipCode,
costCenter, costCenterId,
]; ];
} }

View File

@@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface {
/// Returns a list of [Hub] entities. /// Returns a list of [Hub] entities.
Future<List<Hub>> getHubs(); Future<List<Hub>> getHubs();
/// Fetches the list of available cost centers for the current business.
Future<List<CostCenter>> getCostCenters();
/// Creates a new hub. /// Creates a new hub.
/// ///
/// Takes the [name] and [address] of the new hub. /// Takes the [name] and [address] of the new hub.
@@ -26,7 +29,7 @@ abstract interface class HubRepositoryInterface {
String? street, String? street,
String? country, String? country,
String? zipCode, String? zipCode,
String? costCenter, String? costCenterId,
}); });
/// Deletes a hub by its [id]. /// Deletes a hub by its [id].
@@ -52,6 +55,6 @@ abstract interface class HubRepositoryInterface {
String? street, String? street,
String? country, String? country,
String? zipCode, String? zipCode,
String? costCenter, String? costCenterId,
}); });
} }

View File

@@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase<CreateHubArguments, Hub> {
street: arguments.street, street: arguments.street,
country: arguments.country, country: arguments.country,
zipCode: arguments.zipCode, zipCode: arguments.zipCode,
costCenterId: arguments.costCenterId,
); );
} }
} }

View File

@@ -0,0 +1,14 @@
import 'package:krow_domain/krow_domain.dart';
import '../repositories/hub_repository_interface.dart';
/// Usecase to fetch all available cost centers.
class GetCostCentersUseCase {
GetCostCentersUseCase({required HubRepositoryInterface repository})
: _repository = repository;
final HubRepositoryInterface _repository;
Future<List<CostCenter>> call() async {
return _repository.getCostCenters();
}
}

View File

@@ -17,6 +17,7 @@ class UpdateHubArguments extends UseCaseArgument {
this.street, this.street,
this.country, this.country,
this.zipCode, this.zipCode,
this.costCenterId,
}); });
final String id; final String id;
@@ -30,6 +31,7 @@ class UpdateHubArguments extends UseCaseArgument {
final String? street; final String? street;
final String? country; final String? country;
final String? zipCode; final String? zipCode;
final String? costCenterId;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
@@ -44,6 +46,7 @@ class UpdateHubArguments extends UseCaseArgument {
street, street,
country, country,
zipCode, zipCode,
costCenterId,
]; ];
} }
@@ -67,6 +70,7 @@ class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
street: params.street, street: params.street,
country: params.country, country: params.country,
zipCode: params.zipCode, zipCode: params.zipCode,
costCenterId: params.costCenterId,
); );
} }
} }

View File

@@ -1,8 +1,10 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/arguments/create_hub_arguments.dart'; import '../../../domain/arguments/create_hub_arguments.dart';
import '../../../domain/usecases/create_hub_usecase.dart'; import '../../../domain/usecases/create_hub_usecase.dart';
import '../../../domain/usecases/update_hub_usecase.dart'; import '../../../domain/usecases/update_hub_usecase.dart';
import '../../../domain/usecases/get_cost_centers_usecase.dart';
import 'edit_hub_event.dart'; import 'edit_hub_event.dart';
import 'edit_hub_state.dart'; import 'edit_hub_state.dart';
@@ -12,15 +14,36 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
EditHubBloc({ EditHubBloc({
required CreateHubUseCase createHubUseCase, required CreateHubUseCase createHubUseCase,
required UpdateHubUseCase updateHubUseCase, required UpdateHubUseCase updateHubUseCase,
required GetCostCentersUseCase getCostCentersUseCase,
}) : _createHubUseCase = createHubUseCase, }) : _createHubUseCase = createHubUseCase,
_updateHubUseCase = updateHubUseCase, _updateHubUseCase = updateHubUseCase,
_getCostCentersUseCase = getCostCentersUseCase,
super(const EditHubState()) { super(const EditHubState()) {
on<EditHubCostCentersLoadRequested>(_onCostCentersLoadRequested);
on<EditHubAddRequested>(_onAddRequested); on<EditHubAddRequested>(_onAddRequested);
on<EditHubUpdateRequested>(_onUpdateRequested); on<EditHubUpdateRequested>(_onUpdateRequested);
} }
final CreateHubUseCase _createHubUseCase; final CreateHubUseCase _createHubUseCase;
final UpdateHubUseCase _updateHubUseCase; final UpdateHubUseCase _updateHubUseCase;
final GetCostCentersUseCase _getCostCentersUseCase;
Future<void> _onCostCentersLoadRequested(
EditHubCostCentersLoadRequested event,
Emitter<EditHubState> emit,
) async {
await handleError(
emit: emit.call,
action: () async {
final List<CostCenter> costCenters = await _getCostCentersUseCase.call();
emit(state.copyWith(costCenters: costCenters));
},
onError: (String errorKey) => state.copyWith(
status: EditHubStatus.failure,
errorMessage: errorKey,
),
);
}
Future<void> _onAddRequested( Future<void> _onAddRequested(
EditHubAddRequested event, EditHubAddRequested event,
@@ -43,6 +66,7 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street, street: event.street,
country: event.country, country: event.country,
zipCode: event.zipCode, zipCode: event.zipCode,
costCenterId: event.costCenterId,
), ),
); );
emit( emit(
@@ -79,6 +103,7 @@ class EditHubBloc extends Bloc<EditHubEvent, EditHubState>
street: event.street, street: event.street,
country: event.country, country: event.country,
zipCode: event.zipCode, zipCode: event.zipCode,
costCenterId: event.costCenterId,
), ),
); );
emit( emit(

View File

@@ -8,6 +8,11 @@ abstract class EditHubEvent extends Equatable {
List<Object?> get props => <Object?>[]; List<Object?> get props => <Object?>[];
} }
/// Event triggered to load all available cost centers.
class EditHubCostCentersLoadRequested extends EditHubEvent {
const EditHubCostCentersLoadRequested();
}
/// Event triggered to add a new hub. /// Event triggered to add a new hub.
class EditHubAddRequested extends EditHubEvent { class EditHubAddRequested extends EditHubEvent {
const EditHubAddRequested({ const EditHubAddRequested({
@@ -21,6 +26,7 @@ class EditHubAddRequested extends EditHubEvent {
this.street, this.street,
this.country, this.country,
this.zipCode, this.zipCode,
this.costCenterId,
}); });
final String name; final String name;
@@ -33,6 +39,7 @@ class EditHubAddRequested extends EditHubEvent {
final String? street; final String? street;
final String? country; final String? country;
final String? zipCode; final String? zipCode;
final String? costCenterId;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
@@ -46,6 +53,7 @@ class EditHubAddRequested extends EditHubEvent {
street, street,
country, country,
zipCode, zipCode,
costCenterId,
]; ];
} }
@@ -63,6 +71,7 @@ class EditHubUpdateRequested extends EditHubEvent {
this.street, this.street,
this.country, this.country,
this.zipCode, this.zipCode,
this.costCenterId,
}); });
final String id; final String id;
@@ -76,6 +85,7 @@ class EditHubUpdateRequested extends EditHubEvent {
final String? street; final String? street;
final String? country; final String? country;
final String? zipCode; final String? zipCode;
final String? costCenterId;
@override @override
List<Object?> get props => <Object?>[ List<Object?> get props => <Object?>[
@@ -90,5 +100,6 @@ class EditHubUpdateRequested extends EditHubEvent {
street, street,
country, country,
zipCode, zipCode,
costCenterId,
]; ];
} }

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Status of the edit hub operation. /// Status of the edit hub operation.
enum EditHubStatus { enum EditHubStatus {
@@ -21,6 +22,7 @@ class EditHubState extends Equatable {
this.status = EditHubStatus.initial, this.status = EditHubStatus.initial,
this.errorMessage, this.errorMessage,
this.successMessage, this.successMessage,
this.costCenters = const <CostCenter>[],
}); });
/// The status of the operation. /// The status of the operation.
@@ -32,19 +34,29 @@ class EditHubState extends Equatable {
/// The success message if the operation succeeded. /// The success message if the operation succeeded.
final String? successMessage; final String? successMessage;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Create a copy of this state with the given fields replaced. /// Create a copy of this state with the given fields replaced.
EditHubState copyWith({ EditHubState copyWith({
EditHubStatus? status, EditHubStatus? status,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
List<CostCenter>? costCenters,
}) { }) {
return EditHubState( return EditHubState(
status: status ?? this.status, status: status ?? this.status,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
successMessage: successMessage ?? this.successMessage, successMessage: successMessage ?? this.successMessage,
costCenters: costCenters ?? this.costCenters,
); );
} }
@override @override
List<Object?> get props => <Object?>[status, errorMessage, successMessage]; List<Object?> get props => <Object?>[
status,
errorMessage,
successMessage,
costCenters,
];
} }

View File

@@ -57,20 +57,6 @@ class ClientHubsPage extends StatelessWidget {
builder: (BuildContext context, ClientHubsState state) { builder: (BuildContext context, ClientHubsState state) {
return Scaffold( return Scaffold(
backgroundColor: UiColors.bgMenu, backgroundColor: UiColors.bgMenu,
floatingActionButton: FloatingActionButton(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(8)),
),
child: const Icon(UiIcons.add),
),
body: CustomScrollView( body: CustomScrollView(
slivers: <Widget>[ slivers: <Widget>[
_buildAppBar(context), _buildAppBar(context),
@@ -165,7 +151,8 @@ class ClientHubsPage extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Column( Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
@@ -180,6 +167,20 @@ class ClientHubsPage extends StatelessWidget {
), ),
], ],
), ),
),
UiButton.primary(
onPressed: () async {
final bool? success = await Modular.to.toEditHub();
if (success == true && context.mounted) {
BlocProvider.of<ClientHubsBloc>(
context,
).add(const ClientHubsFetched());
}
},
text: t.client_hubs.add_hub,
leadingIcon: UiIcons.add,
size: UiButtonSize.small,
),
], ],
), ),
], ],

View File

@@ -1,17 +1,15 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/edit_hub/edit_hub_bloc.dart'; import '../blocs/edit_hub/edit_hub_bloc.dart';
import '../blocs/edit_hub/edit_hub_event.dart'; import '../blocs/edit_hub/edit_hub_event.dart';
import '../blocs/edit_hub/edit_hub_state.dart'; import '../blocs/edit_hub/edit_hub_state.dart';
import '../widgets/edit_hub/edit_hub_form_section.dart'; import '../widgets/hub_form_dialog.dart';
/// A dedicated full-screen page for adding or editing a hub. /// A wrapper page that shows the hub form in a modal-style layout.
class EditHubPage extends StatefulWidget { class EditHubPage extends StatefulWidget {
const EditHubPage({this.hub, required this.bloc, super.key}); const EditHubPage({this.hub, required this.bloc, super.key});
@@ -23,66 +21,11 @@ class EditHubPage extends StatefulWidget {
} }
class _EditHubPageState extends State<EditHubPage> { class _EditHubPageState extends State<EditHubPage> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
late final TextEditingController _nameController;
late final TextEditingController _addressController;
late final FocusNode _addressFocusNode;
Prediction? _selectedPrediction;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_nameController = TextEditingController(text: widget.hub?.name); // Load available cost centers
_addressController = TextEditingController(text: widget.hub?.address); widget.bloc.add(const EditHubCostCentersLoadRequested());
_addressFocusNode = FocusNode();
// Update header on change (if header is added back)
_nameController.addListener(() => setState(() {}));
_addressController.addListener(() => setState(() {}));
}
@override
void dispose() {
_nameController.dispose();
_addressController.dispose();
_addressFocusNode.dispose();
super.dispose();
}
void _onSave() {
if (!_formKey.currentState!.validate()) return;
if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_hint,
type: UiSnackbarType.error,
);
return;
}
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: _nameController.text.trim(),
address: _addressController.text.trim(),
placeId: _selectedPrediction?.placeId,
latitude: double.tryParse(_selectedPrediction?.lat ?? ''),
longitude: double.tryParse(_selectedPrediction?.lng ?? ''),
),
);
}
} }
@override @override
@@ -101,7 +44,6 @@ class _EditHubPageState extends State<EditHubPage> {
message: state.successMessage!, message: state.successMessage!,
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
// Pop back to the previous screen.
Modular.to.pop(true); Modular.to.pop(true);
} }
if (state.status == EditHubStatus.failure && if (state.status == EditHubStatus.failure &&
@@ -118,42 +60,59 @@ class _EditHubPageState extends State<EditHubPage> {
final bool isSaving = state.status == EditHubStatus.loading; final bool isSaving = state.status == EditHubStatus.loading;
return Scaffold( return Scaffold(
backgroundColor: UiColors.bgMenu, backgroundColor: UiColors.bgOverlay,
appBar: UiAppBar(
title: widget.hub == null
? t.client_hubs.add_hub_dialog.title
: t.client_hubs.edit_hub.title,
subtitle: widget.hub == null
? t.client_hubs.add_hub_dialog.create_button
: t.client_hubs.edit_hub.subtitle,
onLeadingPressed: () => Modular.to.pop(),
),
body: Stack( body: Stack(
children: <Widget>[ children: <Widget>[
SingleChildScrollView( // Tap background to dismiss
child: Column( GestureDetector(
crossAxisAlignment: CrossAxisAlignment.stretch, onTap: () => Modular.to.pop(),
children: <Widget>[ child: Container(color: Colors.transparent),
Padding( ),
padding: const EdgeInsets.all(UiConstants.space5),
child: EditHubFormSection( // Dialog-style content centered
formKey: _formKey, Align(
nameController: _nameController, alignment: Alignment.center,
addressController: _addressController, child: HubFormDialog(
addressFocusNode: _addressFocusNode, hub: widget.hub,
onAddressSelected: (Prediction prediction) { costCenters: state.costCenters,
_selectedPrediction = prediction; onCancel: () => Modular.to.pop(),
onSave: ({
required String name,
required String address,
String? costCenterId,
String? placeId,
double? latitude,
double? longitude,
}) {
if (widget.hub == null) {
widget.bloc.add(
EditHubAddRequested(
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
} else {
widget.bloc.add(
EditHubUpdateRequested(
id: widget.hub!.id,
name: name,
address: address,
costCenterId: costCenterId,
placeId: placeId,
latitude: latitude,
longitude: longitude,
),
);
}
}, },
onSave: _onSave,
isSaving: isSaving,
isEdit: widget.hub != null,
),
),
],
), ),
), ),
// ── Loading overlay ────────────────────────────────────── // Global loading overlay if saving
if (isSaving) if (isSaving)
Container( Container(
color: UiColors.black.withValues(alpha: 0.1), color: UiColors.black.withValues(alpha: 0.1),

View File

@@ -80,6 +80,15 @@ class HubDetailsPage extends StatelessWidget {
icon: UiIcons.nfc, icon: UiIcons.nfc,
isHighlight: hub.nfcTagId != null, isHighlight: hub.nfcTagId != null,
), ),
const SizedBox(height: UiConstants.space4),
HubDetailsItem(
label: t.client_hubs.hub_details.cost_center_label,
value: hub.costCenter != null
? '${hub.costCenter!.name} (${hub.costCenter!.code})'
: t.client_hubs.hub_details.cost_center_none,
icon: UiIcons.bank, // Using bank icon for cost center
isHighlight: hub.costCenter != null,
),
], ],
), ),
), ),

View File

@@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:google_places_flutter/model/prediction.dart'; import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart';
import '../hub_address_autocomplete.dart'; import '../hub_address_autocomplete.dart';
import 'edit_hub_field_label.dart'; import 'edit_hub_field_label.dart';
@@ -15,6 +16,9 @@ class EditHubFormSection extends StatelessWidget {
required this.addressFocusNode, required this.addressFocusNode,
required this.onAddressSelected, required this.onAddressSelected,
required this.onSave, required this.onSave,
this.costCenters = const <CostCenter>[],
this.selectedCostCenterId,
required this.onCostCenterChanged,
this.isSaving = false, this.isSaving = false,
this.isEdit = false, this.isEdit = false,
super.key, super.key,
@@ -26,6 +30,9 @@ class EditHubFormSection extends StatelessWidget {
final FocusNode addressFocusNode; final FocusNode addressFocusNode;
final ValueChanged<Prediction> onAddressSelected; final ValueChanged<Prediction> onAddressSelected;
final VoidCallback onSave; final VoidCallback onSave;
final List<CostCenter> costCenters;
final String? selectedCostCenterId;
final ValueChanged<String?> onCostCenterChanged;
final bool isSaving; final bool isSaving;
final bool isEdit; final bool isEdit;
@@ -62,6 +69,51 @@ class EditHubFormSection extends StatelessWidget {
onSelected: onAddressSelected, onSelected: onAddressSelected,
), ),
const SizedBox(height: UiConstants.space4),
EditHubFieldLabel(t.client_hubs.edit_hub.cost_center_label),
InkWell(
onTap: () => _showCostCenterSelector(context),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.input,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: selectedCostCenterId != null
? UiColors.ring
: UiColors.border,
width: selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
selectedCostCenterId != null
? _getCostCenterName(selectedCostCenterId!)
: t.client_hubs.edit_hub.cost_center_hint,
style: selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space8), const SizedBox(height: UiConstants.space8),
// ── Save button ────────────────────────────────── // ── Save button ──────────────────────────────────
@@ -102,4 +154,59 @@ class EditHubFormSection extends StatelessWidget {
), ),
); );
} }
String _getCostCenterName(String id) {
try {
final CostCenter cc = costCenters.firstWhere((CostCenter item) => item.id == id);
return cc.code != null ? '${cc.name} (${cc.code})' : cc.name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector(BuildContext context) async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.edit_hub.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: costCenters.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text('No cost centers available'),
)
: ListView.builder(
shrinkWrap: true,
itemCount: costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
onCostCenterChanged(selected.id);
}
}
} }

View File

@@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget {
required this.controller, required this.controller,
required this.hintText, required this.hintText,
this.focusNode, this.focusNode,
this.decoration,
this.onSelected, this.onSelected,
super.key, super.key,
}); });
@@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget {
final TextEditingController controller; final TextEditingController controller;
final String hintText; final String hintText;
final FocusNode? focusNode; final FocusNode? focusNode;
final InputDecoration? decoration;
final void Function(Prediction prediction)? onSelected; final void Function(Prediction prediction)? onSelected;
@override @override
@@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget {
return GooglePlaceAutoCompleteTextField( return GooglePlaceAutoCompleteTextField(
textEditingController: controller, textEditingController: controller,
focusNode: focusNode, focusNode: focusNode,
inputDecoration: decoration ?? const InputDecoration(),
googleAPIKey: AppConfig.googleMapsApiKey, googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500, debounceTime: 500,
countries: HubsConstants.supportedCountries, countries: HubsConstants.supportedCountries,

View File

@@ -5,25 +5,30 @@ import 'package:google_places_flutter/model/prediction.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'hub_address_autocomplete.dart'; import 'hub_address_autocomplete.dart';
import 'edit_hub/edit_hub_field_label.dart';
/// A dialog for adding or editing a hub. /// A bottom sheet dialog for adding or editing a hub.
class HubFormDialog extends StatefulWidget { class HubFormDialog extends StatefulWidget {
/// Creates a [HubFormDialog]. /// Creates a [HubFormDialog].
const HubFormDialog({ const HubFormDialog({
required this.onSave, required this.onSave,
required this.onCancel, required this.onCancel,
this.hub, this.hub,
this.costCenters = const <CostCenter>[],
super.key, super.key,
}); });
/// The hub to edit. If null, a new hub is created. /// The hub to edit. If null, a new hub is created.
final Hub? hub; final Hub? hub;
/// Available cost centers for selection.
final List<CostCenter> costCenters;
/// Callback when the "Save" button is pressed. /// Callback when the "Save" button is pressed.
final void Function( final void Function({
String name, required String name,
String address, { required String address,
String? costCenterId,
String? placeId, String? placeId,
double? latitude, double? latitude,
double? longitude, double? longitude,
@@ -40,6 +45,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
late final TextEditingController _nameController; late final TextEditingController _nameController;
late final TextEditingController _addressController; late final TextEditingController _addressController;
late final FocusNode _addressFocusNode; late final FocusNode _addressFocusNode;
String? _selectedCostCenterId;
Prediction? _selectedPrediction; Prediction? _selectedPrediction;
@override @override
@@ -48,6 +54,7 @@ class _HubFormDialogState extends State<HubFormDialog> {
_nameController = TextEditingController(text: widget.hub?.name); _nameController = TextEditingController(text: widget.hub?.name);
_addressController = TextEditingController(text: widget.hub?.address); _addressController = TextEditingController(text: widget.hub?.address);
_addressFocusNode = FocusNode(); _addressFocusNode = FocusNode();
_selectedCostCenterId = widget.hub?.costCenter?.id;
} }
@override @override
@@ -64,27 +71,29 @@ class _HubFormDialogState extends State<HubFormDialog> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bool isEditing = widget.hub != null; final bool isEditing = widget.hub != null;
final String title = isEditing final String title = isEditing
? 'Edit Hub' // TODO: localize ? t.client_hubs.edit_hub.title
: t.client_hubs.add_hub_dialog.title; : t.client_hubs.add_hub_dialog.title;
final String buttonText = isEditing final String buttonText = isEditing
? 'Save Changes' // TODO: localize ? t.client_hubs.edit_hub.save_button
: t.client_hubs.add_hub_dialog.create_button; : t.client_hubs.add_hub_dialog.create_button;
return Container( return Center(
color: UiColors.bgOverlay,
child: Center(
child: SingleChildScrollView(
child: Container( child: Container(
width: MediaQuery.of(context).size.width * 0.9, margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
padding: const EdgeInsets.all(UiConstants.space5),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.bgPopup, color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3),
boxShadow: const <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow(color: UiColors.popupShadow, blurRadius: 20), BoxShadow(
color: UiColors.black.withValues(alpha: 0.15),
blurRadius: 30,
offset: const Offset(0, 10),
),
], ],
), ),
padding: const EdgeInsets.all(UiConstants.space6),
child: SingleChildScrollView(
child: Form( child: Form(
key: _formKey, key: _formKey,
child: Column( child: Column(
@@ -93,16 +102,22 @@ class _HubFormDialogState extends State<HubFormDialog> {
children: <Widget>[ children: <Widget>[
Text( Text(
title, title,
style: UiTypography.headline3m.textPrimary, style: UiTypography.headline3m.textPrimary.copyWith(
fontSize: 20,
),
), ),
const SizedBox(height: UiConstants.space5), const SizedBox(height: UiConstants.space5),
_buildFieldLabel(t.client_hubs.add_hub_dialog.name_label),
// ── Hub Name ────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label),
const SizedBox(height: UiConstants.space2),
TextFormField( TextFormField(
controller: _nameController, controller: _nameController,
style: UiTypography.body1r.textPrimary, style: UiTypography.body1r.textPrimary,
textInputAction: TextInputAction.next,
validator: (String? value) { validator: (String? value) {
if (value == null || value.trim().isEmpty) { if (value == null || value.trim().isEmpty) {
return 'Name is required'; return t.client_hubs.add_hub_dialog.name_required;
} }
return null; return null;
}, },
@@ -110,21 +125,90 @@ class _HubFormDialogState extends State<HubFormDialog> {
t.client_hubs.add_hub_dialog.name_hint, t.client_hubs.add_hub_dialog.name_hint,
), ),
), ),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
_buildFieldLabel(t.client_hubs.add_hub_dialog.address_label),
// ── Cost Center ─────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: _showCostCenterSelector,
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 16,
),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFD),
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
border: Border.all(
color: _selectedCostCenterId != null
? UiColors.primary
: UiColors.primary.withValues(alpha: 0.1),
width: _selectedCostCenterId != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Expanded(
child: Text(
_selectedCostCenterId != null
? _getCostCenterName(_selectedCostCenterId!)
: t.client_hubs.add_hub_dialog.cost_center_hint,
style: _selectedCostCenterId != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
const SizedBox(height: UiConstants.space4),
// ── Address ─────────────────────────────────
EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label),
const SizedBox(height: UiConstants.space2),
HubAddressAutocomplete( HubAddressAutocomplete(
controller: _addressController, controller: _addressController,
hintText: t.client_hubs.add_hub_dialog.address_hint, hintText: t.client_hubs.add_hub_dialog.address_hint,
decoration: _buildInputDecoration(
t.client_hubs.add_hub_dialog.address_hint,
),
focusNode: _addressFocusNode, focusNode: _addressFocusNode,
onSelected: (Prediction prediction) { onSelected: (Prediction prediction) {
_selectedPrediction = prediction; _selectedPrediction = prediction;
}, },
), ),
const SizedBox(height: UiConstants.space8), const SizedBox(height: UiConstants.space8),
// ── Buttons ─────────────────────────────────
Row( Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: UiButton.secondary( child: UiButton.secondary(
style: OutlinedButton.styleFrom(
side: BorderSide(
color: UiColors.primary.withValues(alpha: 0.1),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: widget.onCancel, onPressed: widget.onCancel,
text: t.common.cancel, text: t.common.cancel,
), ),
@@ -132,16 +216,31 @@ class _HubFormDialogState extends State<HubFormDialog> {
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: UiButton.primary( child: UiButton.primary(
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase * 1.5,
),
),
),
onPressed: () { onPressed: () {
if (_formKey.currentState!.validate()) { if (_formKey.currentState!.validate()) {
if (_addressController.text.trim().isEmpty) { if (_addressController.text.trim().isEmpty) {
UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); UiSnackbar.show(
context,
message: t.client_hubs.add_hub_dialog.address_required,
type: UiSnackbarType.error,
);
return; return;
} }
widget.onSave( widget.onSave(
_nameController.text, name: _nameController.text.trim(),
_addressController.text, address: _addressController.text.trim(),
costCenterId: _selectedCostCenterId,
placeId: _selectedPrediction?.placeId, placeId: _selectedPrediction?.placeId,
latitude: double.tryParse( latitude: double.tryParse(
_selectedPrediction?.lat ?? '', _selectedPrediction?.lat ?? '',
@@ -162,39 +261,90 @@ class _HubFormDialogState extends State<HubFormDialog> {
), ),
), ),
), ),
),
);
}
Widget _buildFieldLabel(String label) {
return Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(label, style: UiTypography.body2m.textPrimary),
); );
} }
InputDecoration _buildInputDecoration(String hint) { InputDecoration _buildInputDecoration(String hint) {
return InputDecoration( return InputDecoration(
hintText: hint, hintText: hint,
hintStyle: UiTypography.body2r.textPlaceholder, hintStyle: UiTypography.body2r.textPlaceholder.copyWith(
color: UiColors.textSecondary.withValues(alpha: 0.5),
),
filled: true, filled: true,
fillColor: UiColors.input, fillColor: const Color(0xFFF8FAFD),
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
vertical: 14, vertical: 16,
), ),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: const BorderSide(color: UiColors.border), borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: const BorderSide(color: UiColors.border), borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase), borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5),
borderSide: const BorderSide(color: UiColors.ring, width: 2), borderSide: const BorderSide(color: UiColors.primary, width: 2),
), ),
errorStyle: UiTypography.footnote2r.textError,
); );
} }
String _getCostCenterName(String id) {
try {
return widget.costCenters.firstWhere((CostCenter cc) => cc.id == id).name;
} catch (_) {
return id;
}
}
Future<void> _showCostCenterSelector() async {
final CostCenter? selected = await showDialog<CostCenter>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
t.client_hubs.add_hub_dialog.cost_center_label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: widget.costCenters.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text('No cost centers available'),
)
: ListView.builder(
shrinkWrap: true,
itemCount: widget.costCenters.length,
itemBuilder: (BuildContext context, int index) {
final CostCenter cc = widget.costCenters[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text(cc.name, style: UiTypography.body1m.textPrimary),
subtitle: cc.code != null ? Text(cc.code!, style: UiTypography.body2r.textSecondary) : null,
onTap: () => Navigator.of(context).pop(cc),
);
},
),
),
),
);
},
);
if (selected != null) {
setState(() {
_selectedCostCenterId = selected.id;
});
}
}
} }

View File

@@ -31,6 +31,8 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
on<OneTimeOrderPositionUpdated>(_onPositionUpdated); on<OneTimeOrderPositionUpdated>(_onPositionUpdated);
on<OneTimeOrderSubmitted>(_onSubmitted); on<OneTimeOrderSubmitted>(_onSubmitted);
on<OneTimeOrderInitialized>(_onInitialized); on<OneTimeOrderInitialized>(_onInitialized);
on<OneTimeOrderHubManagerChanged>(_onHubManagerChanged);
on<OneTimeOrderManagersLoaded>(_onManagersLoaded);
_loadVendors(); _loadVendors();
_loadHubs(); _loadHubs();
@@ -134,6 +136,43 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
} }
} }
Future<void> _loadManagersForHub(
String hubId,
) async {
final List<OneTimeOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
OneTimeOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) {
add(const OneTimeOrderManagersLoaded(<OneTimeOrderManagerOption>[]));
},
);
if (managers != null) {
add(OneTimeOrderManagersLoaded(managers));
}
}
Future<void> _onVendorsLoaded( Future<void> _onVendorsLoaded(
OneTimeOrderVendorsLoaded event, OneTimeOrderVendorsLoaded event,
Emitter<OneTimeOrderState> emit, Emitter<OneTimeOrderState> emit,
@@ -171,15 +210,36 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
location: selectedHub?.name ?? '', location: selectedHub?.name ?? '',
), ),
); );
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id);
} }
}
void _onHubChanged( void _onHubChanged(
OneTimeOrderHubChanged event, OneTimeOrderHubChanged event,
Emitter<OneTimeOrderState> emit, Emitter<OneTimeOrderState> emit,
) { ) {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
_loadManagersForHub(event.hub.id);
} }
void _onHubManagerChanged(
OneTimeOrderHubManagerChanged event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
OneTimeOrderManagersLoaded event,
Emitter<OneTimeOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
void _onEventNameChanged( void _onEventNameChanged(
OneTimeOrderEventNameChanged event, OneTimeOrderEventNameChanged event,
Emitter<OneTimeOrderState> emit, Emitter<OneTimeOrderState> emit,
@@ -267,6 +327,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
), ),
eventName: state.eventName, eventName: state.eventName,
vendorId: state.selectedVendor?.id, vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates, roleRates: roleRates,
); );
await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order));

View File

@@ -89,3 +89,21 @@ class OneTimeOrderInitialized extends OneTimeOrderEvent {
@override @override
List<Object?> get props => <Object?>[data]; List<Object?> get props => <Object?>[data];
} }
class OneTimeOrderHubManagerChanged extends OneTimeOrderEvent {
const OneTimeOrderHubManagerChanged(this.manager);
final OneTimeOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class OneTimeOrderManagersLoaded extends OneTimeOrderEvent {
const OneTimeOrderManagersLoaded(this.managers);
final List<OneTimeOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -16,6 +16,8 @@ class OneTimeOrderState extends Equatable {
this.hubs = const <OneTimeOrderHubOption>[], this.hubs = const <OneTimeOrderHubOption>[],
this.selectedHub, this.selectedHub,
this.roles = const <OneTimeOrderRoleOption>[], this.roles = const <OneTimeOrderRoleOption>[],
this.managers = const <OneTimeOrderManagerOption>[],
this.selectedManager,
}); });
factory OneTimeOrderState.initial() { factory OneTimeOrderState.initial() {
@@ -29,6 +31,7 @@ class OneTimeOrderState extends Equatable {
vendors: const <Vendor>[], vendors: const <Vendor>[],
hubs: const <OneTimeOrderHubOption>[], hubs: const <OneTimeOrderHubOption>[],
roles: const <OneTimeOrderRoleOption>[], roles: const <OneTimeOrderRoleOption>[],
managers: const <OneTimeOrderManagerOption>[],
); );
} }
final DateTime date; final DateTime date;
@@ -42,6 +45,8 @@ class OneTimeOrderState extends Equatable {
final List<OneTimeOrderHubOption> hubs; final List<OneTimeOrderHubOption> hubs;
final OneTimeOrderHubOption? selectedHub; final OneTimeOrderHubOption? selectedHub;
final List<OneTimeOrderRoleOption> roles; final List<OneTimeOrderRoleOption> roles;
final List<OneTimeOrderManagerOption> managers;
final OneTimeOrderManagerOption? selectedManager;
OneTimeOrderState copyWith({ OneTimeOrderState copyWith({
DateTime? date, DateTime? date,
@@ -55,6 +60,8 @@ class OneTimeOrderState extends Equatable {
List<OneTimeOrderHubOption>? hubs, List<OneTimeOrderHubOption>? hubs,
OneTimeOrderHubOption? selectedHub, OneTimeOrderHubOption? selectedHub,
List<OneTimeOrderRoleOption>? roles, List<OneTimeOrderRoleOption>? roles,
List<OneTimeOrderManagerOption>? managers,
OneTimeOrderManagerOption? selectedManager,
}) { }) {
return OneTimeOrderState( return OneTimeOrderState(
date: date ?? this.date, date: date ?? this.date,
@@ -68,6 +75,8 @@ class OneTimeOrderState extends Equatable {
hubs: hubs ?? this.hubs, hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub, selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles, roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
); );
} }
@@ -98,6 +107,8 @@ class OneTimeOrderState extends Equatable {
hubs, hubs,
selectedHub, selectedHub,
roles, roles,
managers,
selectedManager,
]; ];
} }
@@ -158,3 +169,17 @@ class OneTimeOrderRoleOption extends Equatable {
@override @override
List<Object?> get props => <Object?>[id, name, costPerHour]; List<Object?> get props => <Object?>[id, name, costPerHour];
} }
class OneTimeOrderManagerOption extends Equatable {
const OneTimeOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}

View File

@@ -31,6 +31,8 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
on<PermanentOrderPositionUpdated>(_onPositionUpdated); on<PermanentOrderPositionUpdated>(_onPositionUpdated);
on<PermanentOrderSubmitted>(_onSubmitted); on<PermanentOrderSubmitted>(_onSubmitted);
on<PermanentOrderInitialized>(_onInitialized); on<PermanentOrderInitialized>(_onInitialized);
on<PermanentOrderHubManagerChanged>(_onHubManagerChanged);
on<PermanentOrderManagersLoaded>(_onManagersLoaded);
_loadVendors(); _loadVendors();
_loadHubs(); _loadHubs();
@@ -182,6 +184,10 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
location: selectedHub?.name ?? '', location: selectedHub?.name ?? '',
), ),
); );
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id, emit);
}
} }
void _onHubChanged( void _onHubChanged(
@@ -189,8 +195,61 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
) { ) {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
_loadManagersForHub(event.hub.id, emit);
} }
void _onHubManagerChanged(
PermanentOrderHubManagerChanged event,
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
PermanentOrderManagersLoaded event,
Emitter<PermanentOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
Future<void> _loadManagersForHub(
String hubId,
Emitter<PermanentOrderState> emit,
) async {
final List<PermanentOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
PermanentOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) => emit(
state.copyWith(managers: const <PermanentOrderManagerOption>[]),
),
);
if (managers != null) {
emit(state.copyWith(managers: managers, selectedManager: null));
}
}
void _onEventNameChanged( void _onEventNameChanged(
PermanentOrderEventNameChanged event, PermanentOrderEventNameChanged event,
Emitter<PermanentOrderState> emit, Emitter<PermanentOrderState> emit,
@@ -330,6 +389,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
), ),
eventName: state.eventName, eventName: state.eventName,
vendorId: state.selectedVendor?.id, vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates, roleRates: roleRates,
); );
await _createPermanentOrderUseCase(order); await _createPermanentOrderUseCase(order);

View File

@@ -106,3 +106,20 @@ class PermanentOrderInitialized extends PermanentOrderEvent {
@override @override
List<Object?> get props => <Object?>[data]; List<Object?> get props => <Object?>[data];
} }
class PermanentOrderHubManagerChanged extends PermanentOrderEvent {
const PermanentOrderHubManagerChanged(this.manager);
final PermanentOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class PermanentOrderManagersLoaded extends PermanentOrderEvent {
const PermanentOrderManagersLoaded(this.managers);
final List<PermanentOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -18,6 +18,8 @@ class PermanentOrderState extends Equatable {
this.hubs = const <PermanentOrderHubOption>[], this.hubs = const <PermanentOrderHubOption>[],
this.selectedHub, this.selectedHub,
this.roles = const <PermanentOrderRoleOption>[], this.roles = const <PermanentOrderRoleOption>[],
this.managers = const <PermanentOrderManagerOption>[],
this.selectedManager,
}); });
factory PermanentOrderState.initial() { factory PermanentOrderState.initial() {
@@ -45,6 +47,7 @@ class PermanentOrderState extends Equatable {
vendors: const <Vendor>[], vendors: const <Vendor>[],
hubs: const <PermanentOrderHubOption>[], hubs: const <PermanentOrderHubOption>[],
roles: const <PermanentOrderRoleOption>[], roles: const <PermanentOrderRoleOption>[],
managers: const <PermanentOrderManagerOption>[],
); );
} }
@@ -61,6 +64,8 @@ class PermanentOrderState extends Equatable {
final List<PermanentOrderHubOption> hubs; final List<PermanentOrderHubOption> hubs;
final PermanentOrderHubOption? selectedHub; final PermanentOrderHubOption? selectedHub;
final List<PermanentOrderRoleOption> roles; final List<PermanentOrderRoleOption> roles;
final List<PermanentOrderManagerOption> managers;
final PermanentOrderManagerOption? selectedManager;
PermanentOrderState copyWith({ PermanentOrderState copyWith({
DateTime? startDate, DateTime? startDate,
@@ -76,6 +81,8 @@ class PermanentOrderState extends Equatable {
List<PermanentOrderHubOption>? hubs, List<PermanentOrderHubOption>? hubs,
PermanentOrderHubOption? selectedHub, PermanentOrderHubOption? selectedHub,
List<PermanentOrderRoleOption>? roles, List<PermanentOrderRoleOption>? roles,
List<PermanentOrderManagerOption>? managers,
PermanentOrderManagerOption? selectedManager,
}) { }) {
return PermanentOrderState( return PermanentOrderState(
startDate: startDate ?? this.startDate, startDate: startDate ?? this.startDate,
@@ -91,6 +98,8 @@ class PermanentOrderState extends Equatable {
hubs: hubs ?? this.hubs, hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub, selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles, roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
); );
} }
@@ -124,6 +133,8 @@ class PermanentOrderState extends Equatable {
hubs, hubs,
selectedHub, selectedHub,
roles, roles,
managers,
selectedManager,
]; ];
} }
@@ -185,6 +196,20 @@ class PermanentOrderRoleOption extends Equatable {
List<Object?> get props => <Object?>[id, name, costPerHour]; List<Object?> get props => <Object?>[id, name, costPerHour];
} }
class PermanentOrderManagerOption extends Equatable {
const PermanentOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}
class PermanentOrderPosition extends Equatable { class PermanentOrderPosition extends Equatable {
const PermanentOrderPosition({ const PermanentOrderPosition({
required this.role, required this.role,

View File

@@ -32,6 +32,8 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
on<RecurringOrderPositionUpdated>(_onPositionUpdated); on<RecurringOrderPositionUpdated>(_onPositionUpdated);
on<RecurringOrderSubmitted>(_onSubmitted); on<RecurringOrderSubmitted>(_onSubmitted);
on<RecurringOrderInitialized>(_onInitialized); on<RecurringOrderInitialized>(_onInitialized);
on<RecurringOrderHubManagerChanged>(_onHubManagerChanged);
on<RecurringOrderManagersLoaded>(_onManagersLoaded);
_loadVendors(); _loadVendors();
_loadHubs(); _loadHubs();
@@ -183,6 +185,10 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
location: selectedHub?.name ?? '', location: selectedHub?.name ?? '',
), ),
); );
if (selectedHub != null) {
_loadManagersForHub(selectedHub.id, emit);
}
} }
void _onHubChanged( void _onHubChanged(
@@ -190,6 +196,58 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
Emitter<RecurringOrderState> emit, Emitter<RecurringOrderState> emit,
) { ) {
emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); emit(state.copyWith(selectedHub: event.hub, location: event.hub.name));
_loadManagersForHub(event.hub.id, emit);
}
void _onHubManagerChanged(
RecurringOrderHubManagerChanged event,
Emitter<RecurringOrderState> emit,
) {
emit(state.copyWith(selectedManager: event.manager));
}
void _onManagersLoaded(
RecurringOrderManagersLoaded event,
Emitter<RecurringOrderState> emit,
) {
emit(state.copyWith(managers: event.managers));
}
Future<void> _loadManagersForHub(
String hubId,
Emitter<RecurringOrderState> emit,
) async {
final List<RecurringOrderManagerOption>? managers =
await handleErrorWithResult(
action: () async {
final fdc.QueryResult<dc.ListTeamMembersData, void> result =
await _service.connector.listTeamMembers().execute();
return result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.map(
(dc.ListTeamMembersTeamMembers member) =>
RecurringOrderManagerOption(
id: member.id,
name: member.user.fullName ?? 'Unknown',
),
)
.toList();
},
onError: (_) => emit(
state.copyWith(managers: const <RecurringOrderManagerOption>[]),
),
);
if (managers != null) {
emit(state.copyWith(managers: managers, selectedManager: null));
}
} }
void _onEventNameChanged( void _onEventNameChanged(
@@ -349,6 +407,7 @@ class RecurringOrderBloc extends Bloc<RecurringOrderEvent, RecurringOrderState>
), ),
eventName: state.eventName, eventName: state.eventName,
vendorId: state.selectedVendor?.id, vendorId: state.selectedVendor?.id,
hubManagerId: state.selectedManager?.id,
roleRates: roleRates, roleRates: roleRates,
); );
await _createRecurringOrderUseCase(order); await _createRecurringOrderUseCase(order);

View File

@@ -115,3 +115,20 @@ class RecurringOrderInitialized extends RecurringOrderEvent {
@override @override
List<Object?> get props => <Object?>[data]; List<Object?> get props => <Object?>[data];
} }
class RecurringOrderHubManagerChanged extends RecurringOrderEvent {
const RecurringOrderHubManagerChanged(this.manager);
final RecurringOrderManagerOption? manager;
@override
List<Object?> get props => <Object?>[manager];
}
class RecurringOrderManagersLoaded extends RecurringOrderEvent {
const RecurringOrderManagersLoaded(this.managers);
final List<RecurringOrderManagerOption> managers;
@override
List<Object?> get props => <Object?>[managers];
}

View File

@@ -19,6 +19,8 @@ class RecurringOrderState extends Equatable {
this.hubs = const <RecurringOrderHubOption>[], this.hubs = const <RecurringOrderHubOption>[],
this.selectedHub, this.selectedHub,
this.roles = const <RecurringOrderRoleOption>[], this.roles = const <RecurringOrderRoleOption>[],
this.managers = const <RecurringOrderManagerOption>[],
this.selectedManager,
}); });
factory RecurringOrderState.initial() { factory RecurringOrderState.initial() {
@@ -47,6 +49,7 @@ class RecurringOrderState extends Equatable {
vendors: const <Vendor>[], vendors: const <Vendor>[],
hubs: const <RecurringOrderHubOption>[], hubs: const <RecurringOrderHubOption>[],
roles: const <RecurringOrderRoleOption>[], roles: const <RecurringOrderRoleOption>[],
managers: const <RecurringOrderManagerOption>[],
); );
} }
@@ -64,6 +67,8 @@ class RecurringOrderState extends Equatable {
final List<RecurringOrderHubOption> hubs; final List<RecurringOrderHubOption> hubs;
final RecurringOrderHubOption? selectedHub; final RecurringOrderHubOption? selectedHub;
final List<RecurringOrderRoleOption> roles; final List<RecurringOrderRoleOption> roles;
final List<RecurringOrderManagerOption> managers;
final RecurringOrderManagerOption? selectedManager;
RecurringOrderState copyWith({ RecurringOrderState copyWith({
DateTime? startDate, DateTime? startDate,
@@ -80,6 +85,8 @@ class RecurringOrderState extends Equatable {
List<RecurringOrderHubOption>? hubs, List<RecurringOrderHubOption>? hubs,
RecurringOrderHubOption? selectedHub, RecurringOrderHubOption? selectedHub,
List<RecurringOrderRoleOption>? roles, List<RecurringOrderRoleOption>? roles,
List<RecurringOrderManagerOption>? managers,
RecurringOrderManagerOption? selectedManager,
}) { }) {
return RecurringOrderState( return RecurringOrderState(
startDate: startDate ?? this.startDate, startDate: startDate ?? this.startDate,
@@ -96,6 +103,8 @@ class RecurringOrderState extends Equatable {
hubs: hubs ?? this.hubs, hubs: hubs ?? this.hubs,
selectedHub: selectedHub ?? this.selectedHub, selectedHub: selectedHub ?? this.selectedHub,
roles: roles ?? this.roles, roles: roles ?? this.roles,
managers: managers ?? this.managers,
selectedManager: selectedManager ?? this.selectedManager,
); );
} }
@@ -132,6 +141,8 @@ class RecurringOrderState extends Equatable {
hubs, hubs,
selectedHub, selectedHub,
roles, roles,
managers,
selectedManager,
]; ];
} }
@@ -193,6 +204,20 @@ class RecurringOrderRoleOption extends Equatable {
List<Object?> get props => <Object?>[id, name, costPerHour]; List<Object?> get props => <Object?>[id, name, costPerHour];
} }
class RecurringOrderManagerOption extends Equatable {
const RecurringOrderManagerOption({
required this.id,
required this.name,
});
final String id;
final String name;
@override
List<Object?> get props => <Object?>[id, name];
}
class RecurringOrderPosition extends Equatable { class RecurringOrderPosition extends Equatable {
const RecurringOrderPosition({ const RecurringOrderPosition({
required this.role, required this.role,

View File

@@ -48,6 +48,10 @@ class OneTimeOrderPage extends StatelessWidget {
hubs: state.hubs.map(_mapHub).toList(), hubs: state.hubs.map(_mapHub).toList(),
positions: state.positions.map(_mapPosition).toList(), positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(), roles: state.roles.map(_mapRole).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
hubManagers: state.managers.map(_mapManager).toList(),
isValid: state.isValid, isValid: state.isValid,
onEventNameChanged: (String val) => onEventNameChanged: (String val) =>
bloc.add(OneTimeOrderEventNameChanged(val)), bloc.add(OneTimeOrderEventNameChanged(val)),
@@ -61,6 +65,17 @@ class OneTimeOrderPage extends StatelessWidget {
); );
bloc.add(OneTimeOrderHubChanged(originalHub)); bloc.add(OneTimeOrderHubChanged(originalHub));
}, },
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const OneTimeOrderHubManagerChanged(null));
return;
}
final OneTimeOrderManagerOption original =
state.managers.firstWhere(
(OneTimeOrderManagerOption m) => m.id == val.id,
);
bloc.add(OneTimeOrderHubManagerChanged(original));
},
onPositionAdded: () => bloc.add(const OneTimeOrderPositionAdded()), onPositionAdded: () => bloc.add(const OneTimeOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) { onPositionUpdated: (int index, OrderPositionUiModel val) {
final OneTimeOrderPosition original = state.positions[index]; final OneTimeOrderPosition original = state.positions[index];
@@ -130,4 +145,9 @@ class OneTimeOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak, lunchBreak: pos.lunchBreak,
); );
} }
OrderManagerUiModel _mapManager(OneTimeOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
} }

View File

@@ -42,6 +42,10 @@ class PermanentOrderPage extends StatelessWidget {
? _mapHub(state.selectedHub!) ? _mapHub(state.selectedHub!)
: null, : null,
hubs: state.hubs.map(_mapHub).toList(), hubs: state.hubs.map(_mapHub).toList(),
hubManagers: state.managers.map(_mapManager).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
positions: state.positions.map(_mapPosition).toList(), positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(), roles: state.roles.map(_mapRole).toList(),
isValid: state.isValid, isValid: state.isValid,
@@ -59,6 +63,17 @@ class PermanentOrderPage extends StatelessWidget {
); );
bloc.add(PermanentOrderHubChanged(originalHub)); bloc.add(PermanentOrderHubChanged(originalHub));
}, },
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const PermanentOrderHubManagerChanged(null));
return;
}
final PermanentOrderManagerOption original =
state.managers.firstWhere(
(PermanentOrderManagerOption m) => m.id == val.id,
);
bloc.add(PermanentOrderHubManagerChanged(original));
},
onPositionAdded: () => onPositionAdded: () =>
bloc.add(const PermanentOrderPositionAdded()), bloc.add(const PermanentOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) { onPositionUpdated: (int index, OrderPositionUiModel val) {
@@ -181,4 +196,8 @@ class PermanentOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak ?? 'NO_BREAK', lunchBreak: pos.lunchBreak ?? 'NO_BREAK',
); );
} }
OrderManagerUiModel _mapManager(PermanentOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
} }

View File

@@ -43,6 +43,10 @@ class RecurringOrderPage extends StatelessWidget {
? _mapHub(state.selectedHub!) ? _mapHub(state.selectedHub!)
: null, : null,
hubs: state.hubs.map(_mapHub).toList(), hubs: state.hubs.map(_mapHub).toList(),
hubManagers: state.managers.map(_mapManager).toList(),
selectedHubManager: state.selectedManager != null
? _mapManager(state.selectedManager!)
: null,
positions: state.positions.map(_mapPosition).toList(), positions: state.positions.map(_mapPosition).toList(),
roles: state.roles.map(_mapRole).toList(), roles: state.roles.map(_mapRole).toList(),
isValid: state.isValid, isValid: state.isValid,
@@ -62,6 +66,17 @@ class RecurringOrderPage extends StatelessWidget {
); );
bloc.add(RecurringOrderHubChanged(originalHub)); bloc.add(RecurringOrderHubChanged(originalHub));
}, },
onHubManagerChanged: (OrderManagerUiModel? val) {
if (val == null) {
bloc.add(const RecurringOrderHubManagerChanged(null));
return;
}
final RecurringOrderManagerOption original =
state.managers.firstWhere(
(RecurringOrderManagerOption m) => m.id == val.id,
);
bloc.add(RecurringOrderHubManagerChanged(original));
},
onPositionAdded: () => onPositionAdded: () =>
bloc.add(const RecurringOrderPositionAdded()), bloc.add(const RecurringOrderPositionAdded()),
onPositionUpdated: (int index, OrderPositionUiModel val) { onPositionUpdated: (int index, OrderPositionUiModel val) {
@@ -193,4 +208,8 @@ class RecurringOrderPage extends StatelessWidget {
lunchBreak: pos.lunchBreak ?? 'NO_BREAK', lunchBreak: pos.lunchBreak ?? 'NO_BREAK',
); );
} }
OrderManagerUiModel _mapManager(RecurringOrderManagerOption manager) {
return OrderManagerUiModel(id: manager.id, name: manager.name);
}
} }

View File

@@ -0,0 +1,161 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'order_ui_models.dart';
class HubManagerSelector extends StatelessWidget {
const HubManagerSelector({
required this.managers,
required this.selectedManager,
required this.onChanged,
required this.hintText,
required this.label,
this.description,
super.key,
});
final List<OrderManagerUiModel> managers;
final OrderManagerUiModel? selectedManager;
final ValueChanged<OrderManagerUiModel?> onChanged;
final String hintText;
final String label;
final String? description;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Text(
label,
style: UiTypography.body1m.textPrimary,
),
if (description != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Text(description!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: () => _showSelector(context),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: selectedManager != null ? UiColors.primary : UiColors.border,
width: selectedManager != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Icon(
UiIcons.user,
color: selectedManager != null
? UiColors.primary
: UiColors.iconSecondary,
size: 20,
),
const SizedBox(width: UiConstants.space3),
Text(
selectedManager?.name ?? hintText,
style: selectedManager != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
Future<void> _showSelector(BuildContext context) async {
final OrderManagerUiModel? selected = await showDialog<OrderManagerUiModel>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
label,
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.builder(
shrinkWrap: true,
itemCount: managers.isEmpty ? 2 : managers.length + 1,
itemBuilder: (BuildContext context, int index) {
if (managers.isEmpty) {
if (index == 0) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text('No hub managers available'),
);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('None', style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(
const OrderManagerUiModel(id: 'NONE', name: 'None'),
),
);
}
if (index == managers.length) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('None', style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(
const OrderManagerUiModel(id: 'NONE', name: 'None'),
),
);
}
final OrderManagerUiModel manager = managers[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
title: Text(manager.name, style: UiTypography.body1m.textPrimary),
subtitle: manager.phone != null
? Text(manager.phone!, style: UiTypography.body2r.textSecondary)
: null,
onTap: () => Navigator.of(context).pop(manager),
);
},
),
),
),
);
},
);
if (selected != null) {
if (selected.id == 'NONE') {
onChanged(null);
} else {
onChanged(selected);
}
}
}
}

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import '../hub_manager_selector.dart';
import 'one_time_order_date_picker.dart'; import 'one_time_order_date_picker.dart';
import 'one_time_order_event_name_input.dart'; import 'one_time_order_event_name_input.dart';
import 'one_time_order_header.dart'; import 'one_time_order_header.dart';
@@ -23,11 +24,14 @@ class OneTimeOrderView extends StatelessWidget {
required this.hubs, required this.hubs,
required this.positions, required this.positions,
required this.roles, required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid, required this.isValid,
required this.onEventNameChanged, required this.onEventNameChanged,
required this.onVendorChanged, required this.onVendorChanged,
required this.onDateChanged, required this.onDateChanged,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
@@ -47,12 +51,15 @@ class OneTimeOrderView extends StatelessWidget {
final List<OrderHubUiModel> hubs; final List<OrderHubUiModel> hubs;
final List<OrderPositionUiModel> positions; final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles; final List<OrderRoleUiModel> roles;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
final bool isValid; final bool isValid;
final ValueChanged<String> onEventNameChanged; final ValueChanged<String> onEventNameChanged;
final ValueChanged<Vendor> onVendorChanged; final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged; final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
@@ -143,12 +150,15 @@ class OneTimeOrderView extends StatelessWidget {
date: date, date: date,
selectedHub: selectedHub, selectedHub: selectedHub,
hubs: hubs, hubs: hubs,
selectedHubManager: selectedHubManager,
hubManagers: hubManagers,
positions: positions, positions: positions,
roles: roles, roles: roles,
onEventNameChanged: onEventNameChanged, onEventNameChanged: onEventNameChanged,
onVendorChanged: onVendorChanged, onVendorChanged: onVendorChanged,
onDateChanged: onDateChanged, onDateChanged: onDateChanged,
onHubChanged: onHubChanged, onHubChanged: onHubChanged,
onHubManagerChanged: onHubManagerChanged,
onPositionAdded: onPositionAdded, onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated, onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved, onPositionRemoved: onPositionRemoved,
@@ -179,12 +189,15 @@ class _OneTimeOrderForm extends StatelessWidget {
required this.date, required this.date,
required this.selectedHub, required this.selectedHub,
required this.hubs, required this.hubs,
required this.selectedHubManager,
required this.hubManagers,
required this.positions, required this.positions,
required this.roles, required this.roles,
required this.onEventNameChanged, required this.onEventNameChanged,
required this.onVendorChanged, required this.onVendorChanged,
required this.onDateChanged, required this.onDateChanged,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
@@ -196,6 +209,8 @@ class _OneTimeOrderForm extends StatelessWidget {
final DateTime date; final DateTime date;
final OrderHubUiModel? selectedHub; final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs; final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions; final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles; final List<OrderRoleUiModel> roles;
@@ -203,6 +218,7 @@ class _OneTimeOrderForm extends StatelessWidget {
final ValueChanged<Vendor> onVendorChanged; final ValueChanged<Vendor> onVendorChanged;
final ValueChanged<DateTime> onDateChanged; final ValueChanged<DateTime> onDateChanged;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
@@ -310,6 +326,16 @@ class _OneTimeOrderForm extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: UiConstants.space4),
HubManagerSelector(
label: labels.hub_manager_label,
description: labels.hub_manager_desc,
hintText: labels.hub_manager_hint,
managers: hubManagers,
selectedManager: selectedHubManager,
onChanged: onHubManagerChanged,
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
OneTimeOrderSectionHeader( OneTimeOrderSectionHeader(

View File

@@ -94,3 +94,19 @@ class OrderPositionUiModel extends Equatable {
@override @override
List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak]; List<Object?> get props => <Object?>[role, count, startTime, endTime, lunchBreak];
} }
class OrderManagerUiModel extends Equatable {
const OrderManagerUiModel({
required this.id,
required this.name,
this.phone,
});
final String id;
final String name;
final String? phone;
@override
List<Object?> get props => <Object?>[id, name, phone];
}

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:krow_domain/krow_domain.dart' show Vendor;
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import '../hub_manager_selector.dart';
import 'permanent_order_date_picker.dart'; import 'permanent_order_date_picker.dart';
import 'permanent_order_event_name_input.dart'; import 'permanent_order_event_name_input.dart';
import 'permanent_order_header.dart'; import 'permanent_order_header.dart';
@@ -24,12 +25,15 @@ class PermanentOrderView extends StatelessWidget {
required this.hubs, required this.hubs,
required this.positions, required this.positions,
required this.roles, required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid, required this.isValid,
required this.onEventNameChanged, required this.onEventNameChanged,
required this.onVendorChanged, required this.onVendorChanged,
required this.onStartDateChanged, required this.onStartDateChanged,
required this.onDayToggled, required this.onDayToggled,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
@@ -48,6 +52,8 @@ class PermanentOrderView extends StatelessWidget {
final List<String> permanentDays; final List<String> permanentDays;
final OrderHubUiModel? selectedHub; final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs; final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions; final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles; final List<OrderRoleUiModel> roles;
final bool isValid; final bool isValid;
@@ -57,6 +63,7 @@ class PermanentOrderView extends StatelessWidget {
final ValueChanged<DateTime> onStartDateChanged; final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled; final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
@@ -156,9 +163,12 @@ class PermanentOrderView extends StatelessWidget {
onStartDateChanged: onStartDateChanged, onStartDateChanged: onStartDateChanged,
onDayToggled: onDayToggled, onDayToggled: onDayToggled,
onHubChanged: onHubChanged, onHubChanged: onHubChanged,
onHubManagerChanged: onHubManagerChanged,
onPositionAdded: onPositionAdded, onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated, onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved, onPositionRemoved: onPositionRemoved,
hubManagers: hubManagers,
selectedHubManager: selectedHubManager,
), ),
if (status == OrderFormStatus.loading) if (status == OrderFormStatus.loading)
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
@@ -194,9 +204,12 @@ class _PermanentOrderForm extends StatelessWidget {
required this.onStartDateChanged, required this.onStartDateChanged,
required this.onDayToggled, required this.onDayToggled,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
required this.hubManagers,
required this.selectedHubManager,
}); });
final String eventName; final String eventName;
@@ -214,10 +227,14 @@ class _PermanentOrderForm extends StatelessWidget {
final ValueChanged<DateTime> onStartDateChanged; final ValueChanged<DateTime> onStartDateChanged;
final ValueChanged<int> onDayToggled; final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientCreateOrderPermanentEn labels = final TranslationsClientCreateOrderPermanentEn labels =
@@ -331,6 +348,16 @@ class _PermanentOrderForm extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: UiConstants.space4),
HubManagerSelector(
label: oneTimeLabels.hub_manager_label,
description: oneTimeLabels.hub_manager_desc,
hintText: oneTimeLabels.hub_manager_hint,
managers: hubManagers,
selectedManager: selectedHubManager,
onChanged: onHubManagerChanged,
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
PermanentOrderSectionHeader( PermanentOrderSectionHeader(

View File

@@ -3,6 +3,7 @@ import 'package:krow_domain/krow_domain.dart' show Vendor;
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../order_ui_models.dart'; import '../order_ui_models.dart';
import '../hub_manager_selector.dart';
import 'recurring_order_date_picker.dart'; import 'recurring_order_date_picker.dart';
import 'recurring_order_event_name_input.dart'; import 'recurring_order_event_name_input.dart';
import 'recurring_order_header.dart'; import 'recurring_order_header.dart';
@@ -25,6 +26,8 @@ class RecurringOrderView extends StatelessWidget {
required this.hubs, required this.hubs,
required this.positions, required this.positions,
required this.roles, required this.roles,
required this.hubManagers,
required this.selectedHubManager,
required this.isValid, required this.isValid,
required this.onEventNameChanged, required this.onEventNameChanged,
required this.onVendorChanged, required this.onVendorChanged,
@@ -32,6 +35,7 @@ class RecurringOrderView extends StatelessWidget {
required this.onEndDateChanged, required this.onEndDateChanged,
required this.onDayToggled, required this.onDayToggled,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
@@ -51,6 +55,8 @@ class RecurringOrderView extends StatelessWidget {
final List<String> recurringDays; final List<String> recurringDays;
final OrderHubUiModel? selectedHub; final OrderHubUiModel? selectedHub;
final List<OrderHubUiModel> hubs; final List<OrderHubUiModel> hubs;
final OrderManagerUiModel? selectedHubManager;
final List<OrderManagerUiModel> hubManagers;
final List<OrderPositionUiModel> positions; final List<OrderPositionUiModel> positions;
final List<OrderRoleUiModel> roles; final List<OrderRoleUiModel> roles;
final bool isValid; final bool isValid;
@@ -61,6 +67,7 @@ class RecurringOrderView extends StatelessWidget {
final ValueChanged<DateTime> onEndDateChanged; final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled; final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
@@ -165,9 +172,12 @@ class RecurringOrderView extends StatelessWidget {
onEndDateChanged: onEndDateChanged, onEndDateChanged: onEndDateChanged,
onDayToggled: onDayToggled, onDayToggled: onDayToggled,
onHubChanged: onHubChanged, onHubChanged: onHubChanged,
onHubManagerChanged: onHubManagerChanged,
onPositionAdded: onPositionAdded, onPositionAdded: onPositionAdded,
onPositionUpdated: onPositionUpdated, onPositionUpdated: onPositionUpdated,
onPositionRemoved: onPositionRemoved, onPositionRemoved: onPositionRemoved,
hubManagers: hubManagers,
selectedHubManager: selectedHubManager,
), ),
if (status == OrderFormStatus.loading) if (status == OrderFormStatus.loading)
const Center(child: CircularProgressIndicator()), const Center(child: CircularProgressIndicator()),
@@ -205,9 +215,12 @@ class _RecurringOrderForm extends StatelessWidget {
required this.onEndDateChanged, required this.onEndDateChanged,
required this.onDayToggled, required this.onDayToggled,
required this.onHubChanged, required this.onHubChanged,
required this.onHubManagerChanged,
required this.onPositionAdded, required this.onPositionAdded,
required this.onPositionUpdated, required this.onPositionUpdated,
required this.onPositionRemoved, required this.onPositionRemoved,
required this.hubManagers,
required this.selectedHubManager,
}); });
final String eventName; final String eventName;
@@ -227,10 +240,15 @@ class _RecurringOrderForm extends StatelessWidget {
final ValueChanged<DateTime> onEndDateChanged; final ValueChanged<DateTime> onEndDateChanged;
final ValueChanged<int> onDayToggled; final ValueChanged<int> onDayToggled;
final ValueChanged<OrderHubUiModel> onHubChanged; final ValueChanged<OrderHubUiModel> onHubChanged;
final ValueChanged<OrderManagerUiModel?> onHubManagerChanged;
final VoidCallback onPositionAdded; final VoidCallback onPositionAdded;
final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index, OrderPositionUiModel position) onPositionUpdated;
final void Function(int index) onPositionRemoved; final void Function(int index) onPositionRemoved;
final List<OrderManagerUiModel> hubManagers;
final OrderManagerUiModel? selectedHubManager;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final TranslationsClientCreateOrderRecurringEn labels = final TranslationsClientCreateOrderRecurringEn labels =
@@ -351,6 +369,16 @@ class _RecurringOrderForm extends StatelessWidget {
), ),
), ),
), ),
const SizedBox(height: UiConstants.space4),
HubManagerSelector(
label: oneTimeLabels.hub_manager_label,
description: oneTimeLabels.hub_manager_desc,
hintText: oneTimeLabels.hub_manager_hint,
managers: hubManagers,
selectedManager: selectedHubManager,
onChanged: onHubManagerChanged,
),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
RecurringOrderSectionHeader( RecurringOrderSectionHeader(

View File

@@ -57,6 +57,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
const <dc.ListTeamHubsByOwnerIdTeamHubs>[]; const <dc.ListTeamHubsByOwnerIdTeamHubs>[];
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
List<dc.ListTeamMembersTeamMembers> _managers = const <dc.ListTeamMembersTeamMembers>[];
dc.ListTeamMembersTeamMembers? _selectedManager;
String? _shiftId; String? _shiftId;
List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[]; List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[];
@@ -246,6 +249,9 @@ class OrderEditSheetState extends State<OrderEditSheet> {
} }
}); });
} }
if (selected != null) {
await _loadManagersForHub(selected.id, widget.order.hubManagerId);
}
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -331,6 +337,47 @@ class OrderEditSheetState extends State<OrderEditSheet> {
} }
} }
Future<void> _loadManagersForHub(String hubId, [String? preselectedId]) async {
try {
final QueryResult<dc.ListTeamMembersData, void> result =
await _dataConnect.listTeamMembers().execute();
final List<dc.ListTeamMembersTeamMembers> hubManagers = result.data.teamMembers
.where(
(dc.ListTeamMembersTeamMembers member) =>
member.teamHubId == hubId &&
member.role is dc.Known<dc.TeamMemberRole> &&
(member.role as dc.Known<dc.TeamMemberRole>).value ==
dc.TeamMemberRole.MANAGER,
)
.toList();
dc.ListTeamMembersTeamMembers? selected;
if (preselectedId != null && preselectedId.isNotEmpty) {
for (final dc.ListTeamMembersTeamMembers m in hubManagers) {
if (m.id == preselectedId) {
selected = m;
break;
}
}
}
if (mounted) {
setState(() {
_managers = hubManagers;
_selectedManager = selected;
});
}
} catch (_) {
if (mounted) {
setState(() {
_managers = const <dc.ListTeamMembersTeamMembers>[];
_selectedManager = null;
});
}
}
}
Map<String, dynamic> _emptyPosition() { Map<String, dynamic> _emptyPosition() {
return <String, dynamic>{ return <String, dynamic>{
'shiftId': _shiftId, 'shiftId': _shiftId,
@@ -744,6 +791,10 @@ class OrderEditSheetState extends State<OrderEditSheet> {
), ),
), ),
), ),
const SizedBox(height: UiConstants.space4),
_buildHubManagerSelector(),
const SizedBox(height: UiConstants.space6), const SizedBox(height: UiConstants.space6),
Row( Row(
@@ -807,6 +858,130 @@ class OrderEditSheetState extends State<OrderEditSheet> {
); );
} }
Widget _buildHubManagerSelector() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildSectionHeader('SHIFT CONTACT'),
Text('On-site manager or supervisor for this shift', style: UiTypography.body2r.textSecondary),
const SizedBox(height: UiConstants.space2),
InkWell(
onTap: () => _showHubManagerSelector(),
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: 14,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(
color: _selectedManager != null ? UiColors.primary : UiColors.border,
width: _selectedManager != null ? 2 : 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
Icon(
UiIcons.user,
color: _selectedManager != null
? UiColors.primary
: UiColors.iconSecondary,
size: 20,
),
const SizedBox(width: UiConstants.space3),
Text(
_selectedManager?.user.fullName ?? 'Select Contact',
style: _selectedManager != null
? UiTypography.body1r.textPrimary
: UiTypography.body2r.textPlaceholder,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
const Icon(
Icons.keyboard_arrow_down,
color: UiColors.iconSecondary,
),
],
),
),
),
],
);
}
Future<void> _showHubManagerSelector() async {
final dc.ListTeamMembersTeamMembers? selected = await showDialog<dc.ListTeamMembersTeamMembers?>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
),
title: Text(
'Shift Contact',
style: UiTypography.headline3m.textPrimary,
),
contentPadding: const EdgeInsets.symmetric(vertical: 16),
content: SizedBox(
width: double.maxFinite,
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 400),
child: ListView.builder(
shrinkWrap: true,
itemCount: _managers.isEmpty ? 2 : _managers.length + 1,
itemBuilder: (BuildContext context, int index) {
if (_managers.isEmpty) {
if (index == 0) {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Text('No hub managers available'),
);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('None', style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(null),
);
}
if (index == _managers.length) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
title: Text('None', style: UiTypography.body1m.textSecondary),
onTap: () => Navigator.of(context).pop(null),
);
}
final dc.ListTeamMembersTeamMembers manager = _managers[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
title: Text(manager.user.fullName ?? 'Unknown', style: UiTypography.body1m.textPrimary),
onTap: () => Navigator.of(context).pop(manager),
);
},
),
),
),
);
},
);
if (mounted) {
if (selected == null && _managers.isEmpty) {
// Tapped outside or selected None
setState(() => _selectedManager = null);
} else {
setState(() => _selectedManager = selected);
}
}
}
Widget _buildHeader() { Widget _buildHeader() {
return Container( return Container(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), padding: const EdgeInsets.fromLTRB(20, 24, 20, 20),
@@ -938,7 +1113,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
context: context, context: context,
initialTime: TimeOfDay.now(), initialTime: TimeOfDay.now(),
); );
if (picked != null && context.mounted) { if (picked != null && mounted) {
_updatePosition( _updatePosition(
index, index,
'start_time', 'start_time',
@@ -958,7 +1133,7 @@ class OrderEditSheetState extends State<OrderEditSheet> {
context: context, context: context,
initialTime: TimeOfDay.now(), initialTime: TimeOfDay.now(),
); );
if (picked != null && context.mounted) { if (picked != null && mounted) {
_updatePosition( _updatePosition(
index, index,
'end_time', 'end_time',

View File

@@ -259,6 +259,31 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
), ),
], ],
), ),
if (order.hubManagerName != null) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[
const Padding(
padding: EdgeInsets.only(top: 2),
child: Icon(
UiIcons.user,
size: 14,
color: UiColors.iconSecondary,
),
),
const SizedBox(width: UiConstants.space2),
Expanded(
child: Text(
order.hubManagerName!,
style: UiTypography.footnote2r.textSecondary,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
], ],
), ),
), ),

View File

@@ -24,15 +24,52 @@ class SettingsActions extends StatelessWidget {
delegate: SliverChildListDelegate(<Widget>[ delegate: SliverChildListDelegate(<Widget>[
const SizedBox(height: UiConstants.space5), const SizedBox(height: UiConstants.space5),
// Edit Profile button (Yellow)
UiButton.primary(
text: labels.edit_profile,
fullWidth: true,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: () => Modular.to.toClientEditProfile(),
),
const SizedBox(height: UiConstants.space4),
// Hubs button (Yellow)
UiButton.primary(
text: labels.hubs,
fullWidth: true,
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.accent,
foregroundColor: UiColors.accentForeground,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: () => Modular.to.toClientHubs(),
),
const SizedBox(height: UiConstants.space5),
// Quick Links card // Quick Links card
_QuickLinksCard(labels: labels), _QuickLinksCard(labels: labels),
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space5),
// Log Out button (outlined) // Log Out button (outlined)
BlocBuilder<ClientSettingsBloc, ClientSettingsState>( BlocBuilder<ClientSettingsBloc, ClientSettingsState>(
builder: (BuildContext context, ClientSettingsState state) { builder: (BuildContext context, ClientSettingsState state) {
return UiButton.secondary( return UiButton.secondary(
text: labels.log_out, text: labels.log_out,
fullWidth: true,
style: OutlinedButton.styleFrom(
side: const BorderSide(color: UiColors.black),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(UiConstants.radiusBase * 2),
),
),
onPressed: state is ClientSettingsLoading onPressed: state is ClientSettingsLoading
? null ? null
: () => _showSignOutDialog(context), : () => _showSignOutDialog(context),
@@ -113,7 +150,7 @@ class _QuickLinksCard extends StatelessWidget {
onTap: () => Modular.to.toClientHubs(), onTap: () => Modular.to.toClientHubs(),
), ),
_QuickLinkItem( _QuickLinkItem(
icon: UiIcons.building, icon: UiIcons.file,
title: labels.billing_payments, title: labels.billing_payments,
onTap: () => Modular.to.toClientBilling(), onTap: () => Modular.to.toClientBilling(),
), ),

View File

@@ -31,7 +31,7 @@ class SettingsProfileHeader extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
// ── Top bar: back arrow + title ────────────────── // ── Top bar: back arrow + centered title ─────────
SafeArea( SafeArea(
bottom: false, bottom: false,
child: Padding( child: Padding(
@@ -39,9 +39,12 @@ class SettingsProfileHeader extends StatelessWidget {
horizontal: UiConstants.space4, horizontal: UiConstants.space4,
vertical: UiConstants.space2, vertical: UiConstants.space2,
), ),
child: Row( child: Stack(
alignment: Alignment.center,
children: <Widget>[ children: <Widget>[
GestureDetector( Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => Modular.to.toClientHome(), onTap: () => Modular.to.toClientHome(),
child: const Icon( child: const Icon(
UiIcons.arrowLeft, UiIcons.arrowLeft,
@@ -49,11 +52,12 @@ class SettingsProfileHeader extends StatelessWidget {
size: 22, size: 22,
), ),
), ),
const SizedBox(width: UiConstants.space3), ),
Text( Text(
labels.title, labels.title,
style: UiTypography.body1b.copyWith( style: UiTypography.body1b.copyWith(
color: UiColors.white, color: UiColors.white,
fontSize: 18,
), ),
), ),
], ],

View File

@@ -15,6 +15,7 @@ mutation createOrder(
$shifts: Any $shifts: Any
$requested: Int $requested: Int
$teamHubId: UUID! $teamHubId: UUID!
$hubManagerId: UUID
$recurringDays: [String!] $recurringDays: [String!]
$permanentStartDate: Timestamp $permanentStartDate: Timestamp
$permanentDays: [String!] $permanentDays: [String!]
@@ -40,6 +41,7 @@ mutation createOrder(
shifts: $shifts shifts: $shifts
requested: $requested requested: $requested
teamHubId: $teamHubId teamHubId: $teamHubId
hubManagerId: $hubManagerId
recurringDays: $recurringDays recurringDays: $recurringDays
permanentDays: $permanentDays permanentDays: $permanentDays
notes: $notes notes: $notes

View File

@@ -47,6 +47,9 @@ type Order @table(name: "orders", key: ["id"]) {
teamHubId: UUID! teamHubId: UUID!
teamHub: TeamHub! @ref(fields: "teamHubId", references: "id") teamHub: TeamHub! @ref(fields: "teamHubId", references: "id")
hubManagerId: UUID
hubManager: TeamMember @ref(fields: "hubManagerId", references: "id")
date: Timestamp date: Timestamp
startDate: Timestamp #for recurring and permanent startDate: Timestamp #for recurring and permanent