diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index a0e67c19..ddfa75aa 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -1,3 +1,5 @@ +import 'dart:io' show Platform; + import 'package:client_authentication/client_authentication.dart' as client_authentication; import 'package:client_create_order/client_create_order.dart' @@ -10,6 +12,7 @@ import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -20,7 +23,23 @@ import 'firebase_options.dart'; import 'src/widgets/session_listener.dart'; void main() async { - WidgetsFlutterBinding.ensureInitialized(); + final bool isFlutterTest = + !kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; + if (kDebugMode && !isFlutterTest) { + MarionetteBinding.ensureInitialized( + MarionetteConfiguration( + isInteractiveWidget: (Type type) => + type == UiButton || type == UiTextField, + extractText: (Widget widget) { + if (widget is UiTextField) return widget.label; + if (widget is UiButton) return widget.text; + return null; + }, + ), + ); + } else { + WidgetsFlutterBinding.ensureInitialized(); + } await Firebase.initializeApp( options: kIsWeb ? DefaultFirebaseOptions.currentPlatform : null, ); diff --git a/apps/mobile/apps/client/maestro/README.md b/apps/mobile/apps/client/maestro/README.md new file mode 100644 index 00000000..97407ed3 --- /dev/null +++ b/apps/mobile/apps/client/maestro/README.md @@ -0,0 +1,42 @@ +# Maestro Integration Tests — Client App + +Login and signup flows for the KROW Client app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report. +**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md) + +## Prerequisites + +- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed +- Client app built and installed on device/emulator: + ```bash + cd apps/mobile && flutter build apk + adb install build/app/outputs/flutter-apk/app-debug.apk + ``` + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +## Run + +From the project root: + +```bash +# Login +maestro test apps/mobile/apps/client/maestro/login.yaml + +# Signup +maestro test apps/mobile/apps/client/maestro/signup.yaml +``` + +## Flows + +| File | Flow | Description | +|------------|-------------|--------------------------------------------| +| login.yaml | Client Login| Get Started → Sign In → Home | +| signup.yaml| Client Signup| Get Started → Create Account → Home | diff --git a/apps/mobile/apps/client/maestro/login.yaml b/apps/mobile/apps/client/maestro/login.yaml new file mode 100644 index 00000000..6598a03f --- /dev/null +++ b/apps/mobile/apps/client/maestro/login.yaml @@ -0,0 +1,18 @@ +# Client App - Login Flow +# Prerequisites: App built and installed (debug or release) +# Run: maestro test apps/mobile/apps/client/maestro/login.yaml +# Test credentials: legendary@krowd.com / Demo2026! +# Note: Auth uses Firebase/Data Connect + +appId: com.krowwithus.client +--- +- launchApp +- assertVisible: "Sign In" +- tapOn: "Sign In" +- assertVisible: "Email" +- tapOn: "Email" +- inputText: "legendary@krowd.com" +- tapOn: "Password" +- inputText: "Demo2026!" +- tapOn: "Sign In" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/maestro/signup.yaml b/apps/mobile/apps/client/maestro/signup.yaml new file mode 100644 index 00000000..eba61eb0 --- /dev/null +++ b/apps/mobile/apps/client/maestro/signup.yaml @@ -0,0 +1,23 @@ +# Client App - Sign Up Flow +# Prerequisites: App built and installed +# Run: maestro test apps/mobile/apps/client/maestro/signup.yaml +# Use NEW credentials for signup (creates new account) +# Env: MAESTRO_CLIENT_EMAIL, MAESTRO_CLIENT_PASSWORD, MAESTRO_CLIENT_COMPANY + +appId: com.krowwithus.client +--- +- launchApp +- assertVisible: "Create Account" +- tapOn: "Create Account" +- assertVisible: "Company" +- tapOn: "Company" +- inputText: "${MAESTRO_CLIENT_COMPANY}" +- tapOn: "Email" +- inputText: "${MAESTRO_CLIENT_EMAIL}" +- tapOn: "Password" +- inputText: "${MAESTRO_CLIENT_PASSWORD}" +- tapOn: + text: "Confirm Password" +- inputText: "${MAESTRO_CLIENT_PASSWORD}" +- tapOn: "Create Account" +- assertVisible: "Home" diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index b4d6367b..31c14ec3 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: sdk: flutter firebase_core: ^4.4.0 krow_data_connect: ^0.0.1 + marionette_flutter: ^0.3.0 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 2716abc4..1f2dea9f 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -1,7 +1,11 @@ +import 'dart:io' show Platform; + import 'package:core_localization/core_localization.dart' as core_localization; import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; @@ -16,7 +20,23 @@ import 'package:image_picker/image_picker.dart'; import 'src/widgets/session_listener.dart'; void main() async { - WidgetsFlutterBinding.ensureInitialized(); + final bool isFlutterTest = + !kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; + if (kDebugMode && !isFlutterTest) { + MarionetteBinding.ensureInitialized( + MarionetteConfiguration( + isInteractiveWidget: (Type type) => + type == UiButton || type == UiTextField, + extractText: (Widget widget) { + if (widget is UiTextField) return widget.label; + if (widget is UiButton) return widget.text; + return null; + }, + ), + ); + } else { + WidgetsFlutterBinding.ensureInitialized(); + } await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); // Register global BLoC observer for centralized error logging diff --git a/apps/mobile/apps/staff/maestro/README.md b/apps/mobile/apps/staff/maestro/README.md new file mode 100644 index 00000000..505faaec --- /dev/null +++ b/apps/mobile/apps/staff/maestro/README.md @@ -0,0 +1,41 @@ +# Maestro Integration Tests — Staff App + +Login and signup flows for the KROW Staff app. +See [docs/research/flutter-testing-tools.md](/docs/research/flutter-testing-tools.md) for the evaluation report. +**Full run instructions:** [docs/research/maestro-test-run-instructions.md](/docs/research/maestro-test-run-instructions.md) + +## Prerequisites + +- [Maestro CLI](https://maestro.dev/docs/getting-started/installation) installed +- Staff app built and installed +- **Firebase test phone** in Firebase Console (Auth > Sign-in method > Phone): + - Login: +1 555-765-4321 / OTP 123456 + - Signup: add a different test number for new accounts + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +## Run + +From the project root: + +```bash +# Login +maestro test apps/mobile/apps/staff/maestro/login.yaml + +# Signup +maestro test apps/mobile/apps/staff/maestro/signup.yaml +``` + +## Flows + +| File | Flow | Description | +|------------|------------|-------------------------------------| +| login.yaml | Staff Login| Get Started → Log In → Phone → OTP → Home | +| signup.yaml| Staff Signup| Get Started → Sign Up → Phone → OTP → Profile Setup | diff --git a/apps/mobile/apps/staff/maestro/login.yaml b/apps/mobile/apps/staff/maestro/login.yaml new file mode 100644 index 00000000..aa0b21a1 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/login.yaml @@ -0,0 +1,18 @@ +# Staff App - Login Flow (Phone + OTP) +# Prerequisites: App built and installed; Firebase test phone configured +# Firebase test phone: +1 555-765-4321 / OTP 123456 +# Run: maestro test apps/mobile/apps/staff/maestro/login.yaml + +appId: com.krowwithus.staff +--- +- launchApp +- assertVisible: "Log In" +- tapOn: "Log In" +- assertVisible: "Send Code" +- inputText: "5557654321" +- tapOn: "Send Code" +# Wait for OTP screen +- assertVisible: "Continue" +- inputText: "123456" +- tapOn: "Continue" +# On success: staff main. Adjust final assertion to match staff home screen. diff --git a/apps/mobile/apps/staff/maestro/signup.yaml b/apps/mobile/apps/staff/maestro/signup.yaml new file mode 100644 index 00000000..e441e774 --- /dev/null +++ b/apps/mobile/apps/staff/maestro/signup.yaml @@ -0,0 +1,18 @@ +# Staff App - Sign Up Flow (Phone + OTP) +# Prerequisites: App built and installed; Firebase test phone for NEW number +# Use a NEW phone number for signup (creates new account) +# Firebase: add test phone in Auth > Phone; e.g. +1 555-555-0000 / 123456 +# Run: maestro test apps/mobile/apps/staff/maestro/signup.yaml + +appId: com.krowwithus.staff +--- +- launchApp +- assertVisible: "Sign Up" +- tapOn: "Sign Up" +- assertVisible: "Send Code" +- inputText: "${MAESTRO_STAFF_SIGNUP_PHONE}" +- tapOn: "Send Code" +- assertVisible: "Continue" +- inputText: "123456" +- tapOn: "Continue" +# On success: Profile Setup. Adjust assertion to match destination. diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index d3b270ef..4019f01b 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -30,6 +30,7 @@ dependencies: path: ../../packages/core krow_data_connect: path: ../../packages/data_connect + marionette_flutter: ^0.3.0 cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index edb5141e..a3650f69 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -135,6 +135,11 @@ extension ClientNavigator on IModularNavigator { pushNamed(ClientPaths.settings); } + /// Pushes the edit profile page. + void toClientEditProfile() { + pushNamed('${ClientPaths.settings}/edit-profile'); + } + // ========================================================================== // HUBS MANAGEMENT // ========================================================================== @@ -159,6 +164,9 @@ extension ClientNavigator on IModularNavigator { return pushNamed( ClientPaths.editHub, arguments: {'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. ); } diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 75fcb168..bd3e4341 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -208,6 +208,7 @@ "edit_profile": "Edit Profile", "hubs": "Hubs", "log_out": "Log Out", + "log_out_confirmation": "Are you sure you want to log out?", "quick_links": "Quick Links", "clock_in_hubs": "Clock-In Hubs", "billing_payments": "Billing & Payments" @@ -252,6 +253,11 @@ "location_hint": "e.g., Downtown Restaurant", "address_label": "Address", "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", + "address_required": "Address is required", "create_button": "Create Hub" }, "edit_hub": { @@ -261,8 +267,14 @@ "name_hint": "e.g., Main Kitchen, Front Desk", "address_label": "Address", "address_hint": "Full address", + "cost_center_label": "Cost Center", + "cost_center_hint": "eg: 1001, 1002", + "cost_centers_empty": "No cost centers available", + "name_required": "Name is required", "save_button": "Save Changes", - "success": "Hub updated successfully!" + "success": "Hub updated successfully!", + "created_success": "Hub created successfully", + "updated_success": "Hub updated successfully" }, "hub_details": { "title": "Hub Details", @@ -270,7 +282,10 @@ "address_label": "Address", "nfc_label": "NFC Tag", "nfc_not_assigned": "Not Assigned", - "edit_button": "Edit Hub" + "cost_center_label": "Cost Center", + "cost_center_none": "Not Assigned", + "edit_button": "Edit Hub", + "deleted_success": "Hub deleted successfully" }, "nfc_dialog": { "title": "Identify NFC Tag", @@ -326,6 +341,11 @@ "date_hint": "Select date", "location_label": "Location", "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", + "hub_manager_empty": "No hub managers available", + "hub_manager_none": "None", "positions_title": "Positions", "add_position": "Add Position", "position_number": "Position $number", @@ -377,6 +397,41 @@ "active": "Active", "completed": "Completed" }, + "order_edit_sheet": { + "title": "Edit Your Order", + "vendor_section": "VENDOR", + "location_section": "LOCATION", + "shift_contact_section": "SHIFT CONTACT", + "shift_contact_desc": "On-site manager or supervisor for this shift", + "select_contact": "Select Contact", + "no_hub_managers": "No hub managers available", + "none": "None", + "positions_section": "POSITIONS", + "add_position": "Add Position", + "review_positions": "Review $count Positions", + "order_name_hint": "Order name", + "remove": "Remove", + "select_role_hint": "Select role", + "start_label": "Start", + "end_label": "End", + "workers_label": "Workers", + "different_location": "Use different location for this position", + "different_location_title": "Different Location", + "enter_address_hint": "Enter different address", + "no_break": "No Break", + "positions": "Positions", + "workers": "Workers", + "est_cost": "Est. Cost", + "positions_breakdown": "Positions Breakdown", + "edit_button": "Edit", + "confirm_save": "Confirm & Save", + "position_singular": "Position", + "order_updated_title": "Order Updated!", + "order_updated_message": "Your shift has been updated successfully.", + "back_to_orders": "Back to Orders", + "one_time_order_title": "One-Time Order", + "refine_subtitle": "Refine your staffing needs" + }, "card": { "open": "OPEN", "filled": "FILLED", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index b3a1148e..076a4da6 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -208,6 +208,7 @@ "edit_profile": "Editar Perfil", "hubs": "Hubs", "log_out": "Cerrar sesi\u00f3n", + "log_out_confirmation": "\u00bfEst\u00e1 seguro de que desea cerrar sesi\u00f3n?", "quick_links": "Enlaces r\u00e1pidos", "clock_in_hubs": "Hubs de Marcaje", "billing_payments": "Facturaci\u00f3n y Pagos" @@ -252,6 +253,11 @@ "location_hint": "ej., Restaurante Centro", "address_label": "Direcci\u00f3n", "address_hint": "Direcci\u00f3n completa", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "Nombre es obligatorio", + "address_required": "La direcci\u00f3n es obligatoria", "create_button": "Crear Hub" }, "nfc_dialog": { @@ -276,8 +282,14 @@ "name_hint": "Ingresar nombre del hub", "address_label": "Direcci\u00f3n", "address_hint": "Ingresar direcci\u00f3n", + "cost_center_label": "Centro de Costos", + "cost_center_hint": "ej: 1001, 1002", + "cost_centers_empty": "No hay centros de costos disponibles", + "name_required": "El nombre es obligatorio", "save_button": "Guardar Cambios", - "success": "\u00a1Hub actualizado exitosamente!" + "success": "\u00a1Hub actualizado exitosamente!", + "created_success": "Hub creado exitosamente", + "updated_success": "Hub actualizado exitosamente" }, "hub_details": { "title": "Detalles del Hub", @@ -285,7 +297,10 @@ "name_label": "Nombre del Hub", "address_label": "Direcci\u00f3n", "nfc_label": "Etiqueta NFC", - "nfc_not_assigned": "No asignada" + "nfc_not_assigned": "No asignada", + "cost_center_label": "Centro de Costos", + "cost_center_none": "No asignado", + "deleted_success": "Hub eliminado exitosamente" } }, "client_create_order": { @@ -326,6 +341,11 @@ "date_hint": "Seleccionar fecha", "location_label": "Ubicaci\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", + "hub_manager_empty": "No hay contactos de turno disponibles", + "hub_manager_none": "Ninguno", "positions_title": "Posiciones", "add_position": "A\u00f1adir Posici\u00f3n", "position_number": "Posici\u00f3n $number", @@ -377,6 +397,41 @@ "active": "Activos", "completed": "Completados" }, + "order_edit_sheet": { + "title": "Editar Tu Orden", + "vendor_section": "PROVEEDOR", + "location_section": "UBICACI\u00d3N", + "shift_contact_section": "CONTACTO DEL TURNO", + "shift_contact_desc": "Gerente o supervisor en el sitio para este turno", + "select_contact": "Seleccionar Contacto", + "no_hub_managers": "No hay contactos de turno disponibles", + "none": "Ninguno", + "positions_section": "POSICIONES", + "add_position": "A\u00f1adir Posici\u00f3n", + "review_positions": "Revisar $count Posiciones", + "order_name_hint": "Nombre de la orden", + "remove": "Eliminar", + "select_role_hint": "Seleccionar rol", + "start_label": "Inicio", + "end_label": "Fin", + "workers_label": "Trabajadores", + "different_location": "Usar ubicaci\u00f3n diferente para esta posici\u00f3n", + "different_location_title": "Ubicaci\u00f3n Diferente", + "enter_address_hint": "Ingresar direcci\u00f3n diferente", + "no_break": "Sin Descanso", + "positions": "Posiciones", + "workers": "Trabajadores", + "est_cost": "Costo Est.", + "positions_breakdown": "Desglose de Posiciones", + "edit_button": "Editar", + "confirm_save": "Confirmar y Guardar", + "position_singular": "Posici\u00f3n", + "order_updated_title": "\u00a1Orden Actualizada!", + "order_updated_message": "Tu turno ha sido actualizado exitosamente.", + "back_to_orders": "Volver a \u00d3rdenes", + "one_time_order_title": "Orden \u00danica Vez", + "refine_subtitle": "Ajusta tus necesidades de personal" + }, "card": { "open": "ABIERTO", "filled": "LLENO", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart index bc317ea9..c046918c 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart @@ -1,4 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'dart:convert'; import 'package:firebase_data_connect/src/core/ref.dart'; import 'package:http/http.dart' as http; @@ -23,7 +23,25 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { .getTeamHubsByTeamId(teamId: teamId) .execute(); + final QueryResult< + dc.ListTeamHudDepartmentsData, + dc.ListTeamHudDepartmentsVariables + > + deptsResult = await _service.connector.listTeamHudDepartments().execute(); + final Map hubToDept = + {}; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in deptsResult.data.teamHudDepartments) { + if (dep.costCenter != null && + dep.costCenter!.isNotEmpty && + !hubToDept.containsKey(dep.teamHubId)) { + hubToDept[dep.teamHubId] = dep; + } + } + return response.data.teamHubs.map((dc.GetTeamHubsByTeamIdTeamHubs h) { + final dc.ListTeamHudDepartmentsTeamHudDepartments? dept = + hubToDept[h.id]; return Hub( id: h.id, businessId: businessId, @@ -31,6 +49,13 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { address: h.address, nfcTagId: null, status: h.isActive ? HubStatus.active : HubStatus.inactive, + costCenter: dept != null + ? CostCenter( + id: dept.id, + name: dept.name, + code: dept.costCenter ?? dept.name, + ) + : null, ); }).toList(); }); @@ -49,6 +74,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }) async { return _service.run(() async { final String teamId = await _getOrCreateTeamId(businessId); @@ -72,13 +98,27 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { .zipCode(zipCode ?? placeAddress?.zipCode) .execute(); + final String hubId = result.data.teamHub_insert.id; + CostCenter? costCenter; + if (costCenterId != null && costCenterId.isNotEmpty) { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: hubId, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + return Hub( - id: result.data.teamHub_insert.id, + id: hubId, businessId: businessId, name: name, address: address, nfcTagId: null, status: HubStatus.active, + costCenter: costCenter, ); }); } @@ -97,6 +137,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }) async { return _service.run(() async { final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty) @@ -128,7 +169,43 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { await builder.execute(); - // Return a basic hub object reflecting changes (or we could re-fetch) + CostCenter? costCenter; + final QueryResult< + dc.ListTeamHudDepartmentsByTeamHubIdData, + dc.ListTeamHudDepartmentsByTeamHubIdVariables + > + deptsResult = await _service.connector + .listTeamHudDepartmentsByTeamHubId(teamHubId: id) + .execute(); + final List depts = + deptsResult.data.teamHudDepartments; + + if (costCenterId == null || costCenterId.isEmpty) { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(null) + .execute(); + } + } else { + if (depts.isNotEmpty) { + await _service.connector + .updateTeamHudDepartment(id: depts.first.id) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } else { + await _service.connector + .createTeamHudDepartment( + name: costCenterId, + teamHubId: id, + ) + .costCenter(costCenterId) + .execute(); + costCenter = CostCenter(id: costCenterId, name: costCenterId, code: costCenterId); + } + } + return Hub( id: id, businessId: businessId, @@ -136,6 +213,7 @@ class HubsConnectorRepositoryImpl implements HubsConnectorRepository { address: address ?? '', nfcTagId: null, status: HubStatus.active, + costCenter: costCenter, ); }); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart index 28e10e3d..42a83265 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/hubs/domain/repositories/hubs_connector_repository.dart @@ -20,6 +20,7 @@ abstract interface class HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Updates an existing hub. @@ -36,6 +37,7 @@ abstract interface class HubsConnectorRepository { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Deletes a hub. diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index adebada8..87167b9e 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -28,6 +28,7 @@ export 'src/entities/business/business_setting.dart'; export 'src/entities/business/hub.dart'; export 'src/entities/business/hub_department.dart'; export 'src/entities/business/vendor.dart'; +export 'src/entities/business/cost_center.dart'; // Events & Assignments export 'src/entities/events/event.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart new file mode 100644 index 00000000..8d3d5528 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/business/cost_center.dart @@ -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 get props => [id, name, code]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart index 4070a28a..79c06572 100644 --- a/apps/mobile/packages/domain/lib/src/entities/business/hub.dart +++ b/apps/mobile/packages/domain/lib/src/entities/business/hub.dart @@ -1,5 +1,7 @@ import 'package:equatable/equatable.dart'; +import 'cost_center.dart'; + /// The status of a [Hub]. enum HubStatus { /// Fully operational. @@ -14,7 +16,6 @@ enum HubStatus { /// Represents a branch location or operational unit within a [Business]. class Hub extends Equatable { - const Hub({ required this.id, required this.businessId, @@ -22,6 +23,7 @@ class Hub extends Equatable { required this.address, this.nfcTagId, required this.status, + this.costCenter, }); /// Unique identifier. final String id; @@ -41,6 +43,9 @@ class Hub extends Equatable { /// Operational status. final HubStatus status; + /// Assigned cost center for this hub. + final CostCenter? costCenter; + @override - List get props => [id, businessId, name, address, nfcTagId, status]; + List get props => [id, businessId, name, address, nfcTagId, status, costCenter]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart index e0e7ca67..fe50bd20 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/one_time_order.dart @@ -13,6 +13,7 @@ class OneTimeOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); /// The specific date for the shift or event. @@ -33,6 +34,9 @@ class OneTimeOrder extends Equatable { /// Selected vendor id for this order. final String? vendorId; + /// Optional hub manager id. + final String? hubManagerId; + /// Role hourly rates keyed by role id. final Map roleRates; @@ -44,6 +48,7 @@ class OneTimeOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart index b9ab956f..88ae8091 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/order_item.dart @@ -27,6 +27,8 @@ class OrderItem extends Equatable { this.hours = 0, this.totalValue = 0, this.confirmedApps = const >[], + this.hubManagerId, + this.hubManagerName, }); /// Unique identifier of the order. @@ -83,6 +85,12 @@ class OrderItem extends Equatable { /// List of confirmed worker applications. final List> confirmedApps; + /// Optional ID of the assigned hub manager. + final String? hubManagerId; + + /// Optional Name of the assigned hub manager. + final String? hubManagerName; + @override List get props => [ id, @@ -103,5 +111,7 @@ class OrderItem extends Equatable { totalValue, eventName, confirmedApps, + hubManagerId, + hubManagerName, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart index da4feb71..ef950f87 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -11,6 +11,7 @@ class PermanentOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); @@ -23,6 +24,7 @@ class PermanentOrder extends Equatable { final OneTimeOrderHubDetails? hub; final String? eventName; final String? vendorId; + final String? hubManagerId; final Map roleRates; @override @@ -33,6 +35,7 @@ class PermanentOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart index f11b63ec..76f00720 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -12,6 +12,7 @@ class RecurringOrder extends Equatable { this.hub, this.eventName, this.vendorId, + this.hubManagerId, this.roleRates = const {}, }); @@ -39,6 +40,9 @@ class RecurringOrder extends Equatable { /// Selected vendor id for this order. final String? vendorId; + /// Optional hub manager id. + final String? hubManagerId; + /// Role hourly rates keyed by role id. final Map roleRates; @@ -52,6 +56,7 @@ class RecurringOrder extends Equatable { hub, eventName, vendorId, + hubManagerId, roleRates, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 49a88f20..53fdb2e4 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -1,5 +1,6 @@ library; +import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.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/create_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/update_hub_usecase.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart'; @@ -32,6 +34,7 @@ class ClientHubsModule extends Module { // UseCases i.addLazySingleton(GetHubsUseCase.new); + i.addLazySingleton(GetCostCentersUseCase.new); i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new); @@ -61,6 +64,18 @@ class ClientHubsModule extends Module { ); r.child( ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub), + transition: TransitionType.custom, + customTransition: CustomTransition( + opaque: false, + transitionBuilder: ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + return FadeTransition(opacity: animation, child: child); + }, + ), child: (_) { final Map data = r.args.data as Map; return EditHubPage( diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 3e15fa71..ac91ac28 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -1,4 +1,4 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs +// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/hub_repository_interface.dart'; @@ -24,6 +24,24 @@ class HubRepositoryImpl implements HubRepositoryInterface { return _connectorRepository.getHubs(businessId: businessId); } + @override + Future> getCostCenters() async { + return _service.run(() async { + final result = await _service.connector.listTeamHudDepartments().execute(); + final Set seen = {}; + final List costCenters = []; + for (final dc.ListTeamHudDepartmentsTeamHudDepartments dep + in result.data.teamHudDepartments) { + final String? cc = dep.costCenter; + if (cc != null && cc.isNotEmpty && !seen.contains(cc)) { + seen.add(cc); + costCenters.add(CostCenter(id: cc, name: dep.name, code: cc)); + } + } + return costCenters; + }); + } + @override Future createHub({ required String name, @@ -36,6 +54,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }) async { final String businessId = await _service.getBusinessId(); return _connectorRepository.createHub( @@ -50,6 +69,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { street: street, country: country, zipCode: zipCode, + costCenterId: costCenterId, ); } @@ -79,6 +99,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }) async { final String businessId = await _service.getBusinessId(); return _connectorRepository.updateHub( @@ -94,6 +115,7 @@ class HubRepositoryImpl implements HubRepositoryInterface { street: street, country: country, zipCode: zipCode, + costCenterId: costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart index ad6199de..18e6a3fd 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/arguments/create_hub_arguments.dart @@ -19,6 +19,7 @@ class CreateHubArguments extends UseCaseArgument { this.street, this.country, this.zipCode, + this.costCenterId, }); /// The name of the hub. final String name; @@ -34,6 +35,9 @@ class CreateHubArguments extends UseCaseArgument { final String? street; final String? country; final String? zipCode; + + /// The cost center of the hub. + final String? costCenterId; @override List get props => [ @@ -47,5 +51,6 @@ class CreateHubArguments extends UseCaseArgument { street, country, zipCode, + costCenterId, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 0288d180..14e97bf2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -11,6 +11,9 @@ abstract interface class HubRepositoryInterface { /// Returns a list of [Hub] entities. Future> getHubs(); + /// Fetches the list of available cost centers for the current business. + Future> getCostCenters(); + /// Creates a new hub. /// /// Takes the [name] and [address] of the new hub. @@ -26,6 +29,7 @@ abstract interface class HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }); /// Deletes a hub by its [id]. @@ -51,5 +55,6 @@ abstract interface class HubRepositoryInterface { String? street, String? country, String? zipCode, + String? costCenterId, }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart index 9c55ed30..550acd89 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/create_hub_usecase.dart @@ -29,6 +29,7 @@ class CreateHubUseCase implements UseCase { street: arguments.street, country: arguments.country, zipCode: arguments.zipCode, + costCenterId: arguments.costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart new file mode 100644 index 00000000..32f9d895 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/get_cost_centers_usecase.dart @@ -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> call() async { + return _repository.getCostCenters(); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart index 97af203e..cbfdb799 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -17,6 +17,7 @@ class UpdateHubArguments extends UseCaseArgument { this.street, this.country, this.zipCode, + this.costCenterId, }); final String id; @@ -30,6 +31,7 @@ class UpdateHubArguments extends UseCaseArgument { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -44,6 +46,7 @@ class UpdateHubArguments extends UseCaseArgument { street, country, zipCode, + costCenterId, ]; } @@ -67,6 +70,7 @@ class UpdateHubUseCase implements UseCase { street: params.street, country: params.country, zipCode: params.zipCode, + costCenterId: params.costCenterId, ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart index 6923899a..a455c0f3 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart @@ -1,8 +1,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../../domain/arguments/create_hub_arguments.dart'; import '../../../domain/usecases/create_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_state.dart'; @@ -12,15 +14,36 @@ class EditHubBloc extends Bloc EditHubBloc({ required CreateHubUseCase createHubUseCase, required UpdateHubUseCase updateHubUseCase, + required GetCostCentersUseCase getCostCentersUseCase, }) : _createHubUseCase = createHubUseCase, _updateHubUseCase = updateHubUseCase, + _getCostCentersUseCase = getCostCentersUseCase, super(const EditHubState()) { + on(_onCostCentersLoadRequested); on(_onAddRequested); on(_onUpdateRequested); } final CreateHubUseCase _createHubUseCase; final UpdateHubUseCase _updateHubUseCase; + final GetCostCentersUseCase _getCostCentersUseCase; + + Future _onCostCentersLoadRequested( + EditHubCostCentersLoadRequested event, + Emitter emit, + ) async { + await handleError( + emit: emit.call, + action: () async { + final List costCenters = await _getCostCentersUseCase.call(); + emit(state.copyWith(costCenters: costCenters)); + }, + onError: (String errorKey) => state.copyWith( + status: EditHubStatus.failure, + errorMessage: errorKey, + ), + ); + } Future _onAddRequested( EditHubAddRequested event, @@ -43,12 +66,13 @@ class EditHubBloc extends Bloc street: event.street, country: event.country, zipCode: event.zipCode, + costCenterId: event.costCenterId, ), ); emit( state.copyWith( status: EditHubStatus.success, - successMessage: 'Hub created successfully', + successKey: 'created', ), ); }, @@ -79,12 +103,13 @@ class EditHubBloc extends Bloc street: event.street, country: event.country, zipCode: event.zipCode, + costCenterId: event.costCenterId, ), ); emit( state.copyWith( status: EditHubStatus.success, - successMessage: 'Hub updated successfully', + successKey: 'updated', ), ); }, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart index 65e18a83..38e25de0 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart @@ -8,6 +8,11 @@ abstract class EditHubEvent extends Equatable { List get props => []; } +/// Event triggered to load all available cost centers. +class EditHubCostCentersLoadRequested extends EditHubEvent { + const EditHubCostCentersLoadRequested(); +} + /// Event triggered to add a new hub. class EditHubAddRequested extends EditHubEvent { const EditHubAddRequested({ @@ -21,6 +26,7 @@ class EditHubAddRequested extends EditHubEvent { this.street, this.country, this.zipCode, + this.costCenterId, }); final String name; @@ -33,6 +39,7 @@ class EditHubAddRequested extends EditHubEvent { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -46,6 +53,7 @@ class EditHubAddRequested extends EditHubEvent { street, country, zipCode, + costCenterId, ]; } @@ -63,6 +71,7 @@ class EditHubUpdateRequested extends EditHubEvent { this.street, this.country, this.zipCode, + this.costCenterId, }); final String id; @@ -76,6 +85,7 @@ class EditHubUpdateRequested extends EditHubEvent { final String? street; final String? country; final String? zipCode; + final String? costCenterId; @override List get props => [ @@ -90,5 +100,6 @@ class EditHubUpdateRequested extends EditHubEvent { street, country, zipCode, + costCenterId, ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart index 17bdffcd..2c59b055 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Status of the edit hub operation. enum EditHubStatus { @@ -21,6 +22,8 @@ class EditHubState extends Equatable { this.status = EditHubStatus.initial, this.errorMessage, this.successMessage, + this.successKey, + this.costCenters = const [], }); /// The status of the operation. @@ -32,19 +35,35 @@ class EditHubState extends Equatable { /// The success message if the operation succeeded. final String? successMessage; + /// Localization key for success message: 'created' | 'updated'. + final String? successKey; + + /// Available cost centers for selection. + final List costCenters; + /// Create a copy of this state with the given fields replaced. EditHubState copyWith({ EditHubStatus? status, String? errorMessage, String? successMessage, + String? successKey, + List? costCenters, }) { return EditHubState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, + costCenters: costCenters ?? this.costCenters, ); } @override - List get props => [status, errorMessage, successMessage]; + List get props => [ + status, + errorMessage, + successMessage, + successKey, + costCenters, + ]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart index bda30551..4b91b0de 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart @@ -36,7 +36,7 @@ class HubDetailsBloc extends Bloc emit( state.copyWith( status: HubDetailsStatus.deleted, - successMessage: 'Hub deleted successfully', + successKey: 'deleted', ), ); }, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart index f2c7f4c2..17ef70f8 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart @@ -24,6 +24,7 @@ class HubDetailsState extends Equatable { this.status = HubDetailsStatus.initial, this.errorMessage, this.successMessage, + this.successKey, }); /// The status of the operation. @@ -35,19 +36,24 @@ class HubDetailsState extends Equatable { /// The success message if the operation succeeded. final String? successMessage; + /// Localization key for success message: 'deleted'. + final String? successKey; + /// Create a copy of this state with the given fields replaced. HubDetailsState copyWith({ HubDetailsStatus? status, String? errorMessage, String? successMessage, + String? successKey, }) { return HubDetailsState( status: status ?? this.status, errorMessage: errorMessage ?? this.errorMessage, successMessage: successMessage ?? this.successMessage, + successKey: successKey ?? this.successKey, ); } @override - List get props => [status, errorMessage, successMessage]; + List get props => [status, errorMessage, successMessage, successKey]; } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart index 1bcdb4ed..25772bc2 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/client_hubs_page.dart @@ -57,20 +57,6 @@ class ClientHubsPage extends StatelessWidget { builder: (BuildContext context, ClientHubsState state) { return Scaffold( backgroundColor: UiColors.bgMenu, - floatingActionButton: FloatingActionButton( - onPressed: () async { - final bool? success = await Modular.to.toEditHub(); - if (success == true && context.mounted) { - BlocProvider.of( - context, - ).add(const ClientHubsFetched()); - } - }, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - ), - child: const Icon(UiIcons.add), - ), body: CustomScrollView( slivers: [ _buildAppBar(context), @@ -165,20 +151,35 @@ class ClientHubsPage extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_hubs.title, - style: UiTypography.headline1m.white, - ), - Text( - t.client_hubs.subtitle, - style: UiTypography.body2r.copyWith( - color: UiColors.switchInactive, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.title, + style: UiTypography.headline1m.white, ), - ), - ], + Text( + t.client_hubs.subtitle, + style: UiTypography.body2r.copyWith( + color: UiColors.switchInactive, + ), + ), + ], + ), + ), + UiButton.primary( + onPressed: () async { + final bool? success = await Modular.to.toEditHub(); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + text: t.client_hubs.add_hub, + leadingIcon: UiIcons.add, + size: UiButtonSize.small, ), ], ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart index ea547ab2..8bc8373e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -3,15 +3,14 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/edit_hub/edit_hub_bloc.dart'; import '../blocs/edit_hub/edit_hub_event.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 { const EditHubPage({this.hub, required this.bloc, super.key}); @@ -23,66 +22,11 @@ class EditHubPage extends StatefulWidget { } class _EditHubPageState extends State { - final GlobalKey _formKey = GlobalKey(); - late final TextEditingController _nameController; - late final TextEditingController _addressController; - late final FocusNode _addressFocusNode; - Prediction? _selectedPrediction; - @override void initState() { super.initState(); - _nameController = TextEditingController(text: widget.hub?.name); - _addressController = TextEditingController(text: widget.hub?.address); - _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 ?? ''), - ), - ); - } + // Load available cost centers + widget.bloc.add(const EditHubCostCentersLoadRequested()); } @override @@ -91,17 +35,18 @@ class _EditHubPageState extends State { value: widget.bloc, child: BlocListener( listenWhen: (EditHubState prev, EditHubState curr) => - prev.status != curr.status || - prev.successMessage != curr.successMessage, + prev.status != curr.status || prev.successKey != curr.successKey, listener: (BuildContext context, EditHubState state) { if (state.status == EditHubStatus.success && - state.successMessage != null) { + state.successKey != null) { + final String message = state.successKey == 'created' + ? t.client_hubs.edit_hub.created_success + : t.client_hubs.edit_hub.updated_success; UiSnackbar.show( context, - message: state.successMessage!, + message: message, type: UiSnackbarType.success, ); - // Pop back to the previous screen. Modular.to.pop(true); } if (state.status == EditHubStatus.failure && @@ -118,42 +63,59 @@ class _EditHubPageState extends State { final bool isSaving = state.status == EditHubStatus.loading; return Scaffold( - backgroundColor: UiColors.bgMenu, - 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(), - ), + backgroundColor: UiColors.bgOverlay, body: Stack( children: [ - SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: EditHubFormSection( - formKey: _formKey, - nameController: _nameController, - addressController: _addressController, - addressFocusNode: _addressFocusNode, - onAddressSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, - onSave: _onSave, - isSaving: isSaving, - isEdit: widget.hub != null, - ), - ), - ], + // Tap background to dismiss + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container(color: Colors.transparent), + ), + + // Dialog-style content centered + Align( + alignment: Alignment.center, + child: HubFormDialog( + hub: widget.hub, + costCenters: state.costCenters, + 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, + ), + ); + } + }, ), ), - // ── Loading overlay ────────────────────────────────────── + // Global loading overlay if saving if (isSaving) Container( color: UiColors.black.withValues(alpha: 0.1), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index cbcf5d61..16861eb5 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -29,9 +29,12 @@ class HubDetailsPage extends StatelessWidget { child: BlocListener( listener: (BuildContext context, HubDetailsState state) { if (state.status == HubDetailsStatus.deleted) { + final String message = state.successKey == 'deleted' + ? t.client_hubs.hub_details.deleted_success + : (state.successMessage ?? t.client_hubs.hub_details.deleted_success); UiSnackbar.show( context, - message: state.successMessage ?? 'Hub deleted successfully', + message: message, type: UiSnackbarType.success, ); Modular.to.pop(true); // Return true to indicate change @@ -80,6 +83,15 @@ class HubDetailsPage extends StatelessWidget { icon: UiIcons.nfc, 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, + ), ], ), ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart index b874dd3b..3a6e24f6 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart @@ -2,6 +2,7 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../hub_address_autocomplete.dart'; import 'edit_hub_field_label.dart'; @@ -15,6 +16,9 @@ class EditHubFormSection extends StatelessWidget { required this.addressFocusNode, required this.onAddressSelected, required this.onSave, + this.costCenters = const [], + this.selectedCostCenterId, + required this.onCostCenterChanged, this.isSaving = false, this.isEdit = false, super.key, @@ -26,6 +30,9 @@ class EditHubFormSection extends StatelessWidget { final FocusNode addressFocusNode; final ValueChanged onAddressSelected; final VoidCallback onSave; + final List costCenters; + final String? selectedCostCenterId; + final ValueChanged onCostCenterChanged; final bool isSaving; final bool isEdit; @@ -44,7 +51,7 @@ class EditHubFormSection extends StatelessWidget { textInputAction: TextInputAction.next, validator: (String? value) { if (value == null || value.trim().isEmpty) { - return 'Name is required'; + return t.client_hubs.edit_hub.name_required; } return null; }, @@ -62,6 +69,51 @@ class EditHubFormSection extends StatelessWidget { 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: [ + 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), // ── 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 _showCostCenterSelector(BuildContext context) async { + final CostCenter? selected = await showDialog( + 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 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.edit_hub.cost_centers_empty), + ) + : 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); + } + } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart index 66f14d11..ee196446 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_address_autocomplete.dart @@ -11,6 +11,7 @@ class HubAddressAutocomplete extends StatelessWidget { required this.controller, required this.hintText, this.focusNode, + this.decoration, this.onSelected, super.key, }); @@ -18,6 +19,7 @@ class HubAddressAutocomplete extends StatelessWidget { final TextEditingController controller; final String hintText; final FocusNode? focusNode; + final InputDecoration? decoration; final void Function(Prediction prediction)? onSelected; @override @@ -25,6 +27,7 @@ class HubAddressAutocomplete extends StatelessWidget { return GooglePlaceAutoCompleteTextField( textEditingController: controller, focusNode: focusNode, + inputDecoration: decoration ?? const InputDecoration(), googleAPIKey: AppConfig.googleMapsApiKey, debounceTime: 500, countries: HubsConstants.supportedCountries, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart index 7a4d0cd7..25d5f4b0 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart @@ -5,25 +5,30 @@ import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.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 { - /// Creates a [HubFormDialog]. const HubFormDialog({ required this.onSave, required this.onCancel, this.hub, + this.costCenters = const [], super.key, }); /// The hub to edit. If null, a new hub is created. final Hub? hub; + /// Available cost centers for selection. + final List costCenters; + /// Callback when the "Save" button is pressed. - final void Function( - String name, - String address, { + final void Function({ + required String name, + required String address, + String? costCenterId, String? placeId, double? latitude, double? longitude, @@ -40,6 +45,7 @@ class _HubFormDialogState extends State { late final TextEditingController _nameController; late final TextEditingController _addressController; late final FocusNode _addressFocusNode; + String? _selectedCostCenterId; Prediction? _selectedPrediction; @override @@ -48,6 +54,7 @@ class _HubFormDialogState extends State { _nameController = TextEditingController(text: widget.hub?.name); _addressController = TextEditingController(text: widget.hub?.address); _addressFocusNode = FocusNode(); + _selectedCostCenterId = widget.hub?.costCenter?.id; } @override @@ -63,102 +70,193 @@ class _HubFormDialogState extends State { @override Widget build(BuildContext context) { final bool isEditing = widget.hub != null; - final String title = isEditing - ? 'Edit Hub' // TODO: localize + final String title = isEditing + ? t.client_hubs.edit_hub.title : t.client_hubs.add_hub_dialog.title; - + final String buttonText = isEditing - ? 'Save Changes' // TODO: localize + ? t.client_hubs.edit_hub.save_button : t.client_hubs.add_hub_dialog.create_button; - return Container( - color: UiColors.bgOverlay, - child: Center( - child: SingleChildScrollView( - child: Container( - width: MediaQuery.of(context).size.width * 0.9, - padding: const EdgeInsets.all(UiConstants.space5), - decoration: BoxDecoration( - color: UiColors.bgPopup, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow(color: UiColors.popupShadow, blurRadius: 20), - ], + return Center( + child: Container( + margin: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 3), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.15), + blurRadius: 30, + offset: const Offset(0, 10), ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - title, - style: UiTypography.headline3m.textPrimary, + ], + ), + padding: const EdgeInsets.all(UiConstants.space6), + child: SingleChildScrollView( + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: UiTypography.headline3m.textPrimary.copyWith( + fontSize: 20, ), - const SizedBox(height: UiConstants.space5), - _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), - TextFormField( - controller: _nameController, - style: UiTypography.body1r.textPrimary, - validator: (String? value) { - if (value == null || value.trim().isEmpty) { - return 'Name is required'; - } - return null; - }, - decoration: _buildInputDecoration( - t.client_hubs.add_hub_dialog.name_hint, + ), + const SizedBox(height: UiConstants.space5), + + // ── Hub Name ──────────────────────────────── + EditHubFieldLabel(t.client_hubs.add_hub_dialog.name_label), + const SizedBox(height: UiConstants.space2), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return t.client_hubs.add_hub_dialog.name_required; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── 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: [ + 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), - _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.add_hub_dialog.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, + ), + + const SizedBox(height: UiConstants.space4), + + // ── Address ───────────────────────────────── + EditHubFieldLabel(t.client_hubs.add_hub_dialog.address_label), + const SizedBox(height: UiConstants.space2), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.address_hint, ), - const SizedBox(height: UiConstants.space8), - Row( - children: [ - Expanded( - child: UiButton.secondary( - onPressed: widget.onCancel, - text: t.common.cancel, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + + const SizedBox(height: UiConstants.space8), + + // ── Buttons ───────────────────────────────── + Row( + children: [ + Expanded( + 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, + text: t.common.cancel, ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: UiButton.primary( - onPressed: () { - if (_formKey.currentState!.validate()) { - if (_addressController.text.trim().isEmpty) { - UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); - return; - } - - widget.onSave( - _nameController.text, - _addressController.text, - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse( - _selectedPrediction?.lat ?? '', - ), - longitude: double.tryParse( - _selectedPrediction?.lng ?? '', - ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase * 1.5, + ), + ), + ), + onPressed: () { + if (_formKey.currentState!.validate()) { + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show( + context, + message: t.client_hubs.add_hub_dialog.address_required, + type: UiSnackbarType.error, ); + return; } - }, - text: buttonText, - ), + + widget.onSave( + name: _nameController.text.trim(), + address: _addressController.text.trim(), + costCenterId: _selectedCostCenterId, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: buttonText, ), - ], - ), - ], - ), + ), + ], + ), + ], ), ), ), @@ -166,35 +264,87 @@ class _HubFormDialogState extends State { ); } - Widget _buildFieldLabel(String label) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Text(label, style: UiTypography.body2m.textPrimary), - ); - } - InputDecoration _buildInputDecoration(String hint) { return InputDecoration( hintText: hint, - hintStyle: UiTypography.body2r.textPlaceholder, + hintStyle: UiTypography.body2r.textPlaceholder.copyWith( + color: UiColors.textSecondary.withValues(alpha: 0.5), + ), filled: true, - fillColor: UiColors.input, + fillColor: const Color(0xFFF8FAFD), contentPadding: const EdgeInsets.symmetric( horizontal: UiConstants.space4, - vertical: 14, + vertical: 16, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + borderSide: BorderSide(color: UiColors.primary.withValues(alpha: 0.1)), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.ring, width: 2), + borderRadius: BorderRadius.circular(UiConstants.radiusBase * 1.5), + 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 _showCostCenterSelector() async { + final CostCenter? selected = await showDialog( + 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 + ? Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Text(t.client_hubs.add_hub_dialog.cost_centers_empty), + ) + : 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; + }); + } + } } diff --git a/apps/mobile/packages/features/client/orders/analyze.txt b/apps/mobile/packages/features/client/orders/analyze.txt new file mode 100644 index 00000000..28d6d1d5 Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze.txt differ diff --git a/apps/mobile/packages/features/client/orders/analyze_output.txt b/apps/mobile/packages/features/client/orders/analyze_output.txt new file mode 100644 index 00000000..53f8069c Binary files /dev/null and b/apps/mobile/packages/features/client/orders/analyze_output.txt differ diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart index 9b9891b4..a255fe7d 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_bloc.dart @@ -31,6 +31,8 @@ class OneTimeOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -134,6 +136,43 @@ class OneTimeOrderBloc extends Bloc } } + Future _loadManagersForHub( + String hubId, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + OneTimeOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) { + add(const OneTimeOrderManagersLoaded([])); + }, + ); + + if (managers != null) { + add(OneTimeOrderManagersLoaded(managers)); + } + } + + Future _onVendorsLoaded( OneTimeOrderVendorsLoaded event, Emitter emit, @@ -171,15 +210,36 @@ class OneTimeOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id); + } } + void _onHubChanged( OneTimeOrderHubChanged event, Emitter emit, ) { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id); } + void _onHubManagerChanged( + OneTimeOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + OneTimeOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + void _onEventNameChanged( OneTimeOrderEventNameChanged event, Emitter emit, @@ -267,6 +327,7 @@ class OneTimeOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createOneTimeOrderUseCase(OneTimeOrderArguments(order: order)); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart index b6255dab..b64f0542 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_event.dart @@ -89,3 +89,21 @@ class OneTimeOrderInitialized extends OneTimeOrderEvent { @override List get props => [data]; } + +class OneTimeOrderHubManagerChanged extends OneTimeOrderEvent { + const OneTimeOrderHubManagerChanged(this.manager); + final OneTimeOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class OneTimeOrderManagersLoaded extends OneTimeOrderEvent { + const OneTimeOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart index d21bbfc3..b48b9134 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/one_time_order/one_time_order_state.dart @@ -16,6 +16,8 @@ class OneTimeOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory OneTimeOrderState.initial() { @@ -29,6 +31,7 @@ class OneTimeOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } final DateTime date; @@ -42,6 +45,8 @@ class OneTimeOrderState extends Equatable { final List hubs; final OneTimeOrderHubOption? selectedHub; final List roles; + final List managers; + final OneTimeOrderManagerOption? selectedManager; OneTimeOrderState copyWith({ DateTime? date, @@ -55,6 +60,8 @@ class OneTimeOrderState extends Equatable { List? hubs, OneTimeOrderHubOption? selectedHub, List? roles, + List? managers, + OneTimeOrderManagerOption? selectedManager, }) { return OneTimeOrderState( date: date ?? this.date, @@ -68,6 +75,8 @@ class OneTimeOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -98,6 +107,8 @@ class OneTimeOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -158,3 +169,17 @@ class OneTimeOrderRoleOption extends Equatable { @override List get props => [id, name, costPerHour]; } + +class OneTimeOrderManagerOption extends Equatable { + const OneTimeOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart index 6f173604..5c0c34af 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_bloc.dart @@ -31,6 +31,8 @@ class PermanentOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -182,6 +184,10 @@ class PermanentOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + _loadManagersForHub(selectedHub.id, emit); + } } void _onHubChanged( @@ -189,8 +195,61 @@ class PermanentOrderBloc extends Bloc Emitter emit, ) { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + _loadManagersForHub(event.hub.id, emit); } + void _onHubManagerChanged( + PermanentOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + PermanentOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + PermanentOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } + } + + void _onEventNameChanged( PermanentOrderEventNameChanged event, Emitter emit, @@ -330,6 +389,7 @@ class PermanentOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createPermanentOrderUseCase(order); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart index 28dcbcd3..f194618c 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_event.dart @@ -106,3 +106,20 @@ class PermanentOrderInitialized extends PermanentOrderEvent { @override List get props => [data]; } + +class PermanentOrderHubManagerChanged extends PermanentOrderEvent { + const PermanentOrderHubManagerChanged(this.manager); + final PermanentOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class PermanentOrderManagersLoaded extends PermanentOrderEvent { + const PermanentOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart index 38dc743e..4cd04e66 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/permanent_order/permanent_order_state.dart @@ -18,6 +18,8 @@ class PermanentOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory PermanentOrderState.initial() { @@ -45,6 +47,7 @@ class PermanentOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } @@ -61,6 +64,8 @@ class PermanentOrderState extends Equatable { final List hubs; final PermanentOrderHubOption? selectedHub; final List roles; + final List managers; + final PermanentOrderManagerOption? selectedManager; PermanentOrderState copyWith({ DateTime? startDate, @@ -76,6 +81,8 @@ class PermanentOrderState extends Equatable { List? hubs, PermanentOrderHubOption? selectedHub, List? roles, + List? managers, + PermanentOrderManagerOption? selectedManager, }) { return PermanentOrderState( startDate: startDate ?? this.startDate, @@ -91,6 +98,8 @@ class PermanentOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -124,6 +133,8 @@ class PermanentOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -185,6 +196,20 @@ class PermanentOrderRoleOption extends Equatable { List get props => [id, name, costPerHour]; } +class PermanentOrderManagerOption extends Equatable { + const PermanentOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + class PermanentOrderPosition extends Equatable { const PermanentOrderPosition({ required this.role, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart index 0673531e..2c51fef9 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_bloc.dart @@ -32,6 +32,8 @@ class RecurringOrderBloc extends Bloc on(_onPositionUpdated); on(_onSubmitted); on(_onInitialized); + on(_onHubManagerChanged); + on(_onManagersLoaded); _loadVendors(); _loadHubs(); @@ -169,10 +171,10 @@ class RecurringOrderBloc extends Bloc await _loadRolesForVendor(event.vendor.id, emit); } - void _onHubsLoaded( + Future _onHubsLoaded( RecurringOrderHubsLoaded event, Emitter emit, - ) { + ) async { final RecurringOrderHubOption? selectedHub = event.hubs.isNotEmpty ? event.hubs.first : null; @@ -183,13 +185,69 @@ class RecurringOrderBloc extends Bloc location: selectedHub?.name ?? '', ), ); + + if (selectedHub != null) { + await _loadManagersForHub(selectedHub.id, emit); + } } - void _onHubChanged( + Future _onHubChanged( RecurringOrderHubChanged event, Emitter emit, - ) { + ) async { emit(state.copyWith(selectedHub: event.hub, location: event.hub.name)); + await _loadManagersForHub(event.hub.id, emit); + } + + void _onHubManagerChanged( + RecurringOrderHubManagerChanged event, + Emitter emit, + ) { + emit(state.copyWith(selectedManager: event.manager)); + } + + void _onManagersLoaded( + RecurringOrderManagersLoaded event, + Emitter emit, + ) { + emit(state.copyWith(managers: event.managers)); + } + + Future _loadManagersForHub( + String hubId, + Emitter emit, + ) async { + final List? managers = + await handleErrorWithResult( + action: () async { + final fdc.QueryResult result = + await _service.connector.listTeamMembers().execute(); + + return result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).value == + dc.TeamMemberRole.MANAGER, + ) + .map( + (dc.ListTeamMembersTeamMembers member) => + RecurringOrderManagerOption( + id: member.id, + name: member.user.fullName ?? 'Unknown', + ), + ) + .toList(); + }, + onError: (_) => emit( + state.copyWith(managers: const []), + ), + ); + + if (managers != null) { + emit(state.copyWith(managers: managers, selectedManager: null)); + } } void _onEventNameChanged( @@ -349,6 +407,7 @@ class RecurringOrderBloc extends Bloc ), eventName: state.eventName, vendorId: state.selectedVendor?.id, + hubManagerId: state.selectedManager?.id, roleRates: roleRates, ); await _createRecurringOrderUseCase(order); diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart index a04dbdbb..779e97cf 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_event.dart @@ -115,3 +115,20 @@ class RecurringOrderInitialized extends RecurringOrderEvent { @override List get props => [data]; } + +class RecurringOrderHubManagerChanged extends RecurringOrderEvent { + const RecurringOrderHubManagerChanged(this.manager); + final RecurringOrderManagerOption? manager; + + @override + List get props => [manager]; +} + +class RecurringOrderManagersLoaded extends RecurringOrderEvent { + const RecurringOrderManagersLoaded(this.managers); + final List managers; + + @override + List get props => [managers]; +} + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart index 626beae8..8a22eb64 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/blocs/recurring_order/recurring_order_state.dart @@ -19,6 +19,8 @@ class RecurringOrderState extends Equatable { this.hubs = const [], this.selectedHub, this.roles = const [], + this.managers = const [], + this.selectedManager, }); factory RecurringOrderState.initial() { @@ -47,6 +49,7 @@ class RecurringOrderState extends Equatable { vendors: const [], hubs: const [], roles: const [], + managers: const [], ); } @@ -64,6 +67,8 @@ class RecurringOrderState extends Equatable { final List hubs; final RecurringOrderHubOption? selectedHub; final List roles; + final List managers; + final RecurringOrderManagerOption? selectedManager; RecurringOrderState copyWith({ DateTime? startDate, @@ -80,6 +85,8 @@ class RecurringOrderState extends Equatable { List? hubs, RecurringOrderHubOption? selectedHub, List? roles, + List? managers, + RecurringOrderManagerOption? selectedManager, }) { return RecurringOrderState( startDate: startDate ?? this.startDate, @@ -96,6 +103,8 @@ class RecurringOrderState extends Equatable { hubs: hubs ?? this.hubs, selectedHub: selectedHub ?? this.selectedHub, roles: roles ?? this.roles, + managers: managers ?? this.managers, + selectedManager: selectedManager ?? this.selectedManager, ); } @@ -132,6 +141,8 @@ class RecurringOrderState extends Equatable { hubs, selectedHub, roles, + managers, + selectedManager, ]; } @@ -193,6 +204,20 @@ class RecurringOrderRoleOption extends Equatable { List get props => [id, name, costPerHour]; } +class RecurringOrderManagerOption extends Equatable { + const RecurringOrderManagerOption({ + required this.id, + required this.name, + }); + + final String id; + final String name; + + @override + List get props => [id, name]; +} + + class RecurringOrderPosition extends Equatable { const RecurringOrderPosition({ required this.role, diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart index 899e787b..8c8f0e3f 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/one_time_order_page.dart @@ -48,6 +48,10 @@ class OneTimeOrderPage extends StatelessWidget { hubs: state.hubs.map(_mapHub).toList(), positions: state.positions.map(_mapPosition).toList(), roles: state.roles.map(_mapRole).toList(), + selectedHubManager: state.selectedManager != null + ? _mapManager(state.selectedManager!) + : null, + hubManagers: state.managers.map(_mapManager).toList(), isValid: state.isValid, onEventNameChanged: (String val) => bloc.add(OneTimeOrderEventNameChanged(val)), @@ -61,6 +65,17 @@ class OneTimeOrderPage extends StatelessWidget { ); 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()), onPositionUpdated: (int index, OrderPositionUiModel val) { final OneTimeOrderPosition original = state.positions[index]; @@ -130,4 +145,9 @@ class OneTimeOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak, ); } + + OrderManagerUiModel _mapManager(OneTimeOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } + diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart index 2fb67a03..26109e7a 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/permanent_order_page.dart @@ -42,6 +42,10 @@ class PermanentOrderPage extends StatelessWidget { ? _mapHub(state.selectedHub!) : null, 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(), roles: state.roles.map(_mapRole).toList(), isValid: state.isValid, @@ -59,6 +63,17 @@ class PermanentOrderPage extends StatelessWidget { ); 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: () => bloc.add(const PermanentOrderPositionAdded()), onPositionUpdated: (int index, OrderPositionUiModel val) { @@ -181,4 +196,8 @@ class PermanentOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak ?? 'NO_BREAK', ); } + + OrderManagerUiModel _mapManager(PermanentOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } diff --git a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart index 6954e826..c65c26a3 100644 --- a/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart +++ b/apps/mobile/packages/features/client/orders/create_order/lib/src/presentation/pages/recurring_order_page.dart @@ -43,6 +43,10 @@ class RecurringOrderPage extends StatelessWidget { ? _mapHub(state.selectedHub!) : null, 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(), roles: state.roles.map(_mapRole).toList(), isValid: state.isValid, @@ -62,6 +66,17 @@ class RecurringOrderPage extends StatelessWidget { ); 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: () => bloc.add(const RecurringOrderPositionAdded()), onPositionUpdated: (int index, OrderPositionUiModel val) { @@ -193,4 +208,8 @@ class RecurringOrderPage extends StatelessWidget { lunchBreak: pos.lunchBreak ?? 'NO_BREAK', ); } + + OrderManagerUiModel _mapManager(RecurringOrderManagerOption manager) { + return OrderManagerUiModel(id: manager.id, name: manager.name); + } } diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart new file mode 100644 index 00000000..185b9bef --- /dev/null +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/hub_manager_selector.dart @@ -0,0 +1,167 @@ +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, + this.noManagersText, + this.noneText, + super.key, + }); + + final List managers; + final OrderManagerUiModel? selectedManager; + final ValueChanged onChanged; + final String hintText; + final String label; + final String? description; + final String? noManagersText; + final String? noneText; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + label, + style: UiTypography.body1m.textPrimary, + ), + if (description != null) ...[ + 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: [ + Row( + children: [ + 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 _showSelector(BuildContext context) async { + final OrderManagerUiModel? selected = await showDialog( + 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) { + final String emptyText = noManagersText ?? 'No hub managers available'; + final String noneLabel = noneText ?? 'None'; + if (managers.isEmpty) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(emptyText), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + if (index == managers.length) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(noneLabel, style: UiTypography.body1m.textSecondary), + onTap: () => Navigator.of(context).pop( + OrderManagerUiModel(id: 'NONE', name: noneLabel), + ), + ); + } + + 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); + } + } + } +} diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart index ba891dcc..4abe0eae 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/one_time_order/one_time_order_view.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'one_time_order_date_picker.dart'; import 'one_time_order_event_name_input.dart'; import 'one_time_order_header.dart'; @@ -23,11 +24,14 @@ class OneTimeOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, required this.onDateChanged, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -47,12 +51,15 @@ class OneTimeOrderView extends StatelessWidget { final List hubs; final List positions; final List roles; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; final bool isValid; final ValueChanged onEventNameChanged; final ValueChanged onVendorChanged; final ValueChanged onDateChanged; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -143,12 +150,15 @@ class OneTimeOrderView extends StatelessWidget { date: date, selectedHub: selectedHub, hubs: hubs, + selectedHubManager: selectedHubManager, + hubManagers: hubManagers, positions: positions, roles: roles, onEventNameChanged: onEventNameChanged, onVendorChanged: onVendorChanged, onDateChanged: onDateChanged, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, @@ -179,12 +189,15 @@ class _OneTimeOrderForm extends StatelessWidget { required this.date, required this.selectedHub, required this.hubs, + required this.selectedHubManager, + required this.hubManagers, required this.positions, required this.roles, required this.onEventNameChanged, required this.onVendorChanged, required this.onDateChanged, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -196,6 +209,8 @@ class _OneTimeOrderForm extends StatelessWidget { final DateTime date; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; @@ -203,6 +218,7 @@ class _OneTimeOrderForm extends StatelessWidget { final ValueChanged onVendorChanged; final ValueChanged onDateChanged; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -310,6 +326,18 @@ 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, + noManagersText: labels.hub_manager_empty, + noneText: labels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), OneTimeOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart index 48931710..ea6680af 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/order_ui_models.dart @@ -94,3 +94,19 @@ class OrderPositionUiModel extends Equatable { @override List get props => [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 get props => [id, name, phone]; +} + diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart index c33d3641..abcf7a20 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/permanent_order/permanent_order_view.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart' show Vendor; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'permanent_order_date_picker.dart'; import 'permanent_order_event_name_input.dart'; import 'permanent_order_header.dart'; @@ -24,12 +25,15 @@ class PermanentOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, required this.onStartDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -48,6 +52,8 @@ class PermanentOrderView extends StatelessWidget { final List permanentDays; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; final bool isValid; @@ -57,6 +63,7 @@ class PermanentOrderView extends StatelessWidget { final ValueChanged onStartDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -156,9 +163,12 @@ class PermanentOrderView extends StatelessWidget { onStartDateChanged: onStartDateChanged, onDayToggled: onDayToggled, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), if (status == OrderFormStatus.loading) const Center(child: CircularProgressIndicator()), @@ -194,9 +204,12 @@ class _PermanentOrderForm extends StatelessWidget { required this.onStartDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, }); final String eventName; @@ -214,10 +227,14 @@ class _PermanentOrderForm extends StatelessWidget { final ValueChanged onStartDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + @override Widget build(BuildContext context) { final TranslationsClientCreateOrderPermanentEn labels = @@ -331,6 +348,18 @@ 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, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), PermanentOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart index 18c01872..fbc00c07 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart +++ b/apps/mobile/packages/features/client/orders/orders_common/lib/src/presentation/widgets/recurring_order/recurring_order_view.dart @@ -3,6 +3,7 @@ import 'package:krow_domain/krow_domain.dart' show Vendor; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import '../order_ui_models.dart'; +import '../hub_manager_selector.dart'; import 'recurring_order_date_picker.dart'; import 'recurring_order_event_name_input.dart'; import 'recurring_order_header.dart'; @@ -25,6 +26,8 @@ class RecurringOrderView extends StatelessWidget { required this.hubs, required this.positions, required this.roles, + required this.hubManagers, + required this.selectedHubManager, required this.isValid, required this.onEventNameChanged, required this.onVendorChanged, @@ -32,6 +35,7 @@ class RecurringOrderView extends StatelessWidget { required this.onEndDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, @@ -51,6 +55,8 @@ class RecurringOrderView extends StatelessWidget { final List recurringDays; final OrderHubUiModel? selectedHub; final List hubs; + final OrderManagerUiModel? selectedHubManager; + final List hubManagers; final List positions; final List roles; final bool isValid; @@ -61,6 +67,7 @@ class RecurringOrderView extends StatelessWidget { final ValueChanged onEndDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; @@ -165,9 +172,12 @@ class RecurringOrderView extends StatelessWidget { onEndDateChanged: onEndDateChanged, onDayToggled: onDayToggled, onHubChanged: onHubChanged, + onHubManagerChanged: onHubManagerChanged, onPositionAdded: onPositionAdded, onPositionUpdated: onPositionUpdated, onPositionRemoved: onPositionRemoved, + hubManagers: hubManagers, + selectedHubManager: selectedHubManager, ), if (status == OrderFormStatus.loading) const Center(child: CircularProgressIndicator()), @@ -205,9 +215,12 @@ class _RecurringOrderForm extends StatelessWidget { required this.onEndDateChanged, required this.onDayToggled, required this.onHubChanged, + required this.onHubManagerChanged, required this.onPositionAdded, required this.onPositionUpdated, required this.onPositionRemoved, + required this.hubManagers, + required this.selectedHubManager, }); final String eventName; @@ -227,10 +240,15 @@ class _RecurringOrderForm extends StatelessWidget { final ValueChanged onEndDateChanged; final ValueChanged onDayToggled; final ValueChanged onHubChanged; + final ValueChanged onHubManagerChanged; final VoidCallback onPositionAdded; final void Function(int index, OrderPositionUiModel position) onPositionUpdated; final void Function(int index) onPositionRemoved; + final List hubManagers; + final OrderManagerUiModel? selectedHubManager; + + @override Widget build(BuildContext context) { final TranslationsClientCreateOrderRecurringEn labels = @@ -351,6 +369,18 @@ 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, + noManagersText: oneTimeLabels.hub_manager_empty, + noneText: oneTimeLabels.hub_manager_none, + managers: hubManagers, + selectedManager: selectedHubManager, + onChanged: onHubManagerChanged, + ), const SizedBox(height: UiConstants.space6), RecurringOrderSectionHeader( diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart index 5d1606fa..a8cd6843 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/order_edit_sheet.dart @@ -1,3 +1,4 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:firebase_auth/firebase_auth.dart' as firebase; import 'package:firebase_data_connect/firebase_data_connect.dart'; @@ -57,6 +58,9 @@ class OrderEditSheetState extends State { const []; dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; + List _managers = const []; + dc.ListTeamMembersTeamMembers? _selectedManager; + String? _shiftId; List<_ShiftRoleKey> _originalShiftRoles = const <_ShiftRoleKey>[]; @@ -246,6 +250,9 @@ class OrderEditSheetState extends State { } }); } + if (selected != null) { + await _loadManagersForHub(selected.id, widget.order.hubManagerId); + } } catch (_) { if (mounted) { setState(() { @@ -331,6 +338,47 @@ class OrderEditSheetState extends State { } } + Future _loadManagersForHub(String hubId, [String? preselectedId]) async { + try { + final QueryResult result = + await _dataConnect.listTeamMembers().execute(); + + final List hubManagers = result.data.teamMembers + .where( + (dc.ListTeamMembersTeamMembers member) => + member.teamHubId == hubId && + member.role is dc.Known && + (member.role as dc.Known).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 []; + _selectedManager = null; + }); + } + } + } + Map _emptyPosition() { return { 'shiftId': _shiftId, @@ -639,7 +687,7 @@ class OrderEditSheetState extends State { padding: const EdgeInsets.all(UiConstants.space5), children: [ Text( - 'Edit Your Order', + t.client_view_orders.order_edit_sheet.title, style: UiTypography.headline3m.textPrimary, ), const SizedBox(height: UiConstants.space4), @@ -697,7 +745,7 @@ class OrderEditSheetState extends State { _buildSectionHeader('ORDER NAME'), UiTextField( controller: _orderNameController, - hintText: 'Order name', + hintText: t.client_view_orders.order_edit_sheet.order_name_hint, prefixIcon: UiIcons.briefcase, ), const SizedBox(height: UiConstants.space4), @@ -744,13 +792,17 @@ class OrderEditSheetState extends State { ), ), ), + const SizedBox(height: UiConstants.space4), + + _buildHubManagerSelector(), + const SizedBox(height: UiConstants.space6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( - 'POSITIONS', + t.client_view_orders.order_edit_sheet.positions_section, style: UiTypography.headline4m.textPrimary, ), TextButton( @@ -770,7 +822,7 @@ class OrderEditSheetState extends State { color: UiColors.primary, ), Text( - 'Add Position', + t.client_view_orders.order_edit_sheet.add_position, style: UiTypography.body2m.primary, ), ], @@ -791,7 +843,7 @@ class OrderEditSheetState extends State { ), ), _buildBottomAction( - label: 'Review ${_positions.length} Positions', + label: t.client_view_orders.order_edit_sheet.review_positions(count: _positions.length.toString()), onPressed: () => setState(() => _showReview = true), ), const Padding( @@ -807,6 +859,132 @@ class OrderEditSheetState extends State { ); } + Widget _buildHubManagerSelector() { + final TranslationsClientViewOrdersOrderEditSheetEn oes = + t.client_view_orders.order_edit_sheet; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionHeader(oes.shift_contact_section), + Text(oes.shift_contact_desc, 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: [ + Row( + children: [ + Icon( + UiIcons.user, + color: _selectedManager != null + ? UiColors.primary + : UiColors.iconSecondary, + size: 20, + ), + const SizedBox(width: UiConstants.space3), + Text( + _selectedManager?.user.fullName ?? oes.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 _showHubManagerSelector() async { + final dc.ListTeamMembersTeamMembers? selected = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + title: Text( + t.client_view_orders.order_edit_sheet.shift_contact_section, + 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 Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), + child: Text(t.client_view_orders.order_edit_sheet.no_hub_managers), + ); + } + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24), + title: Text(t.client_view_orders.order_edit_sheet.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(t.client_view_orders.order_edit_sheet.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() { return Container( padding: const EdgeInsets.fromLTRB(20, 24, 20, 20), @@ -839,11 +1017,11 @@ class OrderEditSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'One-Time Order', + t.client_view_orders.order_edit_sheet.one_time_order_title, style: UiTypography.headline3m.copyWith(color: UiColors.white), ), Text( - 'Refine your staffing needs', + t.client_view_orders.order_edit_sheet.refine_subtitle, style: UiTypography.footnote2r.copyWith( color: UiColors.white.withValues(alpha: 0.8), ), @@ -885,7 +1063,7 @@ class OrderEditSheetState extends State { GestureDetector( onTap: () => _removePosition(index), child: Text( - 'Remove', + t.client_view_orders.order_edit_sheet.remove, style: UiTypography.footnote1m.copyWith( color: UiColors.destructive, ), @@ -896,7 +1074,7 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space3), _buildDropdownField( - hint: 'Select role', + hint: t.client_view_orders.order_edit_sheet.select_role_hint, value: pos['roleId'], items: [ ..._roles.map((_RoleOption role) => role.id), @@ -931,14 +1109,14 @@ class OrderEditSheetState extends State { children: [ Expanded( child: _buildInlineTimeInput( - label: 'Start', + label: t.client_view_orders.order_edit_sheet.start_label, value: pos['start_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'start_time', @@ -951,14 +1129,14 @@ class OrderEditSheetState extends State { const SizedBox(width: UiConstants.space2), Expanded( child: _buildInlineTimeInput( - label: 'End', + label: t.client_view_orders.order_edit_sheet.end_label, value: pos['end_time'], onTap: () async { final TimeOfDay? picked = await showTimePicker( context: context, initialTime: TimeOfDay.now(), ); - if (picked != null && context.mounted) { + if (picked != null && mounted) { _updatePosition( index, 'end_time', @@ -974,7 +1152,7 @@ class OrderEditSheetState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Workers', + t.client_view_orders.order_edit_sheet.workers_label, style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space1), @@ -1029,7 +1207,7 @@ class OrderEditSheetState extends State { const Icon(UiIcons.mapPin, size: 14, color: UiColors.primary), const SizedBox(width: UiConstants.space1), Text( - 'Use different location for this position', + t.client_view_orders.order_edit_sheet.different_location, style: UiTypography.footnote1m.copyWith( color: UiColors.primary, ), @@ -1053,7 +1231,7 @@ class OrderEditSheetState extends State { ), const SizedBox(width: UiConstants.space1), Text( - 'Different Location', + t.client_view_orders.order_edit_sheet.different_location_title, style: UiTypography.footnote1m.textSecondary, ), ], @@ -1071,7 +1249,7 @@ class OrderEditSheetState extends State { const SizedBox(height: UiConstants.space2), UiTextField( controller: TextEditingController(text: pos['location']), - hintText: 'Enter different address', + hintText: t.client_view_orders.order_edit_sheet.enter_address_hint, onChanged: (String val) => _updatePosition(index, 'location', val), ), @@ -1082,7 +1260,7 @@ class OrderEditSheetState extends State { _buildSectionHeader('LUNCH BREAK'), _buildDropdownField( - hint: 'No Break', + hint: t.client_view_orders.order_edit_sheet.no_break, value: pos['lunch_break'], items: [ 'NO_BREAK', @@ -1105,7 +1283,7 @@ class OrderEditSheetState extends State { case 'MIN_60': return '60 min (Unpaid)'; default: - return 'No Break'; + return t.client_view_orders.order_edit_sheet.no_break; } }, onChanged: (dynamic val) => @@ -1263,11 +1441,11 @@ class OrderEditSheetState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ - _buildSummaryItem('${_positions.length}', 'Positions'), - _buildSummaryItem('$totalWorkers', 'Workers'), + _buildSummaryItem('${_positions.length}', t.client_view_orders.order_edit_sheet.positions), + _buildSummaryItem('$totalWorkers', t.client_view_orders.order_edit_sheet.workers), _buildSummaryItem( '\$${totalCost.round()}', - 'Est. Cost', + t.client_view_orders.order_edit_sheet.est_cost, ), ], ), @@ -1326,7 +1504,7 @@ class OrderEditSheetState extends State { const SizedBox(height: 24), Text( - 'Positions Breakdown', + t.client_view_orders.order_edit_sheet.positions_breakdown, style: UiTypography.body2b.textPrimary, ), const SizedBox(height: 12), @@ -1357,14 +1535,14 @@ class OrderEditSheetState extends State { children: [ Expanded( child: UiButton.secondary( - text: 'Edit', + text: t.client_view_orders.order_edit_sheet.edit_button, onPressed: () => setState(() => _showReview = false), ), ), const SizedBox(width: 12), Expanded( child: UiButton.primary( - text: 'Confirm & Save', + text: t.client_view_orders.order_edit_sheet.confirm_save, onPressed: () async { setState(() => _isLoading = true); await _saveOrderChanges(); @@ -1426,7 +1604,7 @@ class OrderEditSheetState extends State { children: [ Text( (role?.name ?? pos['roleName']?.toString() ?? '').isEmpty - ? 'Position' + ? t.client_view_orders.order_edit_sheet.position_singular : (role?.name ?? pos['roleName']?.toString() ?? ''), style: UiTypography.body2b.textPrimary, ), @@ -1492,14 +1670,14 @@ class OrderEditSheetState extends State { ), const SizedBox(height: 24), Text( - 'Order Updated!', + t.client_view_orders.order_edit_sheet.order_updated_title, style: UiTypography.headline1m.copyWith(color: UiColors.white), ), const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: Text( - 'Your shift has been updated successfully.', + t.client_view_orders.order_edit_sheet.order_updated_message, textAlign: TextAlign.center, style: UiTypography.body1r.copyWith( color: UiColors.white.withValues(alpha: 0.7), @@ -1510,7 +1688,7 @@ class OrderEditSheetState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 40), child: UiButton.secondary( - text: 'Back to Orders', + text: t.client_view_orders.order_edit_sheet.back_to_orders, fullWidth: true, style: OutlinedButton.styleFrom( backgroundColor: UiColors.white, diff --git a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart index e4c215ac..b5f02c97 100644 --- a/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/orders/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -259,6 +259,31 @@ class _ViewOrderCardState extends State { ), ], ), + if (order.hubManagerName != null) ...[ + const SizedBox(height: UiConstants.space2), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 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, + ), + ), + ], + ), + ], ], ), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 7db4d5ab..0950c573 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -24,15 +24,52 @@ class SettingsActions extends StatelessWidget { delegate: SliverChildListDelegate([ 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 _QuickLinksCard(labels: labels), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space5), // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { return UiButton.secondary( 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 ? null : () => _showSignOutDialog(context), @@ -113,7 +150,7 @@ class _QuickLinksCard extends StatelessWidget { onTap: () => Modular.to.toClientHubs(), ), _QuickLinkItem( - icon: UiIcons.building, + icon: UiIcons.file, title: labels.billing_payments, onTap: () => Modular.to.toClientBilling(), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart index 1efc5139..ea359254 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import '../../blocs/client_settings_bloc.dart'; /// A widget that displays the log out button. @@ -58,7 +59,7 @@ class SettingsLogout extends StatelessWidget { style: UiTypography.headline3m.textPrimary, ), content: Text( - 'Are you sure you want to log out?', + t.client_settings.profile.log_out_confirmation, style: UiTypography.body2r.textSecondary, ), actions: [ diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index c6987214..dd746425 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -31,7 +31,7 @@ class SettingsProfileHeader extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ - // ── Top bar: back arrow + title ────────────────── + // ── Top bar: back arrow + centered title ───────── SafeArea( bottom: false, child: Padding( @@ -39,21 +39,25 @@ class SettingsProfileHeader extends StatelessWidget { horizontal: UiConstants.space4, vertical: UiConstants.space2, ), - child: Row( + child: Stack( + alignment: Alignment.center, children: [ - GestureDetector( - onTap: () => Modular.to.toClientHome(), - child: const Icon( - UiIcons.arrowLeft, - color: UiColors.white, - size: 22, + Align( + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 22, + ), ), ), - const SizedBox(width: UiConstants.space3), Text( labels.title, style: UiTypography.body1b.copyWith( color: UiColors.white, + fontSize: 18, ), ), ], diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 07839283..1270ef05 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -925,6 +925,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.257.0" + marionette_flutter: + dependency: transitive + description: + name: marionette_flutter + sha256: "0077073f62a8031879a91be41aa91629f741a7f1348b18feacd53443dae3819f" + url: "https://pub.dev" + source: hosted + version: "0.3.0" matcher: dependency: transitive description: diff --git a/backend/dataconnect/connector/order/mutations.gql b/backend/dataconnect/connector/order/mutations.gql index 95eebf54..4749c498 100644 --- a/backend/dataconnect/connector/order/mutations.gql +++ b/backend/dataconnect/connector/order/mutations.gql @@ -15,6 +15,7 @@ mutation createOrder( $shifts: Any $requested: Int $teamHubId: UUID! + $hubManagerId: UUID $recurringDays: [String!] $permanentStartDate: Timestamp $permanentDays: [String!] @@ -40,6 +41,7 @@ mutation createOrder( shifts: $shifts requested: $requested teamHubId: $teamHubId + hubManagerId: $hubManagerId recurringDays: $recurringDays permanentDays: $permanentDays notes: $notes diff --git a/backend/dataconnect/schema/order.gql b/backend/dataconnect/schema/order.gql index 5ab05abb..056c9369 100644 --- a/backend/dataconnect/schema/order.gql +++ b/backend/dataconnect/schema/order.gql @@ -47,6 +47,9 @@ type Order @table(name: "orders", key: ["id"]) { teamHubId: UUID! teamHub: TeamHub! @ref(fields: "teamHubId", references: "id") + hubManagerId: UUID + hubManager: TeamMember @ref(fields: "hubManagerId", references: "id") + date: Timestamp startDate: Timestamp #for recurring and permanent diff --git a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md index fd1f30e1..b3f860ab 100644 --- a/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md +++ b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md @@ -1,266 +1,365 @@ # KROW Workforce API Contracts -This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details. +This document captures all API contracts used by the Staff and Client mobile applications. The application backend is powered by **Firebase Data Connect (GraphQL)**, so traditional REST endpoints do not exist natively. For clarity and ease of reading for all engineering team members, the tables below formulate these GraphQL Data Connect queries and mutations into their **Conceptual REST Endpoints** alongside the actual **Data Connect Operation Name**. --- ## Staff Application -### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info) +### Authentication / Onboarding Pages +*(Pages: get_started_page.dart, intro_page.dart, phone_verification_page.dart, profile_setup_page.dart)* + #### Setup / User Validation API | Field | Description | |---|---| -| **Endpoint name** | `/getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). | +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if the user is STAFF). | | **Operation** | Query | | **Inputs** | `id: UUID!` (Firebase UID) | | **Outputs** | `User { id, email, phone, role }` | -| **Notes** | Required after OTP verification to route users. | +| **Notes** | Required after OTP verification to route users appropriately. | #### Create Default User API | Field | Description | |---|---| -| **Endpoint name** | `/createUser` | +| **Conceptual Endpoint** | `POST /users` | +| **Data Connect OP** | `createUser` | | **Purpose** | Inserts a base user record into the system during initial signup. | | **Operation** | Mutation | | **Inputs** | `id: UUID!`, `role: UserBaseRole` | | **Outputs** | `id` of newly created User | -| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't physically exist in the database. | #### Get Staff Profile API | Field | Description | |---|---| -| **Endpoint name** | `/getStaffByUserId` | +| **Conceptual Endpoint** | `GET /staff/user/{userId}` | +| **Data Connect OP** | `getStaffByUserId` | | **Purpose** | Finds the specific Staff record associated with the base user ID. | | **Operation** | Query | | **Inputs** | `userId: UUID!` | | **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | -| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. | +| **Notes** | Needed to verify if a complete staff profile exists before allowing navigation to the main app dashboard. | #### Update Staff Profile API | Field | Description | |---|---| -| **Endpoint name** | `/updateStaff` | +| **Conceptual Endpoint** | `PUT /staff/{id}` | +| **Data Connect OP** | `updateStaff` | | **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | | **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `address`, etc. | | **Outputs** | `id` | -| **Notes** | Called incrementally during profile setup wizard. | +| **Notes** | Called incrementally during the profile setup wizard as the user fills out step-by-step information. | + +### Home Page & Benefits Overview +*(Pages: worker_home_page.dart, benefits_overview_page.dart)* -### Home Page (worker_home_page.dart) & Benefits Overview #### Load Today/Tomorrow Shifts | Field | Description | |---|---| -| **Endpoint name** | `/getApplicationsByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/applications` | +| **Data Connect OP** | `getApplicationsByStaffId` | | **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | | **Operation** | Query | | **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | | **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | -| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to successfully display "Today's" and "Tomorrow's" shifts. | #### List Recommended Shifts | Field | Description | |---|---| -| **Endpoint name** | `/listShifts` | +| **Conceptual Endpoint** | `GET /shifts/recommended` | +| **Data Connect OP** | `listShifts` | | **Purpose** | Fetches open shifts that are available for the staff to apply to. | | **Operation** | Query | -| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. | +| **Inputs** | None directly mapped on load, but fetches available items logically. | | **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | -| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on an active backend `$status: OPEN` parameter. | #### Benefits Summary API | Field | Description | |---|---| -| **Endpoint name** | `/listBenefitsDataByStaffId` | -| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. | +| **Conceptual Endpoint** | `GET /staff/{staffId}/benefits` | +| **Data Connect OP** | `listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display gracefully on the home screen. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | -| **Notes** | Calculates `usedHours = total - current`. | +| **Notes** | Used by `benefits_overview_page.dart`. Derives available metrics via `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages +*(Pages: shifts_page.dart, shift_details_page.dart)* -### Find Shifts / Shift Details Pages (shifts_page.dart) #### List Available Shifts Filtered | Field | Description | |---|---| -| **Endpoint name** | `/filterShifts` | +| **Conceptual Endpoint** | `GET /shifts` | +| **Data Connect OP** | `filterShifts` | | **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | | **Operation** | Query | | **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | | **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | -| **Notes** | - | +| **Notes** | Main driver for discovering available work. | #### Get Shift Details | Field | Description | |---|---| -| **Endpoint name** | `/getShiftById` | -| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. | +| **Conceptual Endpoint** | `GET /shifts/{id}` | +| **Data Connect OP** | `getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform requirements and managers. | | **Operation** | Query | | **Inputs** | `id: UUID!` | | **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | -| **Notes** | - | +| **Notes** | Invoked when users click into a full `shift_details_page.dart`. | #### Apply To Shift | Field | Description | |---|---| -| **Endpoint name** | `/createApplication` | -| **Purpose** | Worker submits an intent to take an open shift. | +| **Conceptual Endpoint** | `POST /applications` | +| **Data Connect OP** | `createApplication` | +| **Purpose** | Worker submits an intent to take an open shift (creates an application record). | | **Operation** | Mutation | -| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` | -| **Outputs** | `Application ID` | -| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. | +| **Inputs** | `shiftId: UUID!`, `staffId: UUID!`, `roleId: UUID!`, `status: ApplicationStatus!` (e.g. `PENDING` or `CONFIRMED`), `origin: ApplicationOrigin!` (e.g. `STAFF`); optional: `checkInTime`, `checkOutTime` | +| **Outputs** | `application_insert.id` (Application ID) | +| **Notes** | The app uses `status: CONFIRMED` and `origin: STAFF` when claiming; backend also supports `PENDING` for admin review flows. After creation, shift-role assigned count and shift filled count are updated. | + +### Availability Page +*(Pages: availability_page.dart)* -### Availability Page (availability_page.dart) #### Get Default Availability | Field | Description | |---|---| -| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/availabilities` | +| **Data Connect OP** | `listStaffAvailabilitiesByStaffId` | | **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | -| **Notes** | - | +| **Notes** | Bound to Monday through Sunday configuration. | #### Update Availability | Field | Description | |---|---| -| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) | +| **Conceptual Endpoint** | `PUT /staff/availabilities/{id}` | +| **Data Connect OP** | `updateStaffAvailability` (or `createStaffAvailability` for new entries) | | **Purpose** | Upserts availability preferences. | | **Operation** | Mutation | | **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | | **Outputs** | `id` | | **Notes** | Called individually per day edited. | -### Payments Page (payments_page.dart) +### Payments Page +*(Pages: payments_page.dart, early_pay_page.dart)* + #### Get Recent Payments | Field | Description | |---|---| -| **Endpoint name** | `/listRecentPaymentsByStaffId` | +| **Conceptual Endpoint** | `GET /staff/{staffId}/payments` | +| **Data Connect OP** | `listRecentPaymentsByStaffId` | | **Purpose** | Loads the history of earnings and timesheets completed by the staff. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `Payments { amount, processDate, shiftId, status }` | -| **Notes** | Displays historical metrics under Earnings tab. | +| **Notes** | Displays historical metrics under the comprehensive Earnings tab. | + +### Compliance / Profiles +*(Pages: certificates_page.dart, documents_page.dart, tax_forms_page.dart, form_i9_page.dart, form_w4_page.dart)* -### Compliance / Profiles (Agreements, W4, I9, Documents) #### Get Tax Forms | Field | Description | |---|---| -| **Endpoint name** | `/getTaxFormsByStaffId` | -| **Purpose** | Check the filing status of I9 and W4 forms. | +| **Conceptual Endpoint** | `GET /staff/{staffId}/tax-forms` | +| **Data Connect OP** | `getTaxFormsByStaffId` | +| **Purpose** | Check the filing status and detailed inputs of I9 and W4 forms. | | **Operation** | Query | | **Inputs** | `staffId: UUID!` | | **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | -| **Notes** | Required for staff to be eligible for shifts. | +| **Notes** | Crucial requirement for staff to be eligible to apply for highly regulated shifts. | #### Update Tax Forms | Field | Description | |---|---| -| **Endpoint name** | `/updateTaxForm` | -| **Purpose** | Submits state and filing for the given tax form type. | +| **Conceptual Endpoint** | `PUT /tax-forms/{id}` | +| **Data Connect OP** | `updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type (W4/I9). | | **Operation** | Mutation | | **Inputs** | `id`, `dataPoints...` | | **Outputs** | `id` | -| **Notes** | Updates compliance state. | +| **Notes** | Modifies the core compliance state variables directly. | --- ## Client Application -### Authentication / Intro (Sign In, Get Started) +### Authentication / Intro +*(Pages: client_sign_in_page.dart, client_get_started_page.dart)* + #### Client User Validation API | Field | Description | |---|---| -| **Endpoint name** | `/getUserById` | -| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). | +| **Conceptual Endpoint** | `GET /users/{id}` | +| **Data Connect OP** | `getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (ensuring user is BUSINESS). | | **Operation** | Query | | **Inputs** | `id: UUID!` (Firebase UID) | | **Outputs** | `User { id, email, phone, userRole }` | -| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. | +| **Notes** | Validates against conditional statements checking `userRole == BUSINESS` or `BOTH`. | -#### Get Business Profile API +#### Get Businesses By User API | Field | Description | |---|---| -| **Endpoint name** | `/getBusinessByUserId` | +| **Conceptual Endpoint** | `GET /business/user/{userId}` | +| **Data Connect OP** | `getBusinessesByUserId` | | **Purpose** | Maps the authenticated user to their client business context. | | **Operation** | Query | -| **Inputs** | `userId: UUID!` | -| **Outputs** | `Business { id, businessName, email, contactName }` | -| **Notes** | Used to set the working scopes (Business ID) across the entire app. | +| **Inputs** | `userId: String!` | +| **Outputs** | `Businesses { id, businessName, email, contactName }` | +| **Notes** | Dictates the working scopes (Business ID) across the entire application lifecycle and binds the user. | -### Hubs Page (client_hubs_page.dart, edit_hub.dart) -#### List Hubs +### Hubs Page +*(Pages: client_hubs_page.dart, edit_hub_page.dart, hub_details_page.dart)* + +#### List Hubs by Team | Field | Description | |---|---| -| **Endpoint name** | `/listTeamHubsByBusinessId` | -| **Purpose** | Fetches the primary working sites (Hubs) for a client. | +| **Conceptual Endpoint** | `GET /teams/{teamId}/hubs` | +| **Data Connect OP** | `getTeamHubsByTeamId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client context by using Team mapping. | | **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` | -| **Notes** | - | +| **Inputs** | `teamId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, managerName, isActive }` | +| **Notes** | `teamId` is derived first from `getTeamsByOwnerId(ownerId: businessId)`. | -#### Update / Delete Hub +#### Create / Update / Delete Hub | Field | Description | |---|---| -| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` | -| **Purpose** | Edits or archives a Hub location. | +| **Conceptual Endpoint** | `POST /team-hubs` / `PUT /team-hubs/{id}` / `DELETE /team-hubs/{id}` | +| **Data Connect OP** | `createTeamHub` / `updateTeamHub` / `deleteTeamHub` | +| **Purpose** | Provisions, Edits details directly, or Removes a Team Hub location. | | **Operation** | Mutation | -| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) | +| **Inputs** | `id: UUID!`, optionally `hubName`, `address`, etc. | | **Outputs** | `id` | -| **Notes** | - | +| **Notes** | Fired from `edit_hub_page.dart` mutations. | + +### Orders Page +*(Pages: create_order_page.dart, view_orders_page.dart, recurring_order_page.dart)* -### Orders Page (create_order, view_orders) #### Create Order | Field | Description | |---|---| -| **Endpoint name** | `/createOrder` | -| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). | +| **Conceptual Endpoint** | `POST /orders` | +| **Data Connect OP** | `createOrder` | +| **Purpose** | Submits a new request for temporary staff requirements. | | **Operation** | Mutation | | **Inputs** | `businessId`, `eventName`, `orderType`, `status` | | **Outputs** | `id` (Order ID) | -| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. | +| **Notes** | This explicitly invokes an order pipeline, meaning Shift instances are subsequently created through secondary mutations triggered after order instantiation. | #### List Orders | Field | Description | |---|---| -| **Endpoint name** | `/getOrdersByBusinessId` | +| **Conceptual Endpoint** | `GET /business/{businessId}/orders` | +| **Data Connect OP** | `listOrdersByBusinessId` | | **Purpose** | Retrieves all ongoing and past staff requests from the client. | | **Operation** | Query | | **Inputs** | `businessId: UUID!` | -| **Outputs** | `Orders { id, eventName, shiftCount, status }` | -| **Notes** | - | +| **Outputs** | `Orders { id, eventName }` | +| **Notes** | Populates the `view_orders_page.dart`. | + +### Billing Pages +*(Pages: billing_page.dart, pending_invoices_page.dart, completion_review_page.dart)* -### Billing Pages (billing_page.dart, pending_invoices) #### List Invoices | Field | Description | |---|---| -| **Endpoint name** | `/listInvoicesByBusinessId` | -| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. | +| **Conceptual Endpoint** | `GET /business/{businessId}/invoices` | +| **Data Connect OP** | `listInvoicesByBusinessId` | +| **Purpose** | Fetches all invoices bound directly to the active business context (mapped directly in Firebase Schema). | | **Operation** | Query | | **Inputs** | `businessId: UUID!` | -| **Outputs** | `Invoices { id, amountDue, issueDate, status }` | -| **Notes** | Used across all Billing view tabs. | +| **Outputs** | `Invoices { id, amount, issueDate, status }` | +| **Notes** | Used massively across all Billing view tabs. | -#### Mark Invoice +#### Mark / Dispute Invoice | Field | Description | |---|---| -| **Endpoint name** | `/updateInvoice` | -| **Purpose** | Marks an invoice as disputed or pays it (changes status). | +| **Conceptual Endpoint** | `PUT /invoices/{id}` | +| **Data Connect OP** | `updateInvoice` | +| **Purpose** | Actively marks an invoice as disputed or pays it directly (altering status). | | **Operation** | Mutation | | **Inputs** | `id: UUID!`, `status: InvoiceStatus` | | **Outputs** | `id` | -| **Notes** | Disputing usually involves setting a memo or flag. | +| **Notes** | Disputing usually involves setting a `disputeReason` flag state dynamically via builder pattern. | + +### Reports Page +*(Pages: reports_page.dart, coverage_report_page.dart, performance_report_page.dart)* -### Reports Page (reports_page.dart) #### Get Coverage Stats | Field | Description | |---|---| -| **Endpoint name** | `/getCoverageStatsByBusiness` | -| **Purpose** | Provides data on fulfillments rates vs actual requests. | +| **Conceptual Endpoint** | `GET /business/{businessId}/coverage` | +| **Data Connect OP** | `listShiftsForCoverage` | +| **Purpose** | Provides data on Shifts grouped by Date for fulfillment calculations. | | **Operation** | Query | -| **Inputs** | `businessId: UUID!` | -| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` | -| **Notes** | Driven mostly by aggregated backend views. | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, filled, status }` | +| **Notes** | The frontend aggregates the raw backend rows to compose Coverage percentage natively. | + +#### Get Daily Ops Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/dailyops` | +| **Data Connect OP** | `listShiftsForDailyOpsByBusiness` | +| **Purpose** | Supplies current day operations and shift tracking progress. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `date: Timestamp!` | +| **Outputs** | `Shifts { id, title, location, workersNeeded, filled }` | +| **Notes** | - | + +#### Get Forecast Stats +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/forecast` | +| **Data Connect OP** | `listShiftsForForecastByBusiness` | +| **Purpose** | Retrieves scheduled future shifts to calculate financial run-rates. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date, workersNeeded, hours, cost }` | +| **Notes** | The App maps hours `x` cost to deliver Financial Dashboards. | + +#### Get Performance KPIs +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/performance` | +| **Data Connect OP** | `listShiftsForPerformanceByBusiness` | +| **Purpose** | Fetches historical data allowing time-to-fill and completion-rate calculations. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, workersNeeded, filled, createdAt, filledAt }` | +| **Notes** | Data Connect exposes timestamps so the App calculates `avgFillTimeHours`. | + +#### Get No-Show Metrics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/noshows` | +| **Data Connect OP** | `listShiftsForNoShowRangeByBusiness` | +| **Purpose** | Retrieves shifts where workers historically ghosted the platform. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Shifts { id, date }` | +| **Notes** | Accompanies `listApplicationsForNoShowRange` cascading querying to generate full report. | + +#### Get Spend Analytics +| Field | Description | +|---|---| +| **Conceptual Endpoint** | `GET /business/{businessId}/spend` | +| **Data Connect OP** | `listInvoicesForSpendByBusiness` | +| **Purpose** | Detailed invoice aggregates for Spend metrics filtering. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!`, `startDate: Timestamp!`, `endDate: Timestamp!` | +| **Outputs** | `Invoices { id, issueDate, dueDate, amount, status }` | +| **Notes** | Used explicitly under the "Spend Report" graphings. | --- - -*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.* diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 00000000..fd1f30e1 --- /dev/null +++ b/docs/api-contracts.md @@ -0,0 +1,266 @@ +# KROW Workforce API Contracts + +This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details. + +--- + +## Staff Application + +### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info) +#### Setup / User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, role }` | +| **Notes** | Required after OTP verification to route users. | + +#### Create Default User API +| Field | Description | +|---|---| +| **Endpoint name** | `/createUser` | +| **Purpose** | Inserts a base user record into the system during initial signup. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `role: UserBaseRole` | +| **Outputs** | `id` of newly created User | +| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. | + +#### Get Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getStaffByUserId` | +| **Purpose** | Finds the specific Staff record associated with the base user ID. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` | +| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. | + +#### Update Staff Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaff` | +| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. | +| **Outputs** | `id` | +| **Notes** | Called incrementally during profile setup wizard. | + +### Home Page (worker_home_page.dart) & Benefits Overview +#### Load Today/Tomorrow Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/getApplicationsByStaffId` | +| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` | +| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` | +| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. | + +#### List Recommended Shifts +| Field | Description | +|---|---| +| **Endpoint name** | `/listShifts` | +| **Purpose** | Fetches open shifts that are available for the staff to apply to. | +| **Operation** | Query | +| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. | +| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` | +| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. | + +#### Benefits Summary API +| Field | Description | +|---|---| +| **Endpoint name** | `/listBenefitsDataByStaffId` | +| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` | +| **Notes** | Calculates `usedHours = total - current`. | + +### Find Shifts / Shift Details Pages (shifts_page.dart) +#### List Available Shifts Filtered +| Field | Description | +|---|---| +| **Endpoint name** | `/filterShifts` | +| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. | +| **Operation** | Query | +| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` | +| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` | +| **Notes** | - | + +#### Get Shift Details +| Field | Description | +|---|---| +| **Endpoint name** | `/getShiftById` | +| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. | +| **Operation** | Query | +| **Inputs** | `id: UUID!` | +| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` | +| **Notes** | - | + +#### Apply To Shift +| Field | Description | +|---|---| +| **Endpoint name** | `/createApplication` | +| **Purpose** | Worker submits an intent to take an open shift. | +| **Operation** | Mutation | +| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` | +| **Outputs** | `Application ID` | +| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. | + +### Availability Page (availability_page.dart) +#### Get Default Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` | +| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` | +| **Notes** | - | + +#### Update Availability +| Field | Description | +|---|---| +| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) | +| **Purpose** | Upserts availability preferences. | +| **Operation** | Mutation | +| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` | +| **Outputs** | `id` | +| **Notes** | Called individually per day edited. | + +### Payments Page (payments_page.dart) +#### Get Recent Payments +| Field | Description | +|---|---| +| **Endpoint name** | `/listRecentPaymentsByStaffId` | +| **Purpose** | Loads the history of earnings and timesheets completed by the staff. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `Payments { amount, processDate, shiftId, status }` | +| **Notes** | Displays historical metrics under Earnings tab. | + +### Compliance / Profiles (Agreements, W4, I9, Documents) +#### Get Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/getTaxFormsByStaffId` | +| **Purpose** | Check the filing status of I9 and W4 forms. | +| **Operation** | Query | +| **Inputs** | `staffId: UUID!` | +| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` | +| **Notes** | Required for staff to be eligible for shifts. | + +#### Update Tax Forms +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTaxForm` | +| **Purpose** | Submits state and filing for the given tax form type. | +| **Operation** | Mutation | +| **Inputs** | `id`, `dataPoints...` | +| **Outputs** | `id` | +| **Notes** | Updates compliance state. | + +--- + +## Client Application + +### Authentication / Intro (Sign In, Get Started) +#### Client User Validation API +| Field | Description | +|---|---| +| **Endpoint name** | `/getUserById` | +| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). | +| **Operation** | Query | +| **Inputs** | `id: UUID!` (Firebase UID) | +| **Outputs** | `User { id, email, phone, userRole }` | +| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. | + +#### Get Business Profile API +| Field | Description | +|---|---| +| **Endpoint name** | `/getBusinessByUserId` | +| **Purpose** | Maps the authenticated user to their client business context. | +| **Operation** | Query | +| **Inputs** | `userId: UUID!` | +| **Outputs** | `Business { id, businessName, email, contactName }` | +| **Notes** | Used to set the working scopes (Business ID) across the entire app. | + +### Hubs Page (client_hubs_page.dart, edit_hub.dart) +#### List Hubs +| Field | Description | +|---|---| +| **Endpoint name** | `/listTeamHubsByBusinessId` | +| **Purpose** | Fetches the primary working sites (Hubs) for a client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` | +| **Notes** | - | + +#### Update / Delete Hub +| Field | Description | +|---|---| +| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` | +| **Purpose** | Edits or archives a Hub location. | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) | +| **Outputs** | `id` | +| **Notes** | - | + +### Orders Page (create_order, view_orders) +#### Create Order +| Field | Description | +|---|---| +| **Endpoint name** | `/createOrder` | +| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). | +| **Operation** | Mutation | +| **Inputs** | `businessId`, `eventName`, `orderType`, `status` | +| **Outputs** | `id` (Order ID) | +| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. | + +#### List Orders +| Field | Description | +|---|---| +| **Endpoint name** | `/getOrdersByBusinessId` | +| **Purpose** | Retrieves all ongoing and past staff requests from the client. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Orders { id, eventName, shiftCount, status }` | +| **Notes** | - | + +### Billing Pages (billing_page.dart, pending_invoices) +#### List Invoices +| Field | Description | +|---|---| +| **Endpoint name** | `/listInvoicesByBusinessId` | +| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Invoices { id, amountDue, issueDate, status }` | +| **Notes** | Used across all Billing view tabs. | + +#### Mark Invoice +| Field | Description | +|---|---| +| **Endpoint name** | `/updateInvoice` | +| **Purpose** | Marks an invoice as disputed or pays it (changes status). | +| **Operation** | Mutation | +| **Inputs** | `id: UUID!`, `status: InvoiceStatus` | +| **Outputs** | `id` | +| **Notes** | Disputing usually involves setting a memo or flag. | + +### Reports Page (reports_page.dart) +#### Get Coverage Stats +| Field | Description | +|---|---| +| **Endpoint name** | `/getCoverageStatsByBusiness` | +| **Purpose** | Provides data on fulfillments rates vs actual requests. | +| **Operation** | Query | +| **Inputs** | `businessId: UUID!` | +| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` | +| **Notes** | Driven mostly by aggregated backend views. | + +--- + +*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.* diff --git a/docs/available_gql.txt b/docs/available_gql.txt new file mode 100644 index 00000000..54380559 Binary files /dev/null and b/docs/available_gql.txt differ diff --git a/docs/research/flutter-testing-tools.md b/docs/research/flutter-testing-tools.md new file mode 100644 index 00000000..d7cde701 --- /dev/null +++ b/docs/research/flutter-testing-tools.md @@ -0,0 +1,87 @@ +# 📱 Research: Flutter Integration Testing Evaluation +**Issue:** #533 +**Focus:** Maestro vs. Marionette MCP (LeanCode) +**Status:** ✅ Completed +**Target Apps:** `KROW Client App` & `KROW Staff App` + +--- + +## 1. Executive Summary & Recommendation + +Following a technical spike implementing full authentication flows (Login/Signup) for both KROW platforms, **Maestro is the recommended integration testing framework.** + +While **Marionette MCP** offers an innovative LLM-driven approach for exploratory debugging, it lacks the determinism required for a production-grade CI/CD pipeline. Maestro provides the stability, speed, and native OS interaction necessary to gate our releases effectively. + +### Why Maestro Wins for KROW: +* **Zero-Flake Execution:** Built-in wait logic handles Firebase Auth latency without hard-coded `sleep()` calls. +* **Platform Parity:** Single `.yaml` definitions drive both iOS and Android build variants. +* **Non-Invasive:** Maestro tests the compiled `.apk` or `.app` (Black-box), ensuring we test exactly what the user sees. +* **System Level Access:** Handles native OS permission dialogs (Camera/Location/Notifications) which Marionette cannot "see." + +--- + +## 2. Technical Evaluation Matrix + +| Criteria | Maestro | Marionette MCP | Winner | +| :--- | :--- | :--- | :--- | +| **Test Authoring** | **High Speed:** Declarative YAML; Maestro Studio recorder. | **Variable:** Requires precise Prompt Engineering. | **Maestro** | +| **Execution Latency** | **Low:** Instantaneous interaction (~5s flows). | **High:** LLM API roundtrips (~45s+ flows). | **Maestro** | +| **Environment** | Works on Release/Production builds. | Restricted to Debug/Profile modes. | **Maestro** | +| **CI/CD Readiness** | Native CLI; easy GitHub Actions integration. | High overhead; depends on external AI APIs. | **Maestro** | +| **Context Awareness** | Interacts with Native OS & Bottom Sheets. | Limited to the Flutter Widget Tree. | **Maestro** | + +--- + +## 3. Spike Analysis & Findings + +### Tool A: Maestro (The Standard) +We verified the `login.yaml` and `signup.yaml` flows across both apps. Maestro successfully abstracted the asynchronous nature of our **Data Connect** and **Firebase** backends. + +* **Pros:** * **Semantics Driven:** By targeting `Semantics(identifier: '...')` in our `/design_system/`, tests remain stable even if the UI text changes for localization. + * **Automatic Tolerance:** It detects spinning loaders and waits for destination widgets automatically. +* **Cons:** * Requires strict adherence to adding `Semantics` wrappers on all interactive components. + +### Tool B: Marionette MCP (The Experiment) +We spiked this using the `marionette_flutter` binding and executing via **Cursor/Claude**. + +* **Pros:** * Phenomenal for visual "smoke testing" and live-debugging UI issues via natural language. +* **Cons:** * **Non-Deterministic:** Prone to "hallucinations" during heavy network traffic. + * **Architecture Blocker:** Requires the Dart VM Service to be active, making it impossible to test against hardened production builds. + +--- + +## 4. Implementation & Migration Blueprint + + + +### Phase 1: Semantics Enforcement +We must enforce a linting rule or PR checklist: All interactive widgets in `@krow/design_system` must include a unique `identifier`. + +```dart +// Standardized Implementation +Semantics( + identifier: 'login_submit_button', + child: KrowPrimaryButton( + onPressed: _handleLogin, + label: 'Sign In', + ), +) +``` + +### Phase 2: Repository Structure (Implemented) +Maestro flows are co-located with each app: + +* `apps/mobile/apps/client/maestro/login.yaml` — Client login +* `apps/mobile/apps/client/maestro/signup.yaml` — Client signup +* `apps/mobile/apps/staff/maestro/login.yaml` — Staff login (phone + OTP) +* `apps/mobile/apps/staff/maestro/signup.yaml` — Staff signup (phone + OTP) + +Each directory has a README with run instructions. + +**Marionette MCP:** `marionette_flutter` is added to both apps; `MarionetteBinding` is initialized in debug mode. See [marionette-spike-usage.md](marionette-spike-usage.md) for prompts and workflow. + +### Phase 3: CI/CD Integration +The Maestro CLI will be added to our **GitHub Actions** workflow to automate quality gates. + +* **Trigger:** Every PR targeting `main` or `develop`. +* **Action:** Generate a build, execute `maestro test`, and block merge on failure. diff --git a/docs/research/maestro-test-run-instructions.md b/docs/research/maestro-test-run-instructions.md new file mode 100644 index 00000000..a4fb80e7 --- /dev/null +++ b/docs/research/maestro-test-run-instructions.md @@ -0,0 +1,84 @@ +# How to Run Maestro Integration Tests + +## Credentials + +| Flow | Credentials | +|------|-------------| +| **Client login** | legendary@krowd.com / Demo2026! | +| **Staff login** | 5557654321 / OTP 123456 | +| **Client signup** | Env vars: `MAESTRO_CLIENT_EMAIL`, `MAESTRO_CLIENT_PASSWORD`, `MAESTRO_CLIENT_COMPANY` | +| **Staff signup** | Env var: `MAESTRO_STAFF_SIGNUP_PHONE` (must be new Firebase test phone) | + +--- + +## Step-by-step: Run login tests + +### 1. Install Maestro CLI + +```bash +curl -Ls "https://get.maestro.mobile.dev" | bash +``` + +Or: https://maestro.dev/docs/getting-started/installation + +### 2. Add Firebase test phone (Staff app only) + +In [Firebase Console](https://console.firebase.google.com) → your project → **Authentication** → **Sign-in method** → **Phone** → **Phone numbers for testing**: + +- Add: **+1 5557654321** with verification code **123456** + +### 3. Build and install the apps + +From the **project root**: + +```bash +# Client +make mobile-client-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/client/build/app/outputs/flutter-apk/app-debug.apk + +# Staff +make mobile-staff-build PLATFORM=apk MODE=debug +adb install apps/mobile/apps/staff/build/app/outputs/flutter-apk/app-debug.apk +``` + +Or run the app on a connected device/emulator: `make mobile-client-dev-android DEVICE=` (then Maestro can launch the already-installed app by appId). + +### 4. Run Maestro tests + +From the **project root** (`e:\Krow-google\krow-workforce`): + +```bash +# Client login (uses legendary@krowd.com / Demo2026!) +maestro test apps/mobile/apps/client/maestro/login.yaml + +# Staff login (uses 5557654321 / OTP 123456) +maestro test apps/mobile/apps/staff/maestro/login.yaml +``` + +### 5. Run signup tests (optional) + +**Client signup** — set env vars first: +```bash +$env:MAESTRO_CLIENT_EMAIL="newuser@example.com" +$env:MAESTRO_CLIENT_PASSWORD="YourPassword123!" +$env:MAESTRO_CLIENT_COMPANY="Test Company" +maestro test apps/mobile/apps/client/maestro/signup.yaml +``` + +**Staff signup** — use a new Firebase test phone: +```bash +# Add +1 555-555-0000 / 123456 in Firebase, then: +$env:MAESTRO_STAFF_SIGNUP_PHONE="5555550000" +maestro test apps/mobile/apps/staff/maestro/signup.yaml +``` + +--- + +## Checklist + +- [ ] Maestro CLI installed +- [ ] Firebase test phone +1 5557654321 / 123456 added (for staff) +- [ ] Client app built and installed +- [ ] Staff app built and installed +- [ ] Run from project root: `maestro test apps/mobile/apps/client/maestro/login.yaml` +- [ ] Run from project root: `maestro test apps/mobile/apps/staff/maestro/login.yaml` diff --git a/docs/research/marionette-spike-usage.md b/docs/research/marionette-spike-usage.md new file mode 100644 index 00000000..09553e89 --- /dev/null +++ b/docs/research/marionette-spike-usage.md @@ -0,0 +1,58 @@ +# Marionette MCP Spike — Usage Guide + +**Issue:** #533 +**Purpose:** Document how to run the Marionette MCP spike for auth flows. + +## Prerequisites + +1. **Marionette MCP server** — Install globally: + ```bash + dart pub global activate marionette_mcp + ``` + +2. **Add Marionette to Cursor** — In `.cursor/mcp.json` or global config: + ```json + { + "mcpServers": { + "marionette": { + "command": "marionette_mcp", + "args": [] + } + } + } + ``` + +3. **Run app in debug mode** — The app must be running with VM Service: + ```bash + cd apps/mobile && flutter run -d + ``` + +4. **Get VM Service URI** — From the `flutter run` output, copy the `ws://127.0.0.1:XXXX/ws` URI (often shown in the DevTools link). + +## Spike flows (AI agent prompts) + +Use these prompts with the Marionette MCP connected to the running app. + +### Client — Login + +> Connect to the app using the VM Service URI. Navigate to the Get Started screen, tap "Sign In", enter legendary@krowd.com and Demo2026!, then tap "Sign In". Verify we land on the home screen. + +### Client — Sign up + +> Connect to the app. Tap "Create Account", fill in Company, Email, Password (and confirm) with new credentials, then tap "Create Account". Verify we land on the home screen. + +### Staff — Login + +> Connect to the app. Tap "Log In", enter phone number 5557654321, tap "Send Code", enter OTP 123456, tap "Continue". Verify we reach the staff home screen. +> (Firebase test phone: +1 555-765-4321 / OTP 123456) + +### Staff — Sign up + +> Connect to the app. Tap "Sign Up", enter a NEW phone number (Firebase test phone), tap "Send Code", enter OTP, tap "Continue". Verify we reach Profile Setup or staff home. + +## Limitations observed (from spike) + +- **Debug only** — Marionette needs the Dart VM Service; does not work with release builds. +- **Non-deterministic** — LLM-driven actions can vary in behavior and timing. +- **Latency** — Each step involves API roundtrips (~45s+ for full flow vs ~5s for Maestro). +- **Best use** — Exploratory testing, live debugging, smoke checks during development.