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 0203f45d..edb5141e 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -1,4 +1,5 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'route_paths.dart'; @@ -145,6 +146,22 @@ extension ClientNavigator on IModularNavigator { await pushNamed(ClientPaths.hubs); } + /// Navigates to the details of a specific hub. + Future toHubDetails(Hub hub) { + return pushNamed( + ClientPaths.hubDetails, + arguments: {'hub': hub}, + ); + } + + /// Navigates to the page to add a new hub or edit an existing one. + Future toEditHub({Hub? hub}) async { + return pushNamed( + ClientPaths.editHub, + arguments: {'hub': hub}, + ); + } + // ========================================================================== // ORDER CREATION // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index b0ec3514..7575229d 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -16,14 +16,14 @@ class ClientPaths { /// Generate child route based on the given route and parent route /// /// This is useful for creating nested routes within modules. - static String childRoute(String parent, String child) { + static String childRoute(String parent, String child) { final String childPath = child.replaceFirst(parent, ''); - + // check if the child path is empty if (childPath.isEmpty) { return '/'; - } - + } + // ensure the child path starts with a '/' if (!childPath.startsWith('/')) { return '/$childPath'; @@ -82,10 +82,12 @@ class ClientPaths { static const String billing = '/client-main/billing'; /// Completion review page - review shift completion records. - static const String completionReview = '/client-main/billing/completion-review'; + static const String completionReview = + '/client-main/billing/completion-review'; /// Full list of invoices awaiting approval. - static const String awaitingApproval = '/client-main/billing/awaiting-approval'; + static const String awaitingApproval = + '/client-main/billing/awaiting-approval'; /// Invoice ready page - view status of approved invoices. static const String invoiceReady = '/client-main/billing/invoice-ready'; @@ -118,6 +120,12 @@ class ClientPaths { /// View and manage physical locations/hubs where staff are deployed. static const String hubs = '/client-hubs'; + /// Specific hub details. + static const String hubDetails = '/client-hubs/details'; + + /// Page for adding or editing a hub. + static const String editHub = '/client-hubs/edit'; + // ========================================================================== // ORDER CREATION & MANAGEMENT // ========================================================================== 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 cd9bb931..ebed7f73 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 @@ -1045,7 +1045,7 @@ } }, "staff_profile_attire": { - "title": "Attire", + "title": "Verify Attire", "info_card": { "title": "Your Wardrobe", "description": "Select the attire items you own. This helps us match you with shifts that fit your wardrobe." 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 b189ed26..1111b516 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 @@ -1045,7 +1045,7 @@ } }, "staff_profile_attire": { - "title": "Vestimenta", + "title": "Verificar Vestimenta", "info_card": { "title": "Tu Vestuario", "description": "Selecciona los art\u00edculos de vestimenta que posees. Esto nos ayuda a asignarte turnos que se ajusten a tu vestuario." diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index e206c814..9cdf0888 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,6 +1,7 @@ // 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, implementation_imports import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' + hide AttireVerificationStatus; import 'package:krow_domain/krow_domain.dart'; /// Implementation of [StaffConnectorRepository]. @@ -11,9 +12,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { /// Creates a new [StaffConnectorRepositoryImpl]. /// /// Requires a [DataConnectService] instance for backend communication. - StaffConnectorRepositoryImpl({ - DataConnectService? service, - }) : _service = service ?? DataConnectService.instance; + StaffConnectorRepositoryImpl({DataConnectService? service}) + : _service = service ?? DataConnectService.instance; final DataConnectService _service; @@ -22,15 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .getStaffProfileCompletion(id: staffId) - .execute(); + final QueryResult< + GetStaffProfileCompletionData, + GetStaffProfileCompletionVariables + > + response = await _service.connector + .getStaffProfileCompletion(id: staffId) + .execute(); final GetStaffProfileCompletionStaff? staff = response.data.staff; - final List - emergencyContacts = response.data.emergencyContacts; + final List emergencyContacts = + response.data.emergencyContacts; final List taxForms = response.data.taxForms; @@ -43,11 +45,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .getStaffPersonalInfoCompletion(id: staffId) - .execute(); + final QueryResult< + GetStaffPersonalInfoCompletionData, + GetStaffPersonalInfoCompletionVariables + > + response = await _service.connector + .getStaffPersonalInfoCompletion(id: staffId) + .execute(); final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; @@ -60,11 +64,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .getStaffEmergencyProfileCompletion(id: staffId) - .execute(); + final QueryResult< + GetStaffEmergencyProfileCompletionData, + GetStaffEmergencyProfileCompletionVariables + > + response = await _service.connector + .getStaffEmergencyProfileCompletion(id: staffId) + .execute(); return response.data.emergencyContacts.isNotEmpty; }); @@ -75,11 +81,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .getStaffExperienceProfileCompletion(id: staffId) - .execute(); + final QueryResult< + GetStaffExperienceProfileCompletionData, + GetStaffExperienceProfileCompletionVariables + > + response = await _service.connector + .getStaffExperienceProfileCompletion(id: staffId) + .execute(); final GetStaffExperienceProfileCompletionStaff? staff = response.data.staff; @@ -93,11 +101,13 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .getStaffTaxFormsProfileCompletion(id: staffId) - .execute(); + final QueryResult< + GetStaffTaxFormsProfileCompletionData, + GetStaffTaxFormsProfileCompletionVariables + > + response = await _service.connector + .getStaffTaxFormsProfileCompletion(id: staffId) + .execute(); return response.data.taxForms.isNotEmpty; }); @@ -135,9 +145,7 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final bool hasExperience = (skills is List && skills.isNotEmpty) || (industries is List && industries.isNotEmpty); - return emergencyContacts.isNotEmpty && - taxForms.isNotEmpty && - hasExperience; + return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience; } @override @@ -146,14 +154,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult response = - await _service.connector - .getStaffById(id: staffId) - .execute(); + await _service.connector.getStaffById(id: staffId).execute(); if (response.data.staff == null) { - throw const ServerException( - technicalMessage: 'Staff not found', - ); + throw const ServerException(technicalMessage: 'Staff not found'); } final GetStaffByIdStaff rawStaff = response.data.staff!; @@ -183,23 +187,87 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector - .listBenefitsDataByStaffId(staffId: staffId) - .execute(); + final QueryResult< + ListBenefitsDataByStaffIdData, + ListBenefitsDataByStaffIdVariables + > + response = await _service.connector + .listBenefitsDataByStaffId(staffId: staffId) + .execute(); return response.data.benefitsDatas.map((data) { final plan = data.vendorBenefitPlan; return Benefit( title: plan.title, entitlementHours: plan.total?.toDouble() ?? 0.0, - usedHours: (plan.total ?? 0) - data.current.toDouble(), + usedHours: data.current.toDouble(), ); }).toList(); }); } + @override + Future> getAttireOptions() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + // Fetch all options + final QueryResult optionsResponse = + await _service.connector.listAttireOptions().execute(); + + // Fetch user's attire status + final QueryResult + attiresResponse = await _service.connector + .getStaffAttire(staffId: staffId) + .execute(); + + final Map attireMap = { + for (final item in attiresResponse.data.staffAttires) + item.attireOptionId: item, + }; + + return optionsResponse.data.attireOptions.map((e) { + final GetStaffAttireStaffAttires? userAttire = attireMap[e.id]; + return AttireItem( + id: e.itemId, + label: e.label, + description: e.description, + imageUrl: e.imageUrl, + isMandatory: e.isMandatory ?? false, + verificationStatus: _mapAttireStatus( + userAttire?.verificationStatus?.stringValue, + ), + photoUrl: userAttire?.verificationPhotoUrl, + ); + }).toList(); + }); + } + + AttireVerificationStatus? _mapAttireStatus(String? status) { + if (status == null) return null; + return AttireVerificationStatus.values.firstWhere( + (e) => e.name.toUpperCase() == status.toUpperCase(), + orElse: () => AttireVerificationStatus.pending, + ); + } + + @override + Future upsertStaffAttire({ + required String attireOptionId, + required String photoUrl, + String? verificationId, + }) async { + await _service.run(() async { + final String staffId = await _service.getStaffId(); + + await _service.connector + .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) + .verificationPhotoUrl(photoUrl) + // .verificationId(verificationId) // Uncomment after SDK regeneration + .execute(); + }); + } + @override Future signOut() async { try { @@ -210,4 +278,3 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { } } } - diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index e82e69f3..e4cc2db8 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -45,6 +45,18 @@ abstract interface class StaffConnectorRepository { /// Returns a list of [Benefit] entities. Future> getBenefits(); + /// Fetches the attire options for the current authenticated user. + /// + /// Returns a list of [AttireItem] entities. + Future> getAttireOptions(); + + /// Upserts staff attire photo information. + Future upsertStaffAttire({ + required String attireOptionId, + required String photoUrl, + String? verificationId, + }); + /// Signs out the current user. /// /// Clears the user's session and authentication state. diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index 6aac02b2..537ef4f7 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -276,4 +276,7 @@ class UiIcons { /// Help circle icon for FAQs static const IconData helpCircle = _IconLib.helpCircle; + + /// Gallery icon for gallery + static const IconData gallery = _IconLib.galleryVertical; } diff --git a/apps/mobile/packages/design_system/lib/src/ui_typography.dart b/apps/mobile/packages/design_system/lib/src/ui_typography.dart index 16c0162b..8e1ce9bb 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_typography.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_typography.dart @@ -374,7 +374,7 @@ class UiTypography { /// Body 4 Regular - Font: Instrument Sans, Size: 14, Height: 1.5, Spacing: 0.05 (#121826) static final TextStyle body4r = _primaryBase.copyWith( fontWeight: FontWeight.w400, - fontSize: 12, + fontSize: 10, height: 1.5, letterSpacing: 0.05, color: UiColors.textPrimary, diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart index 4394bb7e..46654038 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_app_bar.dart @@ -1,10 +1,6 @@ import 'package:design_system/design_system.dart'; -import 'package:design_system/src/ui_typography.dart'; import 'package:flutter/material.dart'; -import '../ui_icons.dart'; -import 'ui_icon_button.dart'; - /// A custom AppBar for the Krow UI design system. /// /// This widget provides a consistent look and feel for top app bars across the application. @@ -12,6 +8,7 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget { const UiAppBar({ super.key, this.title, + this.subtitle, this.titleWidget, this.leading, this.actions, @@ -25,6 +22,9 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget { /// The title text to display in the app bar. final String? title; + /// The subtitle text to display in the app bar. + final String? subtitle; + /// A widget to display instead of the title text. final Widget? titleWidget; @@ -57,7 +57,19 @@ class UiAppBar extends StatelessWidget implements PreferredSizeWidget { return AppBar( title: titleWidget ?? - (title != null ? Text(title!, style: UiTypography.headline4b) : null), + (title != null + ? Column( + crossAxisAlignment: centerTitle + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title!, style: UiTypography.headline4b), + if (subtitle != null) + Text(subtitle!, style: UiTypography.body3r.textSecondary), + ], + ) + : null), leading: leading ?? (showBackButton diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart index 1bd3a289..09a781da 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_chip.dart @@ -5,6 +5,9 @@ import '../ui_typography.dart'; /// Sizes for the [UiChip] widget. enum UiChipSize { + // X-Small size (e.g. for tags in tight spaces). + xSmall, + /// Small size (e.g. for tags in tight spaces). small, @@ -25,6 +28,9 @@ enum UiChipVariant { /// Accent style with highlight background. accent, + + /// Desructive style with red background. + destructive, } /// A custom chip widget with supports for different sizes, themes, and icons. @@ -119,6 +125,8 @@ class UiChip extends StatelessWidget { return UiColors.tagInProgress; case UiChipVariant.accent: return UiColors.accent; + case UiChipVariant.destructive: + return UiColors.iconError.withValues(alpha: 0.1); } } @@ -134,11 +142,15 @@ class UiChip extends StatelessWidget { return UiColors.primary; case UiChipVariant.accent: return UiColors.accentForeground; + case UiChipVariant.destructive: + return UiColors.iconError; } } TextStyle _getTextStyle() { switch (size) { + case UiChipSize.xSmall: + return UiTypography.body4r; case UiChipSize.small: return UiTypography.body3r; case UiChipSize.medium: @@ -150,6 +162,8 @@ class UiChip extends StatelessWidget { EdgeInsets _getPadding() { switch (size) { + case UiChipSize.xSmall: + return const EdgeInsets.symmetric(horizontal: 6, vertical: 4); case UiChipSize.small: return const EdgeInsets.symmetric(horizontal: 10, vertical: 6); case UiChipSize.medium: @@ -161,6 +175,8 @@ class UiChip extends StatelessWidget { double _getIconSize() { switch (size) { + case UiChipSize.xSmall: + return 10; case UiChipSize.small: return 12; case UiChipSize.medium: @@ -172,6 +188,8 @@ class UiChip extends StatelessWidget { double _getGap() { switch (size) { + case UiChipSize.xSmall: + return UiConstants.space1; case UiChipSize.small: return UiConstants.space1; case UiChipSize.medium: diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 3d2a9b15..9c67574f 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -68,6 +68,7 @@ export 'src/adapters/financial/bank_account/bank_account_adapter.dart'; // Profile export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/attire_item.dart'; +export 'src/entities/profile/attire_verification_status.dart'; export 'src/entities/profile/relationship_type.dart'; export 'src/entities/profile/industry.dart'; export 'src/entities/profile/tax_form.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart index e9a56519..d830add4 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart @@ -1,26 +1,31 @@ import 'package:equatable/equatable.dart'; +import 'attire_verification_status.dart'; + /// Represents an attire item that a staff member might need or possess. /// /// Attire items are specific clothing or equipment required for jobs. class AttireItem extends Equatable { - /// Creates an [AttireItem]. const AttireItem({ required this.id, required this.label, - this.iconName, + this.description, this.imageUrl, this.isMandatory = false, + this.verificationStatus, + this.photoUrl, + this.verificationId, }); + /// Unique identifier of the attire item. final String id; /// Display name of the item. final String label; - /// Name of the icon to display (mapped in UI). - final String? iconName; + /// Optional description for the attire item. + final String? description; /// URL of the reference image. final String? imageUrl; @@ -28,6 +33,24 @@ class AttireItem extends Equatable { /// Whether this item is mandatory for onboarding. final bool isMandatory; + /// The current verification status of the uploaded photo. + final AttireVerificationStatus? verificationStatus; + + /// The URL of the photo uploaded by the staff member. + final String? photoUrl; + + /// The ID of the verification record. + final String? verificationId; + @override - List get props => [id, label, iconName, imageUrl, isMandatory]; + List get props => [ + id, + label, + description, + imageUrl, + isMandatory, + verificationStatus, + photoUrl, + verificationId, + ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart new file mode 100644 index 00000000..bc5a3430 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart @@ -0,0 +1,11 @@ +/// Represents the verification status of an attire item photo. +enum AttireVerificationStatus { + /// The photo is waiting for review. + pending, + + /// The photo was rejected. + failed, + + /// The photo was approved. + success, +} 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 e3dd08f4..49a88f20 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -11,7 +11,12 @@ import 'src/domain/usecases/delete_hub_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'; +import 'src/presentation/blocs/edit_hub/edit_hub_bloc.dart'; +import 'src/presentation/blocs/hub_details/hub_details_bloc.dart'; import 'src/presentation/pages/client_hubs_page.dart'; +import 'src/presentation/pages/edit_hub_page.dart'; +import 'src/presentation/pages/hub_details_page.dart'; +import 'package:krow_domain/krow_domain.dart'; export 'src/presentation/pages/client_hubs_page.dart'; @@ -34,10 +39,35 @@ class ClientHubsModule extends Module { // BLoCs i.add(ClientHubsBloc.new); + i.add(EditHubBloc.new); + i.add(HubDetailsBloc.new); } @override void routes(RouteManager r) { - r.child(ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), child: (_) => const ClientHubsPage()); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubs), + child: (_) => const ClientHubsPage(), + ); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.hubDetails), + child: (_) { + final Map data = r.args.data as Map; + return HubDetailsPage( + hub: data['hub'] as Hub, + bloc: Modular.get(), + ); + }, + ); + r.child( + ClientPaths.childRoute(ClientPaths.hubs, ClientPaths.editHub), + child: (_) { + final Map data = r.args.data as Map; + return EditHubPage( + hub: data['hub'] as Hub?, + bloc: Modular.get(), + ); + }, + ); } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart index 138efeca..4bd08959 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -2,80 +2,36 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../../domain/arguments/assign_nfc_tag_arguments.dart'; -import '../../domain/arguments/create_hub_arguments.dart'; -import '../../domain/arguments/delete_hub_arguments.dart'; -import '../../domain/usecases/assign_nfc_tag_usecase.dart'; -import '../../domain/usecases/create_hub_usecase.dart'; -import '../../domain/usecases/delete_hub_usecase.dart'; import '../../domain/usecases/get_hubs_usecase.dart'; -import '../../domain/usecases/update_hub_usecase.dart'; import 'client_hubs_event.dart'; import 'client_hubs_state.dart'; /// BLoC responsible for managing the state of the Client Hubs feature. /// /// It orchestrates the flow between the UI and the domain layer by invoking -/// specific use cases for fetching, creating, deleting, and assigning tags to hubs. +/// specific use cases for fetching hubs. class ClientHubsBloc extends Bloc with BlocErrorHandler implements Disposable { - - ClientHubsBloc({ - required GetHubsUseCase getHubsUseCase, - required CreateHubUseCase createHubUseCase, - required DeleteHubUseCase deleteHubUseCase, - required AssignNfcTagUseCase assignNfcTagUseCase, - required UpdateHubUseCase updateHubUseCase, - }) : _getHubsUseCase = getHubsUseCase, - _createHubUseCase = createHubUseCase, - _deleteHubUseCase = deleteHubUseCase, - _assignNfcTagUseCase = assignNfcTagUseCase, - _updateHubUseCase = updateHubUseCase, - super(const ClientHubsState()) { + ClientHubsBloc({required GetHubsUseCase getHubsUseCase}) + : _getHubsUseCase = getHubsUseCase, + super(const ClientHubsState()) { on(_onFetched); - on(_onAddRequested); - on(_onUpdateRequested); - on(_onDeleteRequested); - on(_onNfcTagAssignRequested); on(_onMessageCleared); - on(_onAddDialogToggled); - on(_onIdentifyDialogToggled); } + final GetHubsUseCase _getHubsUseCase; - final CreateHubUseCase _createHubUseCase; - final DeleteHubUseCase _deleteHubUseCase; - final AssignNfcTagUseCase _assignNfcTagUseCase; - final UpdateHubUseCase _updateHubUseCase; - - void _onAddDialogToggled( - ClientHubsAddDialogToggled event, - Emitter emit, - ) { - emit(state.copyWith(showAddHubDialog: event.visible)); - } - - void _onIdentifyDialogToggled( - ClientHubsIdentifyDialogToggled event, - Emitter emit, - ) { - if (event.hub == null) { - emit(state.copyWith(clearHubToIdentify: true)); - } else { - emit(state.copyWith(hubToIdentify: event.hub)); - } - } Future _onFetched( ClientHubsFetched event, Emitter emit, ) async { emit(state.copyWith(status: ClientHubsStatus.loading)); - + await handleError( emit: emit.call, action: () async { - final List hubs = await _getHubsUseCase(); + final List hubs = await _getHubsUseCase.call(); emit(state.copyWith(status: ClientHubsStatus.success, hubs: hubs)); }, onError: (String errorKey) => state.copyWith( @@ -85,143 +41,6 @@ class ClientHubsBloc extends Bloc ); } - Future _onAddRequested( - ClientHubsAddRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit.call, - action: () async { - await _createHubUseCase( - CreateHubArguments( - name: event.name, - address: event.address, - placeId: event.placeId, - latitude: event.latitude, - longitude: event.longitude, - city: event.city, - state: event.state, - street: event.street, - country: event.country, - zipCode: event.zipCode, - costCenter: event.costCenter, - ), - ); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'Hub created successfully', - showAddHubDialog: false, - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - - Future _onUpdateRequested( - ClientHubsUpdateRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit.call, - action: () async { - await _updateHubUseCase( - UpdateHubArguments( - id: event.id, - name: event.name, - address: event.address, - placeId: event.placeId, - latitude: event.latitude, - longitude: event.longitude, - city: event.city, - state: event.state, - street: event.street, - country: event.country, - zipCode: event.zipCode, - costCenter: event.costCenter, - ), - ); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'Hub updated successfully!', - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - - Future _onDeleteRequested( - ClientHubsDeleteRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit.call, - action: () async { - await _deleteHubUseCase(DeleteHubArguments(hubId: event.hubId)); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'Hub deleted successfully', - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - - Future _onNfcTagAssignRequested( - ClientHubsNfcTagAssignRequested event, - Emitter emit, - ) async { - emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); - - await handleError( - emit: emit.call, - action: () async { - await _assignNfcTagUseCase( - AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), - ); - final List hubs = await _getHubsUseCase(); - emit( - state.copyWith( - status: ClientHubsStatus.actionSuccess, - hubs: hubs, - successMessage: 'NFC tag assigned successfully', - clearHubToIdentify: true, - ), - ); - }, - onError: (String errorKey) => state.copyWith( - status: ClientHubsStatus.actionFailure, - errorMessage: errorKey, - ), - ); - } - void _onMessageCleared( ClientHubsMessageCleared event, Emitter emit, @@ -231,8 +50,8 @@ class ClientHubsBloc extends Bloc clearErrorMessage: true, clearSuccessMessage: true, status: - state.status == ClientHubsStatus.actionSuccess || - state.status == ClientHubsStatus.actionFailure + state.status == ClientHubsStatus.success || + state.status == ClientHubsStatus.failure ? ClientHubsStatus.success : state.status, ), diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart index e3178d6e..f329807b 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart @@ -1,5 +1,4 @@ import 'package:equatable/equatable.dart'; -import 'package:krow_domain/krow_domain.dart'; /// Base class for all client hubs events. abstract class ClientHubsEvent extends Equatable { @@ -14,142 +13,7 @@ class ClientHubsFetched extends ClientHubsEvent { const ClientHubsFetched(); } -/// Event triggered to add a new hub. -class ClientHubsAddRequested extends ClientHubsEvent { - - const ClientHubsAddRequested({ - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - this.costCenter, - }); - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - final String? costCenter; - - @override - List get props => [ - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenter, - ]; -} - -/// Event triggered to update an existing hub. -class ClientHubsUpdateRequested extends ClientHubsEvent { - const ClientHubsUpdateRequested({ - required this.id, - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - this.costCenter, - }); - - final String id; - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - final String? costCenter; - - @override - List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - costCenter, - ]; -} - -/// Event triggered to delete a hub. -class ClientHubsDeleteRequested extends ClientHubsEvent { - - const ClientHubsDeleteRequested(this.hubId); - final String hubId; - - @override - List get props => [hubId]; -} - -/// Event triggered to assign an NFC tag to a hub. -class ClientHubsNfcTagAssignRequested extends ClientHubsEvent { - - const ClientHubsNfcTagAssignRequested({ - required this.hubId, - required this.nfcTagId, - }); - final String hubId; - final String nfcTagId; - - @override - List get props => [hubId, nfcTagId]; -} - /// Event triggered to clear any error or success messages. class ClientHubsMessageCleared extends ClientHubsEvent { const ClientHubsMessageCleared(); } - -/// Event triggered to toggle the visibility of the "Add Hub" dialog. -class ClientHubsAddDialogToggled extends ClientHubsEvent { - - const ClientHubsAddDialogToggled({required this.visible}); - final bool visible; - - @override - List get props => [visible]; -} - -/// Event triggered to toggle the visibility of the "Identify NFC" dialog. -class ClientHubsIdentifyDialogToggled extends ClientHubsEvent { - - const ClientHubsIdentifyDialogToggled({this.hub}); - final Hub? hub; - - @override - List get props => [hub]; -} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart index 1d1eea5d..8d9c0daa 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_state.dart @@ -2,47 +2,27 @@ import 'package:equatable/equatable.dart'; import 'package:krow_domain/krow_domain.dart'; /// Enum representing the status of the client hubs state. -enum ClientHubsStatus { - initial, - loading, - success, - failure, - actionInProgress, - actionSuccess, - actionFailure, -} +enum ClientHubsStatus { initial, loading, success, failure } /// State class for the ClientHubs BLoC. class ClientHubsState extends Equatable { - const ClientHubsState({ this.status = ClientHubsStatus.initial, this.hubs = const [], this.errorMessage, this.successMessage, - this.showAddHubDialog = false, - this.hubToIdentify, }); + final ClientHubsStatus status; final List hubs; final String? errorMessage; final String? successMessage; - /// Whether the "Add Hub" dialog should be visible. - final bool showAddHubDialog; - - /// The hub currently being identified/assigned an NFC tag. - /// If null, the identification dialog is closed. - final Hub? hubToIdentify; - ClientHubsState copyWith({ ClientHubsStatus? status, List? hubs, String? errorMessage, String? successMessage, - bool? showAddHubDialog, - Hub? hubToIdentify, - bool clearHubToIdentify = false, bool clearErrorMessage = false, bool clearSuccessMessage = false, }) { @@ -55,10 +35,6 @@ class ClientHubsState extends Equatable { successMessage: clearSuccessMessage ? null : (successMessage ?? this.successMessage), - showAddHubDialog: showAddHubDialog ?? this.showAddHubDialog, - hubToIdentify: clearHubToIdentify - ? null - : (hubToIdentify ?? this.hubToIdentify), ); } @@ -68,7 +44,5 @@ class ClientHubsState extends Equatable { hubs, errorMessage, successMessage, - showAddHubDialog, - hubToIdentify, ]; } 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 new file mode 100644 index 00000000..6923899a --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_bloc.dart @@ -0,0 +1,95 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import '../../../domain/arguments/create_hub_arguments.dart'; +import '../../../domain/usecases/create_hub_usecase.dart'; +import '../../../domain/usecases/update_hub_usecase.dart'; +import 'edit_hub_event.dart'; +import 'edit_hub_state.dart'; + +/// Bloc for creating and updating hubs. +class EditHubBloc extends Bloc + with BlocErrorHandler { + EditHubBloc({ + required CreateHubUseCase createHubUseCase, + required UpdateHubUseCase updateHubUseCase, + }) : _createHubUseCase = createHubUseCase, + _updateHubUseCase = updateHubUseCase, + super(const EditHubState()) { + on(_onAddRequested); + on(_onUpdateRequested); + } + + final CreateHubUseCase _createHubUseCase; + final UpdateHubUseCase _updateHubUseCase; + + Future _onAddRequested( + EditHubAddRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditHubStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _createHubUseCase.call( + CreateHubArguments( + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + ), + ); + emit( + state.copyWith( + status: EditHubStatus.success, + successMessage: 'Hub created successfully', + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey), + ); + } + + Future _onUpdateRequested( + EditHubUpdateRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: EditHubStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _updateHubUseCase.call( + UpdateHubArguments( + id: event.id, + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + ), + ); + emit( + state.copyWith( + status: EditHubStatus.success, + successMessage: 'Hub updated successfully', + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: EditHubStatus.failure, errorMessage: errorKey), + ); + } +} 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 new file mode 100644 index 00000000..65e18a83 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_event.dart @@ -0,0 +1,94 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all edit hub events. +abstract class EditHubEvent extends Equatable { + const EditHubEvent(); + + @override + List get props => []; +} + +/// Event triggered to add a new hub. +class EditHubAddRequested extends EditHubEvent { + const EditHubAddRequested({ + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +/// Event triggered to update an existing hub. +class EditHubUpdateRequested extends EditHubEvent { + const EditHubUpdateRequested({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} diff --git a/apps/mobile/packages/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 new file mode 100644 index 00000000..17bdffcd --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/edit_hub/edit_hub_state.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; + +/// Status of the edit hub operation. +enum EditHubStatus { + /// Initial state. + initial, + + /// Operation in progress. + loading, + + /// Operation succeeded. + success, + + /// Operation failed. + failure, +} + +/// State for the edit hub operation. +class EditHubState extends Equatable { + const EditHubState({ + this.status = EditHubStatus.initial, + this.errorMessage, + this.successMessage, + }); + + /// The status of the operation. + final EditHubStatus status; + + /// The error message if the operation failed. + final String? errorMessage; + + /// The success message if the operation succeeded. + final String? successMessage; + + /// Create a copy of this state with the given fields replaced. + EditHubState copyWith({ + EditHubStatus? status, + String? errorMessage, + String? successMessage, + }) { + return EditHubState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + successMessage: successMessage ?? this.successMessage, + ); + } + + @override + List get props => [status, errorMessage, successMessage]; +} 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 new file mode 100644 index 00000000..bda30551 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_bloc.dart @@ -0,0 +1,75 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import '../../../domain/arguments/assign_nfc_tag_arguments.dart'; +import '../../../domain/arguments/delete_hub_arguments.dart'; +import '../../../domain/usecases/assign_nfc_tag_usecase.dart'; +import '../../../domain/usecases/delete_hub_usecase.dart'; +import 'hub_details_event.dart'; +import 'hub_details_state.dart'; + +/// Bloc for managing hub details and operations like delete and NFC assignment. +class HubDetailsBloc extends Bloc + with BlocErrorHandler { + HubDetailsBloc({ + required DeleteHubUseCase deleteHubUseCase, + required AssignNfcTagUseCase assignNfcTagUseCase, + }) : _deleteHubUseCase = deleteHubUseCase, + _assignNfcTagUseCase = assignNfcTagUseCase, + super(const HubDetailsState()) { + on(_onDeleteRequested); + on(_onNfcTagAssignRequested); + } + + final DeleteHubUseCase _deleteHubUseCase; + final AssignNfcTagUseCase _assignNfcTagUseCase; + + Future _onDeleteRequested( + HubDetailsDeleteRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: HubDetailsStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _deleteHubUseCase.call(DeleteHubArguments(hubId: event.id)); + emit( + state.copyWith( + status: HubDetailsStatus.deleted, + successMessage: 'Hub deleted successfully', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: HubDetailsStatus.failure, + errorMessage: errorKey, + ), + ); + } + + Future _onNfcTagAssignRequested( + HubDetailsNfcTagAssignRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: HubDetailsStatus.loading)); + + await handleError( + emit: emit.call, + action: () async { + await _assignNfcTagUseCase.call( + AssignNfcTagArguments(hubId: event.hubId, nfcTagId: event.nfcTagId), + ); + emit( + state.copyWith( + status: HubDetailsStatus.success, + successMessage: 'NFC tag assigned successfully', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: HubDetailsStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart new file mode 100644 index 00000000..5c23da0b --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_event.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +/// Base class for all hub details events. +abstract class HubDetailsEvent extends Equatable { + const HubDetailsEvent(); + + @override + List get props => []; +} + +/// Event triggered to delete a hub. +class HubDetailsDeleteRequested extends HubDetailsEvent { + const HubDetailsDeleteRequested(this.id); + final String id; + + @override + List get props => [id]; +} + +/// Event triggered to assign an NFC tag to a hub. +class HubDetailsNfcTagAssignRequested extends HubDetailsEvent { + const HubDetailsNfcTagAssignRequested({ + required this.hubId, + required this.nfcTagId, + }); + + final String hubId; + final String nfcTagId; + + @override + List get props => [hubId, nfcTagId]; +} 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 new file mode 100644 index 00000000..f2c7f4c2 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/hub_details/hub_details_state.dart @@ -0,0 +1,53 @@ +import 'package:equatable/equatable.dart'; + +/// Status of the hub details operation. +enum HubDetailsStatus { + /// Initial state. + initial, + + /// Operation in progress. + loading, + + /// Operation succeeded. + success, + + /// Operation failed. + failure, + + /// Hub was deleted. + deleted, +} + +/// State for the hub details operation. +class HubDetailsState extends Equatable { + const HubDetailsState({ + this.status = HubDetailsStatus.initial, + this.errorMessage, + this.successMessage, + }); + + /// The status of the operation. + final HubDetailsStatus status; + + /// The error message if the operation failed. + final String? errorMessage; + + /// The success message if the operation succeeded. + final String? successMessage; + + /// Create a copy of this state with the given fields replaced. + HubDetailsState copyWith({ + HubDetailsStatus? status, + String? errorMessage, + String? successMessage, + }) { + return HubDetailsState( + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + successMessage: successMessage ?? this.successMessage, + ); + } + + @override + List get props => [status, errorMessage, successMessage]; +} 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 c8fdffed..1bcdb4ed 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 @@ -8,11 +8,10 @@ import 'package:krow_domain/krow_domain.dart'; import '../blocs/client_hubs_bloc.dart'; import '../blocs/client_hubs_event.dart'; import '../blocs/client_hubs_state.dart'; -import '../widgets/add_hub_dialog.dart'; + import '../widgets/hub_card.dart'; import '../widgets/hub_empty_state.dart'; import '../widgets/hub_info_card.dart'; -import '../widgets/identify_nfc_dialog.dart'; /// The main page for the client hubs feature. /// @@ -43,7 +42,8 @@ class ClientHubsPage extends StatelessWidget { context, ).add(const ClientHubsMessageCleared()); } - if (state.successMessage != null && state.successMessage!.isNotEmpty) { + if (state.successMessage != null && + state.successMessage!.isNotEmpty) { UiSnackbar.show( context, message: state.successMessage!, @@ -58,104 +58,67 @@ class ClientHubsPage extends StatelessWidget { return Scaffold( backgroundColor: UiColors.bgMenu, floatingActionButton: FloatingActionButton( - onPressed: () => BlocProvider.of( - context, - ).add(const ClientHubsAddDialogToggled(visible: true)), + 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: Stack( - children: [ - CustomScrollView( - slivers: [ - _buildAppBar(context), - SliverPadding( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space5, - vertical: UiConstants.space5, - ).copyWith(bottom: 100), - sliver: SliverList( - delegate: SliverChildListDelegate([ - if (state.status == ClientHubsStatus.loading) - const Center(child: CircularProgressIndicator()) - else if (state.hubs.isEmpty) - HubEmptyState( - onAddPressed: () => - BlocProvider.of(context).add( - const ClientHubsAddDialogToggled( - visible: true, - ), - ), - ) - else ...[ - ...state.hubs.map( - (Hub hub) => HubCard( - hub: hub, - onNfcPressed: () => - BlocProvider.of( - context, - ).add( - ClientHubsIdentifyDialogToggled(hub: hub), - ), - onDeletePressed: () => _confirmDeleteHub( - context, - hub, - ), - ), - ), - ], - const SizedBox(height: UiConstants.space5), - const HubInfoCard(), - ]), + body: CustomScrollView( + slivers: [ + _buildAppBar(context), + SliverPadding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space5, + vertical: UiConstants.space5, + ).copyWith(bottom: 100), + sliver: SliverList( + delegate: SliverChildListDelegate([ + const Padding( + padding: EdgeInsets.only(bottom: UiConstants.space5), + child: HubInfoCard(), ), - ), - ], + + if (state.status == ClientHubsStatus.loading) + const Center(child: CircularProgressIndicator()) + else if (state.hubs.isEmpty) + HubEmptyState( + onAddPressed: () async { + final bool? success = await Modular.to.toEditHub(); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + ) + else ...[ + ...state.hubs.map( + (Hub hub) => HubCard( + hub: hub, + onTap: () async { + final bool? success = await Modular.to + .toHubDetails(hub); + if (success == true && context.mounted) { + BlocProvider.of( + context, + ).add(const ClientHubsFetched()); + } + }, + ), + ), + ], + const SizedBox(height: UiConstants.space5), + ]), + ), ), - if (state.showAddHubDialog) - AddHubDialog( - onCreate: ( - String name, - String address, { - String? placeId, - double? latitude, - double? longitude, - }) { - BlocProvider.of(context).add( - ClientHubsAddRequested( - name: name, - address: address, - placeId: placeId, - latitude: latitude, - longitude: longitude, - ), - ); - }, - onCancel: () => BlocProvider.of( - context, - ).add(const ClientHubsAddDialogToggled(visible: false)), - ), - if (state.hubToIdentify != null) - IdentifyNfcDialog( - hub: state.hubToIdentify!, - onAssign: (String tagId) { - BlocProvider.of(context).add( - ClientHubsNfcTagAssignRequested( - hubId: state.hubToIdentify!.id, - nfcTagId: tagId, - ), - ); - }, - onCancel: () => BlocProvider.of( - context, - ).add(const ClientHubsIdentifyDialogToggled()), - ), - if (state.status == ClientHubsStatus.actionInProgress) - Container( - color: UiColors.black.withValues(alpha: 0.1), - child: const Center(child: CircularProgressIndicator()), - ), ], ), ); @@ -166,7 +129,7 @@ class ClientHubsPage extends StatelessWidget { Widget _buildAppBar(BuildContext context) { return SliverAppBar( - backgroundColor: UiColors.foreground, // Dark Slate equivalent + backgroundColor: UiColors.foreground, automaticallyImplyLeading: false, expandedHeight: 140, pinned: true, @@ -225,51 +188,4 @@ class ClientHubsPage extends StatelessWidget { ), ); } - - Future _confirmDeleteHub(BuildContext context, Hub hub) async { - final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name; - return showDialog( - context: context, - barrierDismissible: false, - builder: (BuildContext dialogContext) { - return AlertDialog( - title: Text(t.client_hubs.delete_dialog.title), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(t.client_hubs.delete_dialog.message(hubName: hubName)), - const SizedBox(height: UiConstants.space2), - Text(t.client_hubs.delete_dialog.undo_warning), - const SizedBox(height: UiConstants.space2), - Text( - t.client_hubs.delete_dialog.dependency_warning, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textSecondary, - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Modular.to.pop(), - child: Text(t.client_hubs.delete_dialog.cancel), - ), - TextButton( - onPressed: () { - BlocProvider.of( - context, - ).add(ClientHubsDeleteRequested(hub.id)); - Modular.to.pop(); - }, - style: TextButton.styleFrom( - foregroundColor: UiColors.destructive, - ), - child: Text(t.client_hubs.delete_dialog.delete), - ), - ], - ); - }, - ); - } } 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 d5031209..ea547ab2 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 @@ -2,28 +2,21 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; import 'package:google_places_flutter/model/prediction.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/client_hubs_bloc.dart'; -import '../blocs/client_hubs_event.dart'; -import '../blocs/client_hubs_state.dart'; -import '../widgets/hub_address_autocomplete.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'; -/// A dedicated full-screen page for editing an existing hub. -/// -/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the -/// updated hub list is reflected on the hubs list page when the user -/// saves and navigates back. +/// A dedicated full-screen page for adding or editing a hub. class EditHubPage extends StatefulWidget { - const EditHubPage({ - required this.hub, - required this.bloc, - super.key, - }); + const EditHubPage({this.hub, required this.bloc, super.key}); - final Hub hub; - final ClientHubsBloc bloc; + final Hub? hub; + final EditHubBloc bloc; @override State createState() => _EditHubPageState(); @@ -32,7 +25,6 @@ class EditHubPage extends StatefulWidget { class _EditHubPageState extends State { final GlobalKey _formKey = GlobalKey(); late final TextEditingController _nameController; - late final TextEditingController _costCenterController; late final TextEditingController _addressController; late final FocusNode _addressFocusNode; Prediction? _selectedPrediction; @@ -40,16 +32,18 @@ class _EditHubPageState extends State { @override void initState() { super.initState(); - _nameController = TextEditingController(text: widget.hub.name); - _costCenterController = TextEditingController(text: widget.hub.costCenter); - _addressController = TextEditingController(text: widget.hub.address); + _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(); - _costCenterController.dispose(); _addressController.dispose(); _addressFocusNode.dispose(); super.dispose(); @@ -67,38 +61,50 @@ class _EditHubPageState extends State { return; } - ReadContext(context).read().add( - ClientHubsUpdateRequested( - 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 ?? ''), - costCenter: _costCenterController.text.trim().isEmpty ? null : _costCenterController.text.trim(), - ), - ); + if (widget.hub == null) { + widget.bloc.add( + EditHubAddRequested( + name: _nameController.text.trim(), + address: _addressController.text.trim(), + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse(_selectedPrediction?.lat ?? ''), + longitude: double.tryParse(_selectedPrediction?.lng ?? ''), + ), + ); + } else { + widget.bloc.add( + EditHubUpdateRequested( + id: widget.hub!.id, + name: _nameController.text.trim(), + address: _addressController.text.trim(), + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse(_selectedPrediction?.lat ?? ''), + longitude: double.tryParse(_selectedPrediction?.lng ?? ''), + ), + ); + } } @override Widget build(BuildContext context) { - return BlocProvider.value( + return BlocProvider.value( value: widget.bloc, - child: BlocListener( - listenWhen: (ClientHubsState prev, ClientHubsState curr) => - prev.status != curr.status || prev.successMessage != curr.successMessage, - listener: (BuildContext context, ClientHubsState state) { - if (state.status == ClientHubsStatus.actionSuccess && + child: BlocListener( + listenWhen: (EditHubState prev, EditHubState curr) => + prev.status != curr.status || + prev.successMessage != curr.successMessage, + listener: (BuildContext context, EditHubState state) { + if (state.status == EditHubStatus.success && state.successMessage != null) { UiSnackbar.show( context, message: state.successMessage!, type: UiSnackbarType.success, ); - // Pop back to details page with updated hub - Navigator.of(context).pop(true); + // Pop back to the previous screen. + Modular.to.pop(true); } - if (state.status == ClientHubsStatus.actionFailure && + if (state.status == EditHubStatus.failure && state.errorMessage != null) { UiSnackbar.show( context, @@ -107,98 +113,43 @@ class _EditHubPageState extends State { ); } }, - child: BlocBuilder( - builder: (BuildContext context, ClientHubsState state) { - final bool isSaving = - state.status == ClientHubsStatus.actionInProgress; + child: BlocBuilder( + builder: (BuildContext context, EditHubState state) { + final bool isSaving = state.status == EditHubStatus.loading; return Scaffold( backgroundColor: UiColors.bgMenu, - appBar: AppBar( - backgroundColor: UiColors.foreground, - leading: IconButton( - icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), - onPressed: () => Navigator.of(context).pop(), - ), - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - t.client_hubs.edit_hub.title, - style: UiTypography.headline3m.white, - ), - Text( - t.client_hubs.edit_hub.subtitle, - style: UiTypography.footnote1r.copyWith( - color: UiColors.white.withValues(alpha: 0.7), - ), - ), - ], - ), + appBar: UiAppBar( + title: widget.hub == null + ? t.client_hubs.add_hub_dialog.title + : t.client_hubs.edit_hub.title, + subtitle: widget.hub == null + ? t.client_hubs.add_hub_dialog.create_button + : t.client_hubs.edit_hub.subtitle, + onLeadingPressed: () => Modular.to.pop(), ), body: Stack( children: [ SingleChildScrollView( - padding: const EdgeInsets.all(UiConstants.space5), - child: Form( - key: _formKey, - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // ── Name field ────────────────────────────────── - _FieldLabel(t.client_hubs.edit_hub.name_label), - TextFormField( - controller: _nameController, - style: UiTypography.body1r.textPrimary, - textInputAction: TextInputAction.next, - validator: (String? value) { - if (value == null || value.trim().isEmpty) { - return 'Name is required'; - } - return null; - }, - decoration: _inputDecoration( - t.client_hubs.edit_hub.name_hint, - ), - ), - - const SizedBox(height: UiConstants.space4), - - // ── Cost Center field ──────────────────────────── - _FieldLabel(t.client_hubs.edit_hub.cost_center_label), - TextFormField( - controller: _costCenterController, - style: UiTypography.body1r.textPrimary, - textInputAction: TextInputAction.next, - decoration: _inputDecoration( - t.client_hubs.edit_hub.cost_center_hint, - ), - ), - - const SizedBox(height: UiConstants.space4), - - // ── Address field ──────────────────────────────── - _FieldLabel(t.client_hubs.edit_hub.address_label), - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.edit_hub.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { + 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, ), - - const SizedBox(height: UiConstants.space8), - - // ── Save button ────────────────────────────────── - UiButton.primary( - onPressed: isSaving ? null : _onSave, - text: t.client_hubs.edit_hub.save_button, - ), - - const SizedBox(height: 40), - ], - ), + ), + ], ), ), @@ -216,42 +167,4 @@ class _EditHubPageState extends State { ), ); } - - InputDecoration _inputDecoration(String hint) { - return InputDecoration( - hintText: hint, - hintStyle: UiTypography.body2r.textPlaceholder, - filled: true, - fillColor: UiColors.input, - contentPadding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.ring, width: 2), - ), - ); - } -} - -class _FieldLabel extends StatelessWidget { - const _FieldLabel(this.text); - final String text; - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: UiConstants.space2), - child: Text(text, style: UiTypography.body2m.textPrimary), - ); - } } 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 2e40eac2..cbcf5d61 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 @@ -1,145 +1,134 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/client_hubs_bloc.dart'; -import 'edit_hub_page.dart'; +import '../blocs/hub_details/hub_details_bloc.dart'; +import '../blocs/hub_details/hub_details_event.dart'; +import '../blocs/hub_details/hub_details_state.dart'; +import '../widgets/hub_details/hub_details_bottom_actions.dart'; +import '../widgets/hub_details/hub_details_header.dart'; +import '../widgets/hub_details/hub_details_item.dart'; /// A read-only details page for a single [Hub]. /// /// Shows hub name, address, and NFC tag assignment. -/// Tapping the edit button navigates to [EditHubPage] (a dedicated page, -/// not a dialog), satisfying the "separate edit hub page" acceptance criterion. class HubDetailsPage extends StatelessWidget { - const HubDetailsPage({ - required this.hub, - required this.bloc, - super.key, - }); + const HubDetailsPage({required this.hub, required this.bloc, super.key}); final Hub hub; - final ClientHubsBloc bloc; + final HubDetailsBloc bloc; @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: Text(hub.name), - backgroundColor: UiColors.foreground, - leading: IconButton( - icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), - onPressed: () => Navigator.of(context).pop(), - ), - actions: [ - TextButton.icon( - onPressed: () => _navigateToEditPage(context), - icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16), - label: Text( - t.client_hubs.hub_details.edit_button, - style: const TextStyle(color: UiColors.white), - ), - ), - ], - ), - backgroundColor: UiColors.bgMenu, - body: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailItem( - label: t.client_hubs.hub_details.name_label, - value: hub.name, - icon: UiIcons.home, - ), - const SizedBox(height: UiConstants.space4), - _buildDetailItem( - label: t.client_hubs.hub_details.cost_center_label, - value: hub.costCenter?.isNotEmpty == true - ? hub.costCenter! - : t.client_hubs.hub_details.cost_center_none, - icon: UiIcons.dollarSign, // or UiIcons.building, hash, etc. - ), - const SizedBox(height: UiConstants.space4), - _buildDetailItem( - label: t.client_hubs.hub_details.address_label, - value: hub.address, - icon: UiIcons.mapPin, - ), - const SizedBox(height: UiConstants.space4), - _buildDetailItem( - label: t.client_hubs.hub_details.nfc_label, - value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned, - icon: UiIcons.nfc, - isHighlight: hub.nfcTagId != null, - ), - ], - ), - ), - ); - } + return BlocProvider.value( + value: bloc, + child: BlocListener( + listener: (BuildContext context, HubDetailsState state) { + if (state.status == HubDetailsStatus.deleted) { + UiSnackbar.show( + context, + message: state.successMessage ?? 'Hub deleted successfully', + type: UiSnackbarType.success, + ); + Modular.to.pop(true); // Return true to indicate change + } + if (state.status == HubDetailsStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: state.errorMessage!, + type: UiSnackbarType.error, + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, HubDetailsState state) { + final bool isLoading = state.status == HubDetailsStatus.loading; - Widget _buildDetailItem({ - required String label, - required String value, - required IconData icon, - bool isHighlight = false, - }) { - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow( - color: UiColors.popupShadow, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - ), - child: Icon( - icon, - color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary, - size: 20, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: UiTypography.footnote1r.textSecondary), - const SizedBox(height: UiConstants.space1), - Text(value, style: UiTypography.body1m.textPrimary), - ], - ), - ), - ], + return Scaffold( + appBar: const UiAppBar(showBackButton: true), + bottomNavigationBar: HubDetailsBottomActions( + isLoading: isLoading, + onDelete: () => _confirmDeleteHub(context), + onEdit: () => _navigateToEditPage(context), + ), + backgroundColor: UiColors.bgMenu, + body: Stack( + children: [ + SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Header ────────────────────────────────────────── + HubDetailsHeader(hub: hub), + const Divider(height: 1, thickness: 0.5), + + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + HubDetailsItem( + label: t.client_hubs.hub_details.nfc_label, + value: + hub.nfcTagId ?? + t.client_hubs.hub_details.nfc_not_assigned, + icon: UiIcons.nfc, + isHighlight: hub.nfcTagId != null, + ), + ], + ), + ), + ], + ), + ), + if (isLoading) + Container( + color: UiColors.black.withValues(alpha: 0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), ), ); } Future _navigateToEditPage(BuildContext context) async { - // Navigate to the dedicated edit page and await result. - // If the page returns `true` (save succeeded), pop the details page too so - // the user sees the refreshed hub list (the BLoC already holds updated data). - final bool? saved = await Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => EditHubPage(hub: hub, bloc: bloc), + final bool? saved = await Modular.to.toEditHub(hub: hub); + if (saved == true && context.mounted) { + Modular.to.pop(true); // Return true to indicate change + } + } + + Future _confirmDeleteHub(BuildContext context) async { + final bool? confirm = await showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: Text(t.client_hubs.delete_dialog.title), + content: Text(t.client_hubs.delete_dialog.message(hubName: hub.name)), + actions: [ + UiButton.text( + onPressed: () => Navigator.of(context).pop(false), + child: Text(t.client_hubs.delete_dialog.cancel), + ), + UiButton.text( + onPressed: () => Navigator.of(context).pop(true), + style: TextButton.styleFrom(foregroundColor: UiColors.destructive), + child: Text(t.client_hubs.delete_dialog.delete), + ), + ], ), ); - if (saved == true && context.mounted) { - Navigator.of(context).pop(); + + if (confirm == true) { + bloc.add(HubDetailsDeleteRequested(hub.id)); } } } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart deleted file mode 100644 index d141b995..00000000 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/add_hub_dialog.dart +++ /dev/null @@ -1,205 +0,0 @@ -import 'package:design_system/design_system.dart'; -import 'package:flutter/material.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:google_places_flutter/model/prediction.dart'; - -import 'hub_address_autocomplete.dart'; - -/// A dialog for adding a new hub. -class AddHubDialog extends StatefulWidget { - - /// Creates an [AddHubDialog]. - const AddHubDialog({ - required this.onCreate, - required this.onCancel, - super.key, - }); - /// Callback when the "Create Hub" button is pressed. - final void Function( - String name, - String address, { - String? placeId, - double? latitude, - double? longitude, - String? costCenter, - }) onCreate; - - /// Callback when the dialog is cancelled. - final VoidCallback onCancel; - - @override - State createState() => _AddHubDialogState(); -} - -class _AddHubDialogState extends State { - late final TextEditingController _nameController; - late final TextEditingController _costCenterController; - late final TextEditingController _addressController; - late final FocusNode _addressFocusNode; - Prediction? _selectedPrediction; - - @override - void initState() { - super.initState(); - _nameController = TextEditingController(); - _costCenterController = TextEditingController(); - _addressController = TextEditingController(); - _addressFocusNode = FocusNode(); - } - - @override - void dispose() { - _nameController.dispose(); - _costCenterController.dispose(); - _addressController.dispose(); - _addressFocusNode.dispose(); - super.dispose(); - } - - final GlobalKey _formKey = GlobalKey(); - - @override - Widget build(BuildContext context) { - 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), - ], - ), - child: Form( - key: _formKey, - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - t.client_hubs.add_hub_dialog.title, - style: UiTypography.headline3m.textPrimary, - ), - 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.space4), - _buildFieldLabel(t.client_hubs.add_hub_dialog.cost_center_label), - TextFormField( - controller: _costCenterController, - style: UiTypography.body1r.textPrimary, - decoration: _buildInputDecoration( - t.client_hubs.add_hub_dialog.cost_center_hint, - ), - textInputAction: TextInputAction.next, - ), - const SizedBox(height: UiConstants.space4), - _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), - // Assuming HubAddressAutocomplete is a custom widget wrapper. - // If it doesn't expose a validator, we might need to modify it or manually check _addressController. - // For now, let's just make sure we validate name. Address is tricky if it's a wrapper. - HubAddressAutocomplete( - controller: _addressController, - hintText: t.client_hubs.add_hub_dialog.address_hint, - focusNode: _addressFocusNode, - onSelected: (Prediction prediction) { - _selectedPrediction = prediction; - }, - ), - const SizedBox(height: UiConstants.space8), - Row( - children: [ - Expanded( - child: UiButton.secondary( - onPressed: widget.onCancel, - text: t.common.cancel, - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: UiButton.primary( - onPressed: () { - if (_formKey.currentState!.validate()) { - // Manually check address if needed, or assume manual entry is ok. - if (_addressController.text.trim().isEmpty) { - // Show manual error or scaffold - UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); - return; - } - - widget.onCreate( - _nameController.text, - _addressController.text, - placeId: _selectedPrediction?.placeId, - latitude: double.tryParse( - _selectedPrediction?.lat ?? '', - ), - longitude: double.tryParse( - _selectedPrediction?.lng ?? '', - ), - costCenter: _costCenterController.text.trim().isEmpty ? null : _costCenterController.text.trim(), - ); - } - }, - text: t.client_hubs.add_hub_dialog.create_button, - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - ); - } - - 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, - filled: true, - fillColor: UiColors.input, - contentPadding: const EdgeInsets.symmetric( - horizontal: UiConstants.space4, - vertical: 14, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - borderSide: const BorderSide(color: UiColors.ring, width: 2), - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart new file mode 100644 index 00000000..7cd617a2 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_field_label.dart @@ -0,0 +1,17 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A simple field label widget for the edit hub page. +class EditHubFieldLabel extends StatelessWidget { + const EditHubFieldLabel(this.text, {super.key}); + + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(text, style: UiTypography.body2m.textPrimary), + ); + } +} 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 new file mode 100644 index 00000000..b874dd3b --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/edit_hub/edit_hub_form_section.dart @@ -0,0 +1,105 @@ +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 '../hub_address_autocomplete.dart'; +import 'edit_hub_field_label.dart'; + +/// The form section for adding or editing a hub. +class EditHubFormSection extends StatelessWidget { + const EditHubFormSection({ + required this.formKey, + required this.nameController, + required this.addressController, + required this.addressFocusNode, + required this.onAddressSelected, + required this.onSave, + this.isSaving = false, + this.isEdit = false, + super.key, + }); + + final GlobalKey formKey; + final TextEditingController nameController; + final TextEditingController addressController; + final FocusNode addressFocusNode; + final ValueChanged onAddressSelected; + final VoidCallback onSave; + final bool isSaving; + final bool isEdit; + + @override + Widget build(BuildContext context) { + return Form( + key: formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Name field ────────────────────────────────── + EditHubFieldLabel(t.client_hubs.edit_hub.name_label), + TextFormField( + controller: nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _inputDecoration(t.client_hubs.edit_hub.name_hint), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Address field ──────────────────────────────── + EditHubFieldLabel(t.client_hubs.edit_hub.address_label), + HubAddressAutocomplete( + controller: addressController, + hintText: t.client_hubs.edit_hub.address_hint, + focusNode: addressFocusNode, + onSelected: onAddressSelected, + ), + + const SizedBox(height: UiConstants.space8), + + // ── Save button ────────────────────────────────── + UiButton.primary( + onPressed: isSaving ? null : onSave, + text: isEdit + ? t.client_hubs.edit_hub.save_button + : t.client_hubs.add_hub_dialog.create_button, + ), + + const SizedBox(height: 40), + ], + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart index 812be35b..eb6b1aba 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_card.dart @@ -5,115 +5,95 @@ import 'package:core_localization/core_localization.dart'; /// A card displaying information about a single hub. class HubCard extends StatelessWidget { - /// Creates a [HubCard]. - const HubCard({ - required this.hub, - required this.onNfcPressed, - required this.onDeletePressed, - super.key, - }); + const HubCard({required this.hub, required this.onTap, super.key}); + /// The hub to display. final Hub hub; - /// Callback when the NFC button is pressed. - final VoidCallback onNfcPressed; - - /// Callback when the delete button is pressed. - final VoidCallback onDeletePressed; + /// Callback when the card is tapped. + final VoidCallback onTap; @override Widget build(BuildContext context) { final bool hasNfc = hub.nfcTagId != null; - return Container( - margin: const EdgeInsets.only(bottom: UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ - BoxShadow( - color: UiColors.popupShadow, - blurRadius: 10, - offset: Offset(0, 4), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), - child: Row( - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - color: UiColors.tagInProgress, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: UiColors.tagInProgress, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + hasNfc ? UiIcons.success : UiIcons.nfc, + color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird, + size: 24, + ), ), - child: Icon( - hasNfc ? UiIcons.success : UiIcons.nfc, - color: hasNfc ? UiColors.iconSuccess : UiColors.iconThird, - size: 24, - ), - ), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(hub.name, style: UiTypography.body1b.textPrimary), - if (hub.address.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: UiConstants.space1), - child: Row( - children: [ - const Icon( - UiIcons.mapPin, - size: 12, - color: UiColors.iconThird, - ), - const SizedBox(width: UiConstants.space1), - Expanded( - child: Text( - hub.address, - style: UiTypography.footnote1r.textSecondary, - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(hub.name, style: UiTypography.body1b.textPrimary), + if (hub.address.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + UiIcons.mapPin, + size: 12, + color: UiColors.iconThird, ), - ), - ], - ), - ), - if (hasNfc) - Padding( - padding: const EdgeInsets.only(top: UiConstants.space1), - child: Text( - t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), - style: UiTypography.footnote1b.copyWith( - color: UiColors.textSuccess, - fontFamily: 'monospace', + const SizedBox(width: UiConstants.space1), + Flexible( + child: Text( + hub.address, + style: UiTypography.footnote1r.textSecondary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], ), ), - ), - ], - ), - ), - Row( - children: [ - IconButton( - onPressed: onDeletePressed, - icon: const Icon( - UiIcons.delete, - color: UiColors.destructive, - size: 20, - ), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - splashRadius: 20, + if (hasNfc) + Padding( + padding: const EdgeInsets.only(top: UiConstants.space1), + child: Text( + t.client_hubs.hub_card.tag_label(id: hub.nfcTagId!), + style: UiTypography.footnote1b.copyWith( + color: UiColors.textSuccess, + fontFamily: 'monospace', + ), + ), + ), + ], ), - ], - ), - ], + ), + const Icon( + UiIcons.chevronRight, + size: 16, + color: UiColors.iconSecondary, + ), + ], + ), ), ), ); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart new file mode 100644 index 00000000..d109c6bc --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_bottom_actions.dart @@ -0,0 +1,55 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// Bottom action buttons for the hub details page. +class HubDetailsBottomActions extends StatelessWidget { + const HubDetailsBottomActions({ + required this.onDelete, + required this.onEdit, + this.isLoading = false, + super.key, + }); + + final VoidCallback onDelete; + final VoidCallback onEdit; + final bool isLoading; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Divider(height: 1, thickness: 0.5), + Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: isLoading ? null : onDelete, + text: t.common.delete, + leadingIcon: UiIcons.delete, + style: OutlinedButton.styleFrom( + foregroundColor: UiColors.destructive, + side: const BorderSide(color: UiColors.destructive), + ), + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: UiButton.secondary( + onPressed: isLoading ? null : onEdit, + text: t.client_hubs.hub_details.edit_button, + leadingIcon: UiIcons.edit, + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart new file mode 100644 index 00000000..ccf670ed --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_header.dart @@ -0,0 +1,45 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Header widget for the hub details page. +class HubDetailsHeader extends StatelessWidget { + const HubDetailsHeader({required this.hub, super.key}); + + final Hub hub; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: IntrinsicHeight( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + spacing: UiConstants.space1, + children: [ + Text(hub.name, style: UiTypography.headline1b.textPrimary), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 16, + color: UiColors.textSecondary, + ), + const SizedBox(width: UiConstants.space1), + Expanded( + child: Text( + hub.address, + style: UiTypography.body2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart new file mode 100644 index 00000000..9a087669 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_details/hub_details_item.dart @@ -0,0 +1,59 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +/// A reusable detail item for the hub details page. +class HubDetailsItem extends StatelessWidget { + const HubDetailsItem({ + required this.label, + required this.value, + required this.icon, + this.isHighlight = false, + super.key, + }); + + final String label; + final String value; + final IconData icon; + final bool isHighlight; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: isHighlight + ? UiColors.tagInProgress + : UiColors.bgInputField, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + icon, + color: isHighlight ? UiColors.iconSuccess : UiColors.iconThird, + size: 20, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), + const SizedBox(height: UiConstants.space1), + Text(value, style: UiTypography.body1m.textPrimary), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart index 013e533c..634d9029 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_info_card.dart @@ -31,10 +31,7 @@ class HubInfoCard extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( t.client_hubs.about_hubs.description, - style: UiTypography.footnote1r.copyWith( - color: UiColors.textSecondary, - height: 1.4, - ), + style: UiTypography.footnote1r.textSecondary, ), ], ), 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 0e702c33..7db4d5ab 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 @@ -4,6 +4,7 @@ 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 primary actions for the settings page. @@ -27,10 +28,6 @@ class SettingsActions extends StatelessWidget { _QuickLinksCard(labels: labels), const SizedBox(height: UiConstants.space4), - // Notifications section - _NotificationsSettingsCard(), - const SizedBox(height: UiConstants.space4), - // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { @@ -80,15 +77,14 @@ class SettingsActions extends StatelessWidget { /// Handles the sign-out button click event. void _onSignoutClicked(BuildContext context) { - ReadContext(context) - .read() - .add(const ClientSettingsSignOutRequested()); + ReadContext( + context, + ).read().add(const ClientSettingsSignOutRequested()); } } /// Quick Links card — inline here since it's always part of SettingsActions ordering. class _QuickLinksCard extends StatelessWidget { - const _QuickLinksCard({required this.labels}); final TranslationsClientSettingsProfileEn labels; @@ -130,7 +126,6 @@ class _QuickLinksCard extends StatelessWidget { /// A single quick link row item. class _QuickLinkItem extends StatelessWidget { - const _QuickLinkItem({ required this.icon, required this.title, @@ -198,24 +193,36 @@ class _NotificationsSettingsCard extends StatelessWidget { icon: UiIcons.bell, title: context.t.client_settings.preferences.push, value: state.pushEnabled, - onChanged: (val) => ReadContext(context).read().add( - ClientSettingsNotificationToggled(type: 'push', isEnabled: val), + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'push', + isEnabled: val, + ), ), ), _NotificationToggle( icon: UiIcons.mail, title: context.t.client_settings.preferences.email, value: state.emailEnabled, - onChanged: (val) => ReadContext(context).read().add( - ClientSettingsNotificationToggled(type: 'email', isEnabled: val), + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'email', + isEnabled: val, + ), ), ), _NotificationToggle( icon: UiIcons.phone, title: context.t.client_settings.preferences.sms, value: state.smsEnabled, - onChanged: (val) => ReadContext(context).read().add( - ClientSettingsNotificationToggled(type: 'sms', isEnabled: val), + onChanged: (val) => + ReadContext(context).read().add( + ClientSettingsNotificationToggled( + type: 'sms', + isEnabled: val, + ), ), ), ], 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 61dbf227..c6987214 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 @@ -12,7 +12,8 @@ class SettingsProfileHeader extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; + final TranslationsClientSettingsProfileEn labels = + t.client_settings.profile; final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final String businessName = session?.business?.businessName ?? 'Your Company'; @@ -26,9 +27,7 @@ class SettingsProfileHeader extends StatelessWidget { child: Container( width: double.infinity, padding: const EdgeInsets.only(bottom: 36), - decoration: const BoxDecoration( - color: UiColors.primary, - ), + decoration: const BoxDecoration(color: UiColors.primary), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -75,13 +74,6 @@ class SettingsProfileHeader extends StatelessWidget { color: UiColors.white.withValues(alpha: 0.6), width: 3, ), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.15), - blurRadius: 16, - offset: const Offset(0, 6), - ), - ], ), child: ClipOval( child: photoUrl != null && photoUrl.isNotEmpty @@ -103,9 +95,7 @@ class SettingsProfileHeader extends StatelessWidget { // ── Business Name ───────────────────────────────── Text( businessName, - style: UiTypography.headline3m.copyWith( - color: UiColors.white, - ), + style: UiTypography.headline3m.copyWith(color: UiColors.white), ), const SizedBox(height: UiConstants.space2), @@ -128,21 +118,6 @@ class SettingsProfileHeader extends StatelessWidget { ), ], ), - const SizedBox(height: UiConstants.space5), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 100), - child: UiButton.secondary( - text: labels.edit_profile, - size: UiButtonSize.small, - onPressed: () => - Modular.to.pushNamed('${ClientPaths.settings}/edit-profile'), - style: OutlinedButton.styleFrom( - foregroundColor: UiColors.white, - side: const BorderSide(color: UiColors.white, width: 1.5), - backgroundColor: UiColors.white.withValues(alpha: 0.1), - ), - ), - ), ], ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 6676c3be..a567b5e9 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -121,26 +121,14 @@ class HomeRepositoryImpl .listBenefitsDataByStaffId(staffId: staffId) .execute(); - final List results = response.data.benefitsDatas.map((data) { + return response.data.benefitsDatas.map((data) { final plan = data.vendorBenefitPlan; - final total = plan.total?.toDouble() ?? 0.0; - final current = data.current.toDouble(); return Benefit( title: plan.title, - entitlementHours: total, - usedHours: total - current, + entitlementHours: plan.total?.toDouble() ?? 0.0, + usedHours: data.current.toDouble(), ); }).toList(); - - // Fallback for verification if DB is empty - if (results.isEmpty) { - return [ - const Benefit(title: 'Sick Days', entitlementHours: 40, usedHours: 30), // 10 remaining - const Benefit(title: 'Vacation', entitlementHours: 40, usedHours: 0), // 40 remaining - const Benefit(title: 'Holidays', entitlementHours: 24, usedHours: 0), // 24 remaining - ]; - } - return results; }); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index c8454d76..271bc46c 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -22,9 +22,36 @@ class BenefitsOverviewPage extends StatelessWidget { appBar: _buildAppBar(context), body: BlocBuilder( builder: (context, state) { + if (state.status == HomeStatus.loading || + state.status == HomeStatus.initial) { + return const Center(child: CircularProgressIndicator()); + } + + if (state.status == HomeStatus.error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Text( + state.errorMessage ?? t.staff.home.benefits.overview.subtitle, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + ), + ); + } + final benefits = state.benefits; if (benefits.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return Center( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space6), + child: Text( + t.staff.home.benefits.overview.subtitle, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + ), + ); } return ListView.builder( @@ -32,7 +59,7 @@ class BenefitsOverviewPage extends StatelessWidget { left: UiConstants.space4, right: UiConstants.space4, top: UiConstants.space6, - bottom: 120, // Extra padding for bottom navigation and safe area + bottom: 120, ), itemCount: benefits.length, itemBuilder: (context, index) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart index ece3bc18..327e58ea 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/widgets/sections/onboarding_section.dart @@ -21,7 +21,9 @@ class OnboardingSection extends StatelessWidget { @override Widget build(BuildContext context) { - final TranslationsStaffProfileEn i18n = Translations.of(context).staff.profile; + final TranslationsStaffProfileEn i18n = Translations.of( + context, + ).staff.profile; return BlocBuilder( builder: (BuildContext context, ProfileState state) { @@ -49,6 +51,11 @@ class OnboardingSection extends StatelessWidget { completed: state.experienceComplete, onTap: () => Modular.to.toExperience(), ), + ProfileMenuItem( + icon: UiIcons.shirt, + label: i18n.menu_items.attire, + onTap: () => Modular.to.toAttire(), + ), ], ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index 7937e0c1..eb32cf88 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,12 +1,13 @@ import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'data/repositories_impl/attire_repository_impl.dart'; import 'domain/repositories/attire_repository.dart'; import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart'; -import 'presentation/blocs/attire_cubit.dart'; import 'presentation/pages/attire_page.dart'; class StaffAttireModule extends Module { @@ -19,9 +20,10 @@ class StaffAttireModule extends Module { i.addLazySingleton(GetAttireOptionsUseCase.new); i.addLazySingleton(SaveAttireUseCase.new); i.addLazySingleton(UploadAttirePhotoUseCase.new); - + // BLoC i.addLazySingleton(AttireCubit.new); + i.add(AttireCaptureCubit.new); } @override diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index 704dab96..727c8f77 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -1,4 +1,3 @@ -import 'package:firebase_data_connect/firebase_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_domain/krow_domain.dart'; @@ -6,30 +5,19 @@ import '../../domain/repositories/attire_repository.dart'; /// Implementation of [AttireRepository]. /// -/// Delegates data access to [DataConnectService]. +/// Delegates data access to [StaffConnectorRepository]. class AttireRepositoryImpl implements AttireRepository { - /// Creates an [AttireRepositoryImpl]. - AttireRepositoryImpl({DataConnectService? service}) - : _service = service ?? DataConnectService.instance; - /// The Data Connect service. - final DataConnectService _service; + AttireRepositoryImpl({StaffConnectorRepository? connector}) + : _connector = + connector ?? DataConnectService.instance.getStaffRepository(); + + /// The Staff Connector repository. + final StaffConnectorRepository _connector; @override Future> getAttireOptions() async { - return _service.run(() async { - final QueryResult result = - await _service.connector.listAttireOptions().execute(); - return result.data.attireOptions - .map((ListAttireOptionsAttireOptions e) => AttireItem( - id: e.itemId, - label: e.label, - iconName: e.icon, - imageUrl: e.imageUrl, - isMandatory: e.isMandatory ?? false, - )) - .toList(); - }); + return _connector.getAttireOptions(); } @override @@ -37,16 +25,22 @@ class AttireRepositoryImpl implements AttireRepository { required List selectedItemIds, required Map photoUrls, }) async { - // TODO: Connect to actual backend mutation when available. - // For now, simulate network delay as per prototype behavior. - await Future.delayed(const Duration(seconds: 1)); + // We already upsert photos in uploadPhoto (to follow the new flow). + // This could save selections if there was a separate "SelectedAttire" table. + // For now, it's a no-op as the source of truth is the StaffAttire table. } @override Future uploadPhoto(String itemId) async { - // TODO: Connect to actual storage service/mutation when available. - // For now, simulate upload delay and return mock URL. - await Future.delayed(const Duration(seconds: 1)); - return 'mock_url_for_$itemId'; + // In a real app, this would upload to Firebase Storage first. + // Since the prototype returns a mock URL, we'll use that to upsert our record. + final String mockUrl = 'mock_url_for_$itemId'; + + await _connector.upsertStaffAttire( + attireOptionId: itemId, + photoUrl: mockUrl, + ); + + return mockUrl; } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart new file mode 100644 index 00000000..ce9862d5 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -0,0 +1,91 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/domain/arguments/save_attire_arguments.dart'; +import 'package:staff_attire/src/domain/usecases/get_attire_options_usecase.dart'; +import 'package:staff_attire/src/domain/usecases/save_attire_usecase.dart'; + +import 'attire_state.dart'; + +class AttireCubit extends Cubit + with BlocErrorHandler { + AttireCubit(this._getAttireOptionsUseCase, this._saveAttireUseCase) + : super(const AttireState()) { + loadOptions(); + } + final GetAttireOptionsUseCase _getAttireOptionsUseCase; + final SaveAttireUseCase _saveAttireUseCase; + + Future loadOptions() async { + emit(state.copyWith(status: AttireStatus.loading)); + await handleError( + emit: emit, + action: () async { + final List options = await _getAttireOptionsUseCase(); + + // Extract photo URLs and selection status from backend data + final Map photoUrls = {}; + final List selectedIds = []; + + for (final AttireItem item in options) { + if (item.photoUrl != null) { + photoUrls[item.id] = item.photoUrl!; + } + // If mandatory or has photo, consider it selected initially + if (item.isMandatory || item.photoUrl != null) { + selectedIds.add(item.id); + } + } + + emit( + state.copyWith( + status: AttireStatus.success, + options: options, + selectedIds: selectedIds, + photoUrls: photoUrls, + ), + ); + }, + onError: (String errorKey) => + state.copyWith(status: AttireStatus.failure, errorMessage: errorKey), + ); + } + + void toggleSelection(String id) { + // Prevent unselecting mandatory items + if (state.isMandatory(id)) return; + + final List currentSelection = List.from(state.selectedIds); + if (currentSelection.contains(id)) { + currentSelection.remove(id); + } else { + currentSelection.add(id); + } + emit(state.copyWith(selectedIds: currentSelection)); + } + + void syncCapturedPhoto(String itemId, String url) { + // When a photo is captured, we refresh the options to get the updated status from backend + loadOptions(); + } + + Future save() async { + if (!state.canSave) return; + + emit(state.copyWith(status: AttireStatus.saving)); + await handleError( + emit: emit, + action: () async { + await _saveAttireUseCase( + SaveAttireArguments( + selectedItemIds: state.selectedIds, + photoUrls: state.photoUrls, + ), + ); + emit(state.copyWith(status: AttireStatus.saved)); + }, + onError: (String errorKey) => + state.copyWith(status: AttireStatus.failure, errorMessage: errorKey), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart similarity index 59% rename from apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart rename to apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index 179ff3f0..3d882c07 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -4,51 +4,51 @@ import 'package:krow_domain/krow_domain.dart'; enum AttireStatus { initial, loading, success, failure, saving, saved } class AttireState extends Equatable { - const AttireState({ this.status = AttireStatus.initial, this.options = const [], this.selectedIds = const [], this.photoUrls = const {}, - this.uploadingStatus = const {}, - this.attestationChecked = false, this.errorMessage, }); final AttireStatus status; final List options; final List selectedIds; final Map photoUrls; - final Map uploadingStatus; - final bool attestationChecked; final String? errorMessage; - bool get uploading => uploadingStatus.values.any((bool u) => u); - /// Helper to check if item is mandatory bool isMandatory(String id) { - return options.firstWhere((AttireItem e) => e.id == id, orElse: () => const AttireItem(id: '', label: '')).isMandatory; + return options + .firstWhere( + (AttireItem e) => e.id == id, + orElse: () => const AttireItem(id: '', label: ''), + ) + .isMandatory; } /// Validation logic bool get allMandatorySelected { - final Iterable mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); + final Iterable mandatoryIds = options + .where((AttireItem e) => e.isMandatory) + .map((AttireItem e) => e.id); return mandatoryIds.every((String id) => selectedIds.contains(id)); } bool get allMandatoryHavePhotos { - final Iterable mandatoryIds = options.where((AttireItem e) => e.isMandatory).map((AttireItem e) => e.id); + final Iterable mandatoryIds = options + .where((AttireItem e) => e.isMandatory) + .map((AttireItem e) => e.id); return mandatoryIds.every((String id) => photoUrls.containsKey(id)); } - bool get canSave => allMandatorySelected && allMandatoryHavePhotos && attestationChecked && !uploading; + bool get canSave => allMandatorySelected && allMandatoryHavePhotos; AttireState copyWith({ AttireStatus? status, List? options, List? selectedIds, Map? photoUrls, - Map? uploadingStatus, - bool? attestationChecked, String? errorMessage, }) { return AttireState( @@ -56,20 +56,16 @@ class AttireState extends Equatable { options: options ?? this.options, selectedIds: selectedIds ?? this.selectedIds, photoUrls: photoUrls ?? this.photoUrls, - uploadingStatus: uploadingStatus ?? this.uploadingStatus, - attestationChecked: attestationChecked ?? this.attestationChecked, errorMessage: errorMessage, ); } @override List get props => [ - status, - options, - selectedIds, - photoUrls, - uploadingStatus, - attestationChecked, - errorMessage - ]; + status, + options, + selectedIds, + photoUrls, + errorMessage, + ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart new file mode 100644 index 00000000..884abb37 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -0,0 +1,39 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_core/core.dart'; +import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; +import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; + +import 'attire_capture_state.dart'; + +class AttireCaptureCubit extends Cubit + with BlocErrorHandler { + AttireCaptureCubit(this._uploadAttirePhotoUseCase) + : super(const AttireCaptureState()); + + final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase; + + void toggleAttestation(bool value) { + emit(state.copyWith(isAttested: value)); + } + + Future uploadPhoto(String itemId) async { + emit(state.copyWith(status: AttireCaptureStatus.uploading)); + + await handleError( + emit: emit, + action: () async { + final String url = await _uploadAttirePhotoUseCase( + UploadAttirePhotoArguments(itemId: itemId), + ); + + emit( + state.copyWith(status: AttireCaptureStatus.success, photoUrl: url), + ); + }, + onError: (String errorKey) => state.copyWith( + status: AttireCaptureStatus.failure, + errorMessage: errorKey, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart new file mode 100644 index 00000000..6b776816 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; + +enum AttireCaptureStatus { initial, uploading, success, failure } + +class AttireCaptureState extends Equatable { + const AttireCaptureState({ + this.status = AttireCaptureStatus.initial, + this.isAttested = false, + this.photoUrl, + this.errorMessage, + }); + + final AttireCaptureStatus status; + final bool isAttested; + final String? photoUrl; + final String? errorMessage; + + AttireCaptureState copyWith({ + AttireCaptureStatus? status, + bool? isAttested, + String? photoUrl, + String? errorMessage, + }) { + return AttireCaptureState( + status: status ?? this.status, + isAttested: isAttested ?? this.isAttested, + photoUrl: photoUrl ?? this.photoUrl, + errorMessage: errorMessage, + ); + } + + @override + List get props => [ + status, + isAttested, + photoUrl, + errorMessage, + ]; +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart deleted file mode 100644 index a184ea56..00000000 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_cubit.dart +++ /dev/null @@ -1,160 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:krow_core/core.dart'; -import 'package:krow_domain/krow_domain.dart'; - -import '../../domain/arguments/save_attire_arguments.dart'; -import '../../domain/arguments/upload_attire_photo_arguments.dart'; -import '../../domain/usecases/get_attire_options_usecase.dart'; -import '../../domain/usecases/save_attire_usecase.dart'; -import '../../domain/usecases/upload_attire_photo_usecase.dart'; -import 'attire_state.dart'; - -class AttireCubit extends Cubit - with BlocErrorHandler { - - AttireCubit( - this._getAttireOptionsUseCase, - this._saveAttireUseCase, - this._uploadAttirePhotoUseCase, - ) : super(const AttireState()) { - loadOptions(); - } - final GetAttireOptionsUseCase _getAttireOptionsUseCase; - final SaveAttireUseCase _saveAttireUseCase; - final UploadAttirePhotoUseCase _uploadAttirePhotoUseCase; - - Future loadOptions() async { - emit(state.copyWith(status: AttireStatus.loading)); - await handleError( - emit: emit, - action: () async { - final List options = await _getAttireOptionsUseCase(); - - // Auto-select mandatory items initially as per prototype - final List mandatoryIds = - options - .where((AttireItem e) => e.isMandatory) - .map((AttireItem e) => e.id) - .toList(); - - final List initialSelection = List.from( - state.selectedIds, - ); - for (final String id in mandatoryIds) { - if (!initialSelection.contains(id)) { - initialSelection.add(id); - } - } - - emit( - state.copyWith( - status: AttireStatus.success, - options: options, - selectedIds: initialSelection, - ), - ); - }, - onError: - (String errorKey) => state.copyWith( - status: AttireStatus.failure, - errorMessage: errorKey, - ), - ); - } - - void toggleSelection(String id) { - // Prevent unselecting mandatory items - if (state.isMandatory(id)) return; - - final List currentSelection = List.from(state.selectedIds); - if (currentSelection.contains(id)) { - currentSelection.remove(id); - } else { - currentSelection.add(id); - } - emit(state.copyWith(selectedIds: currentSelection)); - } - - void toggleAttestation(bool value) { - emit(state.copyWith(attestationChecked: value)); - } - - Future uploadPhoto(String itemId) async { - final Map currentUploading = Map.from( - state.uploadingStatus, - ); - currentUploading[itemId] = true; - emit(state.copyWith(uploadingStatus: currentUploading)); - - await handleError( - emit: emit, - action: () async { - final String url = await _uploadAttirePhotoUseCase( - UploadAttirePhotoArguments(itemId: itemId), - ); - - final Map currentPhotos = Map.from( - state.photoUrls, - ); - currentPhotos[itemId] = url; - - // Auto-select item on upload success if not selected - final List currentSelection = List.from( - state.selectedIds, - ); - if (!currentSelection.contains(itemId)) { - currentSelection.add(itemId); - } - - final Map updatedUploading = Map.from( - state.uploadingStatus, - ); - updatedUploading[itemId] = false; - - emit( - state.copyWith( - uploadingStatus: updatedUploading, - photoUrls: currentPhotos, - selectedIds: currentSelection, - ), - ); - }, - onError: (String errorKey) { - final Map updatedUploading = Map.from( - state.uploadingStatus, - ); - updatedUploading[itemId] = false; - // Could handle error specifically via snackbar event - // For now, attaching the error message but keeping state generally usable - return state.copyWith( - uploadingStatus: updatedUploading, - errorMessage: errorKey, - ); - }, - ); - } - - Future save() async { - if (!state.canSave) return; - - emit(state.copyWith(status: AttireStatus.saving)); - await handleError( - emit: emit, - action: () async { - await _saveAttireUseCase( - SaveAttireArguments( - selectedItemIds: state.selectedIds, - photoUrls: state.photoUrls, - ), - ); - emit(state.copyWith(status: AttireStatus.saved)); - }, - onError: - (String errorKey) => state.copyWith( - status: AttireStatus.failure, - errorMessage: errorKey, - ), - ); - } -} - diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart new file mode 100644 index 00000000..5585f500 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -0,0 +1,206 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; + +import '../widgets/attestation_checkbox.dart'; +import '../widgets/attire_capture_page/attire_image_preview.dart'; +import '../widgets/attire_capture_page/attire_upload_buttons.dart'; +import '../widgets/attire_capture_page/attire_verification_status_card.dart'; + +class AttireCapturePage extends StatefulWidget { + const AttireCapturePage({ + super.key, + required this.item, + this.initialPhotoUrl, + }); + + final AttireItem item; + final String? initialPhotoUrl; + + @override + State createState() => _AttireCapturePageState(); +} + +class _AttireCapturePageState extends State { + void _onUpload(BuildContext context) { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + if (!cubit.state.isAttested) { + UiSnackbar.show( + context, + message: 'Please attest that you own this item.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + return; + } + // Call the upload via cubit + cubit.uploadPhoto(widget.item.id); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => Modular.get(), + child: Builder( + builder: (BuildContext context) { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + return Scaffold( + appBar: UiAppBar(title: widget.item.label, showBackButton: true), + body: BlocConsumer( + bloc: cubit, + listener: (BuildContext context, AttireCaptureState state) { + if (state.status == AttireCaptureStatus.failure) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage ?? 'Error'), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, AttireCaptureState state) { + final bool isUploading = + state.status == AttireCaptureStatus.uploading; + final String? currentPhotoUrl = + state.photoUrl ?? widget.initialPhotoUrl; + final bool hasUploadedPhoto = currentPhotoUrl != null; + + final String statusText = switch (widget + .item + .verificationStatus) { + AttireVerificationStatus.success => 'Approved', + AttireVerificationStatus.failed => 'Rejected', + AttireVerificationStatus.pending => 'Pending Verification', + _ => + hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', + }; + + final Color statusColor = + switch (widget.item.verificationStatus) { + AttireVerificationStatus.success => UiColors.textSuccess, + AttireVerificationStatus.failed => UiColors.textError, + AttireVerificationStatus.pending => UiColors.textWarning, + _ => + hasUploadedPhoto + ? UiColors.textWarning + : UiColors.textInactive, + }; + + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + children: [ + // Image Preview (Toggle between example and uploaded) + if (hasUploadedPhoto) ...[ + Text( + 'Your Uploaded Photo', + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(imageUrl: currentPhotoUrl), + const SizedBox(height: UiConstants.space4), + Text( + 'Reference Example', + style: UiTypography.body2b.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), + child: Image.network( + widget.item.imageUrl ?? '', + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => + const SizedBox.shrink(), + ), + ), + ), + ] else ...[ + AttireImagePreview( + imageUrl: widget.item.imageUrl, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'Example of the item that you need to upload.', + style: UiTypography.body1b.textSecondary, + textAlign: TextAlign.center, + ), + ], + + const SizedBox(height: UiConstants.space6), + if (widget.item.description != null) + Text( + widget.item.description!, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space8), + + // Verification info + AttireVerificationStatusCard( + statusText: statusText, + statusColor: statusColor, + ), + const SizedBox(height: UiConstants.space6), + + AttestationCheckbox( + isChecked: state.isAttested, + onChanged: (bool? val) { + cubit.toggleAttestation(val ?? false); + }, + ), + const SizedBox(height: UiConstants.space6), + + if (isUploading) + const Center( + child: Padding( + padding: EdgeInsets.all(UiConstants.space8), + child: CircularProgressIndicator(), + ), + ) + else + AttireUploadButtons(onUpload: _onUpload), + ], + ), + ), + ), + if (hasUploadedPhoto) + SafeArea( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: SizedBox( + width: double.infinity, + child: UiButton.primary( + text: 'Submit Image', + onPressed: () { + Modular.to.pop(currentPhotoUrl); + }, + ), + ), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index c788cfe0..c2782981 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -1,101 +1,143 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:core_localization/core_localization.dart'; +import 'package:krow_domain/krow_domain.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; +import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; -import '../blocs/attire_cubit.dart'; -import '../blocs/attire_state.dart'; -import '../widgets/attestation_checkbox.dart'; -import '../widgets/attire_bottom_bar.dart'; -import '../widgets/attire_grid.dart'; +import '../widgets/attire_filter_chips.dart'; import '../widgets/attire_info_card.dart'; +import '../widgets/attire_item_card.dart'; +import 'attire_capture_page.dart'; -class AttirePage extends StatelessWidget { +class AttirePage extends StatefulWidget { const AttirePage({super.key}); + @override + State createState() => _AttirePageState(); +} + +class _AttirePageState extends State { + String _filter = 'All'; + @override Widget build(BuildContext context) { - // Note: t.staff_profile_attire is available via re-export of core_localization final AttireCubit cubit = Modular.get(); - return BlocProvider.value( - value: cubit, - child: Scaffold( - backgroundColor: UiColors.background, // FAFBFC - appBar: AppBar( - backgroundColor: UiColors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconSecondary), - onPressed: () => Modular.to.pop(), - ), - title: Text( - t.staff_profile_attire.title, - style: UiTypography.headline3m.textPrimary, - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1.0), - child: Container(color: UiColors.border, height: 1.0), - ), - ), - body: BlocConsumer( + return Scaffold( + appBar: UiAppBar( + title: t.staff_profile_attire.title, + showBackButton: true, + ), + body: BlocProvider.value( + value: cubit, + child: BlocConsumer( listener: (BuildContext context, AttireState state) { if (state.status == AttireStatus.failure) { UiSnackbar.show( context, message: translateErrorKey(state.errorMessage ?? 'Error'), type: UiSnackbarType.error, - margin: const EdgeInsets.only( - bottom: 150, - left: UiConstants.space4, - right: UiConstants.space4, - ), ); } - if (state.status == AttireStatus.saved) { - Modular.to.pop(); - } }, builder: (BuildContext context, AttireState state) { if (state.status == AttireStatus.loading && state.options.isEmpty) { return const Center(child: CircularProgressIndicator()); } + final List options = state.options; + final List filteredOptions = options.where(( + AttireItem item, + ) { + if (_filter == 'Required') return item.isMandatory; + if (_filter == 'Non-Essential') return !item.isMandatory; + return true; + }).toList(); + return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(UiConstants.space5), child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ const AttireInfoCard(), const SizedBox(height: UiConstants.space6), - AttireGrid( - items: state.options, - selectedIds: state.selectedIds, - photoUrls: state.photoUrls, - uploadingStatus: state.uploadingStatus, - onToggle: cubit.toggleSelection, - onUpload: cubit.uploadPhoto, + + // Filter Chips + AttireFilterChips( + selectedFilter: _filter, + onFilterChanged: (String value) { + setState(() { + _filter = value; + }); + }, ), const SizedBox(height: UiConstants.space6), - AttestationCheckbox( - isChecked: state.attestationChecked, - onChanged: (bool? val) => cubit.toggleAttestation(val ?? false), - ), + + // Item List + if (filteredOptions.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space10, + ), + child: Center( + child: Column( + children: [ + const Icon( + UiIcons.shirt, + size: 48, + color: UiColors.iconInactive, + ), + const SizedBox(height: UiConstants.space4), + Text( + 'No items found for this filter.', + style: UiTypography.body1m.textSecondary, + ), + ], + ), + ), + ) + else + ...filteredOptions.map((AttireItem item) { + return Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, + ), + child: AttireItemCard( + item: item, + isUploading: false, + uploadedPhotoUrl: state.photoUrls[item.id], + onTap: () async { + final String? resultUrl = + await Navigator.push( + context, + MaterialPageRoute( + builder: (BuildContext ctx) => + AttireCapturePage( + item: item, + initialPhotoUrl: + state.photoUrls[item.id], + ), + ), + ); + + if (resultUrl != null && mounted) { + cubit.syncCapturedPhoto(item.id, resultUrl); + } + }, + ), + ); + }), const SizedBox(height: UiConstants.space20), ], ), ), ), - AttireBottomBar( - canSave: state.canSave, - allMandatorySelected: state.allMandatorySelected, - allMandatoryHavePhotos: state.allMandatoryHavePhotos, - attestationChecked: state.attestationChecked, - onSave: cubit.save, - ), ], ); }, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart new file mode 100644 index 00000000..5adfeec2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart @@ -0,0 +1,72 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireImagePreview extends StatelessWidget { + const AttireImagePreview({super.key, required this.imageUrl}); + + final String? imageUrl; + + void _viewEnlargedImage(BuildContext context) { + showDialog( + context: context, + builder: (BuildContext context) { + return Dialog( + backgroundColor: Colors.transparent, + child: Container( + constraints: const BoxConstraints(maxHeight: 500, maxWidth: 500), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + image: DecorationImage( + image: NetworkImage( + imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ), + fit: BoxFit.contain, + ), + ), + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => _viewEnlargedImage(context), + child: Container( + height: 200, + width: double.infinity, + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow( + color: Color(0x19000000), + blurRadius: 4, + offset: Offset(0, 2), + ), + ], + image: DecorationImage( + image: NetworkImage( + imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ), + fit: BoxFit.cover, + ), + ), + child: const Align( + alignment: Alignment.bottomRight, + child: Padding( + padding: EdgeInsets.all(8.0), + child: Icon( + UiIcons.search, + color: UiColors.white, + shadows: [Shadow(color: Colors.black, blurRadius: 4)], + ), + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart new file mode 100644 index 00000000..83067e7e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart @@ -0,0 +1,31 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireUploadButtons extends StatelessWidget { + const AttireUploadButtons({super.key, required this.onUpload}); + + final void Function(BuildContext) onUpload; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: UiButton.secondary( + leadingIcon: UiIcons.gallery, + text: 'Gallery', + onPressed: () => onUpload(context), + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: UiButton.primary( + leadingIcon: UiIcons.camera, + text: 'Camera', + onPressed: () => onUpload(context), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart new file mode 100644 index 00000000..2799aea2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_verification_status_card.dart @@ -0,0 +1,46 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireVerificationStatusCard extends StatelessWidget { + const AttireVerificationStatusCard({ + super.key, + required this.statusText, + required this.statusColor, + }); + + final String statusText; + final Color statusColor; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + children: [ + const Icon(UiIcons.info, color: UiColors.primary, size: 24), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Verification Status', + style: UiTypography.footnote2m.textPrimary, + ), + Text( + statusText, + style: UiTypography.body2m.copyWith(color: statusColor), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart new file mode 100644 index 00000000..b7ca10eb --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_filter_chips.dart @@ -0,0 +1,56 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +class AttireFilterChips extends StatelessWidget { + const AttireFilterChips({ + super.key, + required this.selectedFilter, + required this.onFilterChanged, + }); + + final String selectedFilter; + final ValueChanged onFilterChanged; + + Widget _buildFilterChip(String label) { + final bool isSelected = selectedFilter == label; + return GestureDetector( + onTap: () => onFilterChanged(label), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, + ), + decoration: BoxDecoration( + color: isSelected ? UiColors.primary : UiColors.white, + borderRadius: UiConstants.radiusFull, + border: Border.all( + color: isSelected ? UiColors.primary : UiColors.border, + ), + ), + child: Text( + label, + textAlign: TextAlign.center, + style: (isSelected + ? UiTypography.footnote2m.white + : UiTypography.footnote2m.textSecondary), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _buildFilterChip('All'), + const SizedBox(width: UiConstants.space2), + _buildFilterChip('Required'), + const SizedBox(width: UiConstants.space2), + _buildFilterChip('Non-Essential'), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart index e917a4c1..dc4a0c9e 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_grid.dart @@ -5,7 +5,6 @@ import 'package:core_localization/core_localization.dart'; import 'package:krow_domain/krow_domain.dart'; class AttireGrid extends StatelessWidget { - const AttireGrid({ super.key, required this.items, @@ -53,7 +52,9 @@ class AttireGrid extends StatelessWidget { ) { return Container( decoration: BoxDecoration( - color: isSelected ? UiColors.primary.withOpacity(0.1) : Colors.transparent, + color: isSelected + ? UiColors.primary.withOpacity(0.1) + : Colors.transparent, borderRadius: UiConstants.radiusSm, border: Border.all( color: isSelected ? UiColors.primary : UiColors.border, @@ -67,19 +68,17 @@ class AttireGrid extends StatelessWidget { top: UiConstants.space2, left: UiConstants.space2, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: UiColors.destructive, // Red borderRadius: UiConstants.radiusSm, ), child: Text( t.staff_profile_attire.status.required, - style: UiTypography.body3m.copyWith( // 12px Medium -> Bold + style: UiTypography.body3m.copyWith( + // 12px Medium -> Bold fontWeight: FontWeight.bold, - fontSize: 9, + fontSize: 9, color: UiColors.white, ), ), @@ -97,11 +96,7 @@ class AttireGrid extends StatelessWidget { shape: BoxShape.circle, ), child: const Center( - child: Icon( - UiIcons.check, - color: UiColors.white, - size: 12, - ), + child: Icon(UiIcons.check, color: UiColors.white, size: 12), ), ), ), @@ -119,26 +114,34 @@ class AttireGrid extends StatelessWidget { height: 80, width: 80, decoration: BoxDecoration( - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), image: DecorationImage( image: NetworkImage(item.imageUrl!), fit: BoxFit.cover, ), ), ) - : Icon( - _getIcon(item.iconName), + : const Icon( + UiIcons.shirt, size: 48, - color: UiColors.textPrimary, // Was charcoal + color: UiColors.iconSecondary, ), const SizedBox(height: UiConstants.space2), Text( item.label, textAlign: TextAlign.center, - style: UiTypography.body2m.copyWith( - color: UiColors.textPrimary, - ), + style: UiTypography.body2m.textPrimary, ), + if (item.description != null) + Text( + item.description!, + textAlign: TextAlign.center, + style: UiTypography.body3r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), ], ), ), @@ -158,7 +161,9 @@ class AttireGrid extends StatelessWidget { border: Border.all( color: hasPhoto ? UiColors.primary : UiColors.border, ), - borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderRadius: BorderRadius.circular( + UiConstants.radiusBase, + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -169,7 +174,9 @@ class AttireGrid extends StatelessWidget { height: 12, child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(UiColors.primary), + valueColor: AlwaysStoppedAnimation( + UiColors.primary, + ), ), ) else if (hasPhoto) @@ -189,10 +196,12 @@ class AttireGrid extends StatelessWidget { isUploading ? '...' : hasPhoto - ? t.staff_profile_attire.status.added - : t.staff_profile_attire.status.add_photo, + ? t.staff_profile_attire.status.added + : t.staff_profile_attire.status.add_photo, style: UiTypography.body3m.copyWith( - color: hasPhoto ? UiColors.primary : UiColors.textSecondary, + color: hasPhoto + ? UiColors.primary + : UiColors.textSecondary, ), ), ], @@ -217,23 +226,4 @@ class AttireGrid extends StatelessWidget { ), ); } - - IconData _getIcon(String? name) { - switch (name) { - case 'footprints': - return UiIcons.footprints; - case 'scissors': - return UiIcons.scissors; - case 'user': - return UiIcons.user; - case 'shirt': - return UiIcons.shirt; - case 'hardHat': - return UiIcons.hardHat; - case 'chefHat': - return UiIcons.chefHat; - default: - return UiIcons.help; - } - } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart new file mode 100644 index 00000000..43c88fbc --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -0,0 +1,130 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +class AttireItemCard extends StatelessWidget { + final AttireItem item; + final String? uploadedPhotoUrl; + final bool isUploading; + final VoidCallback onTap; + + const AttireItemCard({ + super.key, + required this.item, + this.uploadedPhotoUrl, + this.isUploading = false, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final bool hasPhoto = item.photoUrl != null; + final String statusText = switch (item.verificationStatus) { + AttireVerificationStatus.success => 'Approved', + AttireVerificationStatus.failed => 'Rejected', + AttireVerificationStatus.pending => 'Pending', + _ => hasPhoto ? 'Pending' : 'To Do', + }; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + border: Border.all(color: UiColors.border), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: UiColors.background, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + image: DecorationImage( + image: NetworkImage( + item.imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ), + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: UiConstants.space4), + // details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.label, style: UiTypography.body1m.textPrimary), + if (item.description != null) ...[ + Text( + item.description!, + style: UiTypography.body2r.textSecondary, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: UiConstants.space2), + Row( + spacing: UiConstants.space2, + children: [ + if (item.isMandatory) + const UiChip( + label: 'Required', + size: UiChipSize.xSmall, + variant: UiChipVariant.destructive, + ), + if (isUploading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else if (hasPhoto) + UiChip( + label: statusText, + size: UiChipSize.xSmall, + variant: item.verificationStatus == 'SUCCESS' + ? UiChipVariant.primary + : UiChipVariant.secondary, + ), + ], + ), + ], + ), + ), + const SizedBox(width: UiConstants.space2), + // Chevron or status + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 20), + if (!hasPhoto && !isUploading) + const Icon( + UiIcons.chevronRight, + color: UiColors.textInactive, + size: 24, + ) + else if (hasPhoto && !isUploading) + Icon( + item.verificationStatus == 'SUCCESS' + ? UiIcons.check + : UiIcons.clock, + color: item.verificationStatus == 'SUCCESS' + ? UiColors.textPrimary + : UiColors.textWarning, + size: 24, + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/backend/dataconnect/connector/attireOption/mutations.gql b/backend/dataconnect/connector/attireOption/mutations.gql index 59f4f7f9..8ff9f197 100644 --- a/backend/dataconnect/connector/attireOption/mutations.gql +++ b/backend/dataconnect/connector/attireOption/mutations.gql @@ -1,7 +1,7 @@ mutation createAttireOption( $itemId: String! $label: String! - $icon: String + $description: String $imageUrl: String $isMandatory: Boolean $vendorId: UUID @@ -10,7 +10,7 @@ mutation createAttireOption( data: { itemId: $itemId label: $label - icon: $icon + description: $description imageUrl: $imageUrl isMandatory: $isMandatory vendorId: $vendorId @@ -22,7 +22,7 @@ mutation updateAttireOption( $id: UUID! $itemId: String $label: String - $icon: String + $description: String $imageUrl: String $isMandatory: Boolean $vendorId: UUID @@ -32,7 +32,7 @@ mutation updateAttireOption( data: { itemId: $itemId label: $label - icon: $icon + description: $description imageUrl: $imageUrl isMandatory: $isMandatory vendorId: $vendorId diff --git a/backend/dataconnect/connector/attireOption/queries.gql b/backend/dataconnect/connector/attireOption/queries.gql index 76ce2817..311fe9da 100644 --- a/backend/dataconnect/connector/attireOption/queries.gql +++ b/backend/dataconnect/connector/attireOption/queries.gql @@ -3,7 +3,7 @@ query listAttireOptions @auth(level: USER) { id itemId label - icon + description imageUrl isMandatory vendorId @@ -16,7 +16,7 @@ query getAttireOptionById($id: UUID!) @auth(level: USER) { id itemId label - icon + description imageUrl isMandatory vendorId @@ -39,7 +39,7 @@ query filterAttireOptions( id itemId label - icon + description imageUrl isMandatory vendorId diff --git a/backend/dataconnect/connector/benefitsData/queries.gql b/backend/dataconnect/connector/benefitsData/queries.gql index 2bc60a37..c856fcbf 100644 --- a/backend/dataconnect/connector/benefitsData/queries.gql +++ b/backend/dataconnect/connector/benefitsData/queries.gql @@ -1,4 +1,38 @@ +# ---------------------------------------------------------- +# GET WORKER BENEFIT BALANCES (M4) +# Returns all active benefit plans with balance data for a given worker. +# Supports: Sick Leave (40h), Holidays (24h), Vacation (40h) +# Extensible: any future VendorBenefitPlan will appear automatically. +# +# Fields: +# vendorBenefitPlan.title → benefit type name +# vendorBenefitPlan.total → total entitlement (hours) +# current → used hours +# remaining = total - current → computed client-side +# ---------------------------------------------------------- +query getWorkerBenefitBalances( + $staffId: UUID! +) @auth(level: USER) { + benefitsDatas( + where: { + staffId: { eq: $staffId } + } + ) { + vendorBenefitPlanId + current + + vendorBenefitPlan { + id + title + description + requestLabel + total + isActive + } + } +} + # ---------------------------------------------------------- # LIST ALL (admin/debug) # ---------------------------------------------------------- diff --git a/backend/dataconnect/connector/shiftDayCompletion/mutations.gql b/backend/dataconnect/connector/shiftDayCompletion/mutations.gql new file mode 100644 index 00000000..edaa5b92 --- /dev/null +++ b/backend/dataconnect/connector/shiftDayCompletion/mutations.gql @@ -0,0 +1,129 @@ + +# ------------------------------------------------------------ +# CREATE — called automatically at the end of each shift day +# ------------------------------------------------------------ +mutation createShiftDayCompletion( + $shiftId: UUID! + $orderId: UUID! + $businessId: UUID! + $vendorId: UUID! + $dayDate: Timestamp! + $dayNumber: Int! + $hours: Float + $cost: Float + $staffSummary: Any + $createdBy: String +) @auth(level: USER) { + shiftDayCompletion_insert( + data: { + shiftId: $shiftId + orderId: $orderId + businessId: $businessId + vendorId: $vendorId + dayDate: $dayDate + dayNumber: $dayNumber + status: PENDING_REVIEW + hours: $hours + cost: $cost + staffSummary: $staffSummary + createdBy: $createdBy + } + ) +} + +# ------------------------------------------------------------ +# APPROVE — client approves a daily completion record +# ------------------------------------------------------------ +mutation approveShiftDayCompletion( + $id: UUID! + $reviewedBy: String! + $reviewedAt: Timestamp! +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: APPROVED + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + } + ) +} + +# ------------------------------------------------------------ +# DISPUTE — client disputes a daily completion record +# ------------------------------------------------------------ +mutation disputeShiftDayCompletion( + $id: UUID! + $reviewedBy: String! + $reviewedAt: Timestamp! + $disputeReason: String! + $disputeDetails: String + $disputedItems: Any +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: DISPUTED + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + disputeReason: $disputeReason + disputeDetails: $disputeDetails + disputedItems: $disputedItems + } + ) +} + +# ------------------------------------------------------------ +# LINK INVOICE — set once invoice is generated after full approval +# ------------------------------------------------------------ +mutation linkInvoiceToShiftDayCompletion( + $id: UUID! + $invoiceId: UUID! +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + invoiceId: $invoiceId + } + ) +} + +# ------------------------------------------------------------ +# UPDATE — general-purpose update (admin use) +# ------------------------------------------------------------ +mutation updateShiftDayCompletion( + $id: UUID! + $status: ShiftDayCompletionStatus + $hours: Float + $cost: Float + $staffSummary: Any + $disputeReason: String + $disputeDetails: String + $disputedItems: Any + $reviewedBy: String + $reviewedAt: Timestamp + $invoiceId: UUID +) @auth(level: USER) { + shiftDayCompletion_update( + id: $id + data: { + status: $status + hours: $hours + cost: $cost + staffSummary: $staffSummary + disputeReason: $disputeReason + disputeDetails: $disputeDetails + disputedItems: $disputedItems + reviewedBy: $reviewedBy + reviewedAt: $reviewedAt + invoiceId: $invoiceId + } + ) +} + +# ------------------------------------------------------------ +# DELETE +# ------------------------------------------------------------ +mutation deleteShiftDayCompletion($id: UUID!) @auth(level: USER) { + shiftDayCompletion_delete(id: $id) +} diff --git a/backend/dataconnect/connector/shiftDayCompletion/queries.gql b/backend/dataconnect/connector/shiftDayCompletion/queries.gql new file mode 100644 index 00000000..3532c03a --- /dev/null +++ b/backend/dataconnect/connector/shiftDayCompletion/queries.gql @@ -0,0 +1,417 @@ + +# ------------------------------------------------------------ +# GET BY ID +# ------------------------------------------------------------ +query getShiftDayCompletionById($id: UUID!) @auth(level: USER) { + shiftDayCompletion(id: $id) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + createdBy + + shift { + id + title + date + startTime + endTime + hours + durationDays + status + } + + order { + id + eventName + orderType + poReference + teamHub { + hubName + address + } + } + + business { + id + businessName + email + contactName + } + + vendor { + id + companyName + email + } + + invoice { + id + invoiceNumber + status + issueDate + dueDate + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL COMPLETION RECORDS FOR A SHIFT +# ------------------------------------------------------------ +query listShiftDayCompletionsByShift( + $shiftId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { shiftId: { eq: $shiftId } } + orderBy: { dayNumber: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL COMPLETION RECORDS FOR AN ORDER +# ------------------------------------------------------------ +query listShiftDayCompletionsByOrder( + $orderId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { orderId: { eq: $orderId } } + orderBy: { dayDate: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST PENDING REVIEW RECORDS FOR A BUSINESS (client view) +# ------------------------------------------------------------ +query listPendingShiftDayCompletionsByBusiness( + $businessId: UUID! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + businessId: { eq: $businessId } + status: { eq: PENDING_REVIEW } + } + orderBy: { dayDate: ASC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + location + locationAddress + } + + order { + id + eventName + orderType + poReference + teamHub { + hubName + address + } + } + + vendor { + id + companyName + } + } +} + +# ------------------------------------------------------------ +# LIST ALL RECORDS FOR A BUSINESS FILTERED BY STATUS +# ------------------------------------------------------------ +query listShiftDayCompletionsByBusinessAndStatus( + $businessId: UUID! + $status: ShiftDayCompletionStatus! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + businessId: { eq: $businessId } + status: { eq: $status } + } + orderBy: { dayDate: DESC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + disputedItems + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + order { + id + eventName + orderType + poReference + } + + invoice { + id + invoiceNumber + status + amount + } + } +} + +# ------------------------------------------------------------ +# LIST ALL APPROVED RECORDS FOR A SHIFT (invoice trigger check) +# ------------------------------------------------------------ +query listApprovedShiftDayCompletionsByShift( + $shiftId: UUID! +) @auth(level: USER) { + shiftDayCompletions( + where: { + shiftId: { eq: $shiftId } + status: { eq: APPROVED } + } + orderBy: { dayNumber: ASC } + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + durationDays + hours + cost + status + order { + id + eventName + businessId + vendorId + poReference + teamHub { + hubName + address + } + } + } + } +} + +# ------------------------------------------------------------ +# LIST ALL RECORDS BY VENDOR FILTERED BY STATUS +# ------------------------------------------------------------ +query listShiftDayCompletionsByVendorAndStatus( + $vendorId: UUID! + $status: ShiftDayCompletionStatus! + $offset: Int + $limit: Int +) @auth(level: USER) { + shiftDayCompletions( + where: { + vendorId: { eq: $vendorId } + status: { eq: $status } + } + orderBy: { dayDate: DESC } + offset: $offset + limit: $limit + ) { + id + shiftId + orderId + businessId + vendorId + dayDate + dayNumber + status + hours + cost + staffSummary + disputeReason + disputeDetails + reviewedBy + reviewedAt + invoiceId + createdAt + updatedAt + + shift { + id + title + date + startTime + endTime + durationDays + status + } + + business { + id + businessName + email + } + + invoice { + id + invoiceNumber + status + amount + } + } +} diff --git a/backend/dataconnect/connector/staffAttire/mutations.gql b/backend/dataconnect/connector/staffAttire/mutations.gql new file mode 100644 index 00000000..25184389 --- /dev/null +++ b/backend/dataconnect/connector/staffAttire/mutations.gql @@ -0,0 +1,16 @@ +mutation upsertStaffAttire( + $staffId: UUID! + $attireOptionId: UUID! + $verificationPhotoUrl: String + $verificationId: String +) @auth(level: USER) { + staffAttire_upsert( + data: { + staffId: $staffId + attireOptionId: $attireOptionId + verificationPhotoUrl: $verificationPhotoUrl + verificationId: $verificationId + verificationStatus: PENDING + } + ) +} diff --git a/backend/dataconnect/connector/staffAttire/queries.gql b/backend/dataconnect/connector/staffAttire/queries.gql new file mode 100644 index 00000000..bb7d097c --- /dev/null +++ b/backend/dataconnect/connector/staffAttire/queries.gql @@ -0,0 +1,8 @@ +query getStaffAttire($staffId: UUID!) @auth(level: USER) { + staffAttires(where: { staffId: { eq: $staffId } }) { + attireOptionId + verificationStatus + verificationPhotoUrl + verificationId + } +} diff --git a/backend/dataconnect/functions/cleanAttire.gql b/backend/dataconnect/functions/cleanAttire.gql new file mode 100644 index 00000000..69b689a0 --- /dev/null +++ b/backend/dataconnect/functions/cleanAttire.gql @@ -0,0 +1,3 @@ +mutation cleanAttireOptions @transaction { + attireOption_deleteMany(all: true) +} diff --git a/backend/dataconnect/functions/seed.gql b/backend/dataconnect/functions/seed.gql index 1c6e0fcd..065a8246 100644 --- a/backend/dataconnect/functions/seed.gql +++ b/backend/dataconnect/functions/seed.gql @@ -1770,5 +1770,163 @@ mutation seedAll @transaction { invoiceId: "ba0529be-7906-417f-8ec7-c866d0633fee" } ) + + # Attire Options (Required) + attire_1: attireOption_insert( + data: { + id: "4bce6592-e38e-4d90-a478-d1ce0f286146" + itemId: "shoes_non_slip" + label: "Non Slip Shoes" + description: "Black, closed-toe, non-slip work shoes." + imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_2: attireOption_insert( + data: { + id: "786e9761-b398-42bd-b363-91a40938864e" + itemId: "pants_black" + label: "Black Pants" + description: "Professional black slacks or trousers. No jeans." + imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_3: attireOption_insert( + data: { + id: "17b135e6-b8f0-4541-b12b-505e95de31ef" + itemId: "socks_black" + label: "Black Socks" + description: "Solid black dress or crew socks." + imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_4: attireOption_insert( + data: { + id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517" + itemId: "shirt_white_button_up" + label: "White Button Up" + description: "Clean, pressed, long-sleeve white button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + + # Attire Options (Non-Essential) + attire_5: attireOption_insert( + data: { + id: "32e77813-24f5-495b-98de-872e33073820" + itemId: "pants_blue_jeans" + label: "Blue Jeans" + description: "Standard blue denim jeans, no rips or tears." + imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_6: attireOption_insert( + data: { + id: "de3c5a90-2c88-4c87-bb00-b62c6460d506" + itemId: "shirt_white_polo" + label: "White Polo" + description: "White polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_7: attireOption_insert( + data: { + id: "64149864-b886-4a00-9aa2-09903a401b5b" + itemId: "shirt_catering" + label: "Catering Shirt" + description: "Company approved catering staff shirt." + imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_8: attireOption_insert( + data: { + id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076" + itemId: "banquette" + label: "Banquette" + description: "Standard banquette or event setup uniform." + imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_9: attireOption_insert( + data: { + id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248" + itemId: "hat_black_cap" + label: "Black Cap" + description: "Plain black baseball cap, no logos." + imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_10: attireOption_insert( + data: { + id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6" + itemId: "chef_coat" + label: "Chef Coat" + description: "Standard white double-breasted chef coat." + imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_11: attireOption_insert( + data: { + id: "d857d96b-5bf4-4648-bb9c-f909436729fd" + itemId: "shirt_black_button_up" + label: "Black Button Up" + description: "Clean, pressed, long-sleeve black button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_12: attireOption_insert( + data: { + id: "1f61267b-1f7a-43f1-bfd7-2a018347285b" + itemId: "shirt_black_polo" + label: "Black Polo" + description: "Black polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_13: attireOption_insert( + data: { + id: "16192098-e5ec-4bf2-86d3-c693663BA687" + itemId: "all_black_bistro" + label: "All Black Bistro" + description: "Full black bistro uniform including apron." + imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_14: attireOption_insert( + data: { + id: "6be15ab9-6c73-453b-950b-d4ba35d875de" + itemId: "white_black_bistro" + label: "White and Black Bistro" + description: "White shirt with black pants and bistro apron." + imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) } #v.3 \ No newline at end of file diff --git a/backend/dataconnect/functions/seedAttire.gql b/backend/dataconnect/functions/seedAttire.gql new file mode 100644 index 00000000..fa9f9870 --- /dev/null +++ b/backend/dataconnect/functions/seedAttire.gql @@ -0,0 +1,159 @@ +mutation seedAttireOptions @transaction { + # Attire Options (Required) + attire_1: attireOption_upsert( + data: { + id: "4bce6592-e38e-4d90-a478-d1ce0f286146" + itemId: "shoes_non_slip" + label: "Non Slip Shoes" + description: "Black, closed-toe, non-slip work shoes." + imageUrl: "https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_2: attireOption_upsert( + data: { + id: "786e9761-b398-42bd-b363-91a40938864e" + itemId: "pants_black" + label: "Black Pants" + description: "Professional black slacks or trousers. No jeans." + imageUrl: "https://images.unsplash.com/photo-1594633312681-425c7b97ccd1?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_3: attireOption_upsert( + data: { + id: "17b135e6-b8f0-4541-b12b-505e95de31ef" + itemId: "socks_black" + label: "Black Socks" + description: "Solid black dress or crew socks." + imageUrl: "https://images.unsplash.com/photo-1582966298431-99c6a1e8d44e?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_4: attireOption_upsert( + data: { + id: "bbff61b3-3f99-4637-9a2f-1d4c6fa61517" + itemId: "shirt_white_button_up" + label: "White Button Up" + description: "Clean, pressed, long-sleeve white button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: true + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + + # Attire Options (Non-Essential) + attire_5: attireOption_upsert( + data: { + id: "32e77813-24f5-495b-98de-872e33073820" + itemId: "pants_blue_jeans" + label: "Blue Jeans" + description: "Standard blue denim jeans, no rips or tears." + imageUrl: "https://images.unsplash.com/photo-1542272604-787c3835535d?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_6: attireOption_upsert( + data: { + id: "de3c5a90-2c88-4c87-bb00-b62c6460d506" + itemId: "shirt_white_polo" + label: "White Polo" + description: "White polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1581655353564-df123a1eb820?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_7: attireOption_upsert( + data: { + id: "64149864-b886-4a00-9aa2-09903a401b5b" + itemId: "shirt_catering" + label: "Catering Shirt" + description: "Company approved catering staff shirt." + imageUrl: "https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_8: attireOption_upsert( + data: { + id: "9b2e493e-e95c-4dcd-9073-e42dbcf77076" + itemId: "banquette" + label: "Banquette" + description: "Standard banquette or event setup uniform." + imageUrl: "https://images.unsplash.com/photo-1514362545857-3bc16c4c7d1b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_9: attireOption_upsert( + data: { + id: "2e30cde5-5acd-4dd0-b8e9-af6d6b59b248" + itemId: "hat_black_cap" + label: "Black Cap" + description: "Plain black baseball cap, no logos." + imageUrl: "https://images.unsplash.com/photo-1588850561407-ed78c282e89b?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_10: attireOption_upsert( + data: { + id: "90d912ed-1227-44ef-ae75-bc7ca2c491c6" + itemId: "chef_coat" + label: "Chef Coat" + description: "Standard white double-breasted chef coat." + imageUrl: "https://images.unsplash.com/photo-1583394293214-28ded15ee548?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_11: attireOption_upsert( + data: { + id: "d857d96b-5bf4-4648-bb9c-f909436729fd" + itemId: "shirt_black_button_up" + label: "Black Button Up" + description: "Clean, pressed, long-sleeve black button-up shirt." + imageUrl: "https://images.unsplash.com/photo-1598033129183-c4f50c7176c8?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_12: attireOption_upsert( + data: { + id: "1f61267b-1f7a-43f1-bfd7-2a018347285b" + itemId: "shirt_black_polo" + label: "Black Polo" + description: "Black polo shirt with collar." + imageUrl: "https://images.unsplash.com/photo-1583743814966-8936f5b7be1a?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_13: attireOption_upsert( + data: { + id: "16192098-e5ec-4bf2-86d3-c693663BA687" + itemId: "all_black_bistro" + label: "All Black Bistro" + description: "Full black bistro uniform including apron." + imageUrl: "https://images.unsplash.com/photo-1551632432-c735e8399527?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) + attire_14: attireOption_upsert( + data: { + id: "6be15ab9-6c73-453b-950b-d4ba35d875de" + itemId: "white_black_bistro" + label: "White and Black Bistro" + description: "White shirt with black pants and bistro apron." + imageUrl: "https://images.unsplash.com/photo-1600565193348-f74bd3c7ccdf?auto=format&fit=crop&q=80&w=400&h=400" + isMandatory: false + vendorId: "c3b25c47-0ebd-4402-a9b1-b8a875a7f71a" + } + ) +} \ No newline at end of file diff --git a/backend/dataconnect/schema/attireOption.gql b/backend/dataconnect/schema/attireOption.gql index 2c09a410..8edf8254 100644 --- a/backend/dataconnect/schema/attireOption.gql +++ b/backend/dataconnect/schema/attireOption.gql @@ -2,7 +2,7 @@ type AttireOption @table(name: "attire_options") { id: UUID! @default(expr: "uuidV4()") itemId: String! label: String! - icon: String + description: String imageUrl: String isMandatory: Boolean diff --git a/backend/dataconnect/schema/benefitsData.gql b/backend/dataconnect/schema/benefitsData.gql index 397d80f3..50a075d8 100644 --- a/backend/dataconnect/schema/benefitsData.gql +++ b/backend/dataconnect/schema/benefitsData.gql @@ -1,13 +1,15 @@ -type BenefitsData @table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) { +type BenefitsData + @table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) { id: UUID! @default(expr: "uuidV4()") - vendorBenefitPlanId: UUID! - vendorBenefitPlan: VendorBenefitPlan! @ref( fields: "vendorBenefitPlanId", references: "id" ) + vendorBenefitPlanId: UUID! + vendorBenefitPlan: VendorBenefitPlan! + @ref(fields: "vendorBenefitPlanId", references: "id") current: Int! staffId: UUID! - staff: Staff! @ref( fields: "staffId", references: "id" ) + staff: Staff! @ref(fields: "staffId", references: "id") createdAt: Timestamp @default(expr: "request.time") updatedAt: Timestamp @default(expr: "request.time") diff --git a/backend/dataconnect/schema/shiftDayCompletion.gql b/backend/dataconnect/schema/shiftDayCompletion.gql new file mode 100644 index 00000000..a990edb7 --- /dev/null +++ b/backend/dataconnect/schema/shiftDayCompletion.gql @@ -0,0 +1,45 @@ +enum ShiftDayCompletionStatus { + PENDING_REVIEW + APPROVED + DISPUTED +} + +type ShiftDayCompletion @table(name: "shift_day_completions", key: ["id"]) { + id: UUID! @default(expr: "uuidV4()") + + shiftId: UUID! + shift: Shift! @ref(fields: "shiftId", references: "id") + + orderId: UUID! + order: Order! @ref(fields: "orderId", references: "id") + + businessId: UUID! + business: Business! @ref(fields: "businessId", references: "id") + + vendorId: UUID! + vendor: Vendor! @ref(fields: "vendorId", references: "id") + + dayDate: Timestamp! + dayNumber: Int! + + status: ShiftDayCompletionStatus! @default(expr: "'PENDING_REVIEW'") + + hours: Float + cost: Float + + staffSummary: Any @col(dataType: "jsonb") + + disputeReason: String + disputeDetails: String + disputedItems: Any @col(dataType: "jsonb") + + reviewedBy: String + reviewedAt: Timestamp + + invoiceId: UUID + invoice: Invoice @ref(fields: "invoiceId", references: "id") + + createdAt: Timestamp @default(expr: "request.time") + updatedAt: Timestamp @default(expr: "request.time") + createdBy: String +} diff --git a/backend/dataconnect/schema/staffAttire.gql b/backend/dataconnect/schema/staffAttire.gql new file mode 100644 index 00000000..e61e8f9b --- /dev/null +++ b/backend/dataconnect/schema/staffAttire.gql @@ -0,0 +1,22 @@ +enum AttireVerificationStatus { + PENDING + FAILED + SUCCESS +} + +type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"]) { + staffId: UUID! + staff: Staff! @ref(fields: "staffId", references: "id") + + attireOptionId: UUID! + attireOption: AttireOption! @ref(fields: "attireOptionId", references: "id") + + # Verification Metadata + verificationStatus: AttireVerificationStatus @default(expr: "'PENDING'") + verifiedAt: Timestamp + verificationPhotoUrl: String # Proof of ownership + verificationId: String + + createdAt: Timestamp @default(expr: "request.time") + updatedAt: Timestamp @default(expr: "request.time") +} diff --git a/docs/ARCHITECTURE/web.md b/docs/ARCHITECTURE/web.md new file mode 100644 index 00000000..7c353ba8 --- /dev/null +++ b/docs/ARCHITECTURE/web.md @@ -0,0 +1,91 @@ +# KROW Web Application Architecture + +## 1. Overview +The KROW Web Application serves as the "Command Center" for the platform, catering to administrators, HR, finance, and client executives. It is a high-performance, scalable dashboard designed for complex data management and analytics. + +## 2. Tech Stack +- **Framework**: [React 19](https://react.dev/) +- **Build Tool**: [Vite](https://vitejs.dev/) +- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/) +- **State Management**: [Redux Toolkit](https://redux-toolkit.js.org/) +- **Data Fetching**: [TanStack Query (React Query)](https://tanstack.com/query/latest) +- **Backend Integration**: Firebase Data Connect + PostgreSQL +- **Language**: TypeScript + +## 3. Monorepo & Project Structure + +### Recommendation: Skip Nx +After evaluating `nx` for the KROW project, the recommendation is to **skip its adoption** at this stage. + +**Reasoning:** +- **Existing Orchestration**: The root `Makefile` and `Melos` (for mobile) already provide a robust orchestration layer. Adding `nx` would introduce redundant complexity. +- **Heterogeneous Stack**: `nx` excels in JS/TS-heavy monorepos. Our project is a mix of Flutter (Dart) and React (TS), which reduces the native benefits of `nx`. +- **Maintainability**: The overhead of learning and maintaining `nx` configurations outweighs the current benefits for a project of this scale. + +### Future Alternative: Turborepo +If caching and task orchestration become a bottleneck for the web/JS side, **Turborepo** is recommended as a lighter alternative that integrates seamlessly with our current `pnpm` setup. + +### Final Project Structure (Unified) +``` +/apps + /web # React Web Dashboard + /mobile # Flutter Mobile Apps (Melos monorepo) +/packages + /design-tokens # Shared Design System (TS/JSON) +/backend + /dataconnect # Shared GraphQL Schemas +/docs + /ARCHITECTURE # Architecture Documentation +/Makefile # Unified Command Orchestrator +``` + +## 4. Shared Design System + +### Package: `@krow/design-tokens` +A dedicated package at `/packages/design-tokens` serves as the single source of truth for design constants across all platforms. + +**Folder Structure:** +``` +/packages/design-tokens + /src + /colors.ts # Color palette definitions + /typography.ts # Typography scale and font families + /index.ts # Main export entry + /package.json + /tsconfig.json +``` + +### Color Palette (Aligned with Mobile) +Based on `UiColors` from the mobile app: +- **Primary**: `#0A39DF` (Brand Blue) +- **Accent**: `#F9E547` (Accent Yellow) +- **Background**: `#FAFBFC` +- **Foreground**: `#121826` +- **Secondary**: `#F1F3F5` +- **Muted**: `#F1F3F5` +- **Destructive**: `#F04444` (Error Red) +- **Success**: `#10B981` (Success Green) +- **Border**: `#D1D5DB` + +### Typography Scale (Aligned with Mobile) +- **Primary Font**: Instrument Sans +- **Secondary Font**: Space Grotesk +- **Scales**: + - **Display L**: 36px, Height 1.1 + - **Display M**: 32px, Height 1.1 + - **Title 1**: 18px, Height 1.5 + - **Body 1**: 16px, Height 1.5 + - **Body 2**: 14px, Height 1.5 + +### Implementation Strategy +1. **Definition**: Define tokens in TypeScript (or JSON) within `/packages/design-tokens`. +2. **Web Consumption**: Export tokens for use in `tailwind.config.ts` or as CSS variables. +3. **Mobile Consumption**: Use a script to generate `ui_colors.dart` and `ui_typography.dart` from the shared tokens to ensure perfect alignment. + +## 5. Web Application Organization +The web application follows a **feature-based** modular architecture: +- `src/features/`: Contains feature-specific logic, components, and hooks (e.g., `billing`, `scheduling`, `admin`). +- `src/components/shared/`: Reusable UI components built with Tailwind. +- `src/hooks/`: Shared React hooks. +- `src/store/`: Redux slices for global state. +- `src/dataconnect-generated/`: SDK generated by Firebase Data Connect. diff --git a/docs/api-contracts.md b/docs/BACKEND/API_GUIDES/00-initial-api-contracts.md similarity index 100% rename from docs/api-contracts.md rename to docs/BACKEND/API_GUIDES/00-initial-api-contracts.md diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/client_app_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/mobile/staff_app_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/business_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/staff_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/team_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/user_uml_diagram.mmd diff --git a/docs/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd b/docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd similarity index 100% rename from docs/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd rename to docs/BACKEND/DATACONNECT_GUIDES/DIAGRAMS/uml/vendor_uml_diagram_simplify.mmd diff --git a/docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md b/docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md similarity index 100% rename from docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md rename to docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md diff --git a/docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md b/docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md similarity index 100% rename from docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md rename to docs/BACKEND/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md diff --git a/docs/DATACONNECT_GUIDES/backend_cloud_run_functions.md b/docs/BACKEND/DATACONNECT_GUIDES/backend_cloud_run_functions.md similarity index 100% rename from docs/DATACONNECT_GUIDES/backend_cloud_run_functions.md rename to docs/BACKEND/DATACONNECT_GUIDES/backend_cloud_run_functions.md diff --git a/internal/launchpad/assets/documents/data connect/backend_manual.md b/internal/launchpad/assets/documents/data connect/backend_manual.md new file mode 100644 index 00000000..a256a882 --- /dev/null +++ b/internal/launchpad/assets/documents/data connect/backend_manual.md @@ -0,0 +1,294 @@ +# Krow Workforce – Backend Manual +Firebase Data Connect + Cloud SQL (PostgreSQL) + +--- + +## 1. Backend Overview + +This project uses Firebase Data Connect with Cloud SQL (PostgreSQL) as the main backend system. + +The architecture is based on: + +- GraphQL Schemas → Define database tables +- Connectors (Queries & Mutations) → Data access layer +- Cloud SQL → Real database +- Auto-generated SDK → Used by Web & Mobile apps +- Makefile → Automates backend workflows + +The goal is to keep the backend scalable, structured, and aligned with Web and Mobile applications. + +--- + +## 2. Project Structure + +``` +dataconnect/ +│ +├── dataconnect.yaml +├── schema/ +│ ├── Staff.gql +│ ├── Vendor.gql +│ ├── Business.gql +│ └── ... +│ +├── connector/ +│ ├── staff/ +│ │ ├── queries.gql +│ │ └── mutations.gql +│ ├── invoice/ +│ └── ... +│ +├── connector/connector.yaml +│ +docs/backend-diagrams/ +│ ├── business_uml_diagram.mmd +│ ├── staff_uml_diagram.mmd +│ ├── team_uml_diagram.mmd +│ ├── user_uml_diagram.mmd +│ └── vendor_uml_diagram_simplify.mmd +``` + +--- + +## 3. dataconnect.yaml (Main Configuration) + +```yaml +specVersion: "v1" +serviceId: "krow-workforce-db" +location: "us-central1" + +schema: + source: "./schema" + datasource: + postgresql: + database: "krow_db" + cloudSql: + instanceId: "krow-sql" + +connectorDirs: ["./connector"] +``` + +### Purpose + +| Field | Description | +|------|------------| +| serviceId | Data Connect service name | +| schema.source | Where GraphQL schemas live | +| datasource | Cloud SQL connection | +| connectorDirs | Where queries/mutations are | + +--- + +## 4. Database Schemas + +All database schemas are located in: + +``` +dataconnect/schema/ +``` + +Each `.gql` file represents a table: + +- Staff.gql +- Invoice.gql +- ShiftRole.gql +- Application.gql +- etc. + +Schemas define: + +- Fields +- Enums +- Relationships (`@ref`) +- Composite keys (`key: []`) + +--- + +## 5. Queries & Mutations (Connectors) + +Located in: + +``` +dataconnect/connector// +``` + +Example: + +``` +dataconnect/connector/staff/queries.gql +dataconnect/connector/staff/mutations.gql +``` + +Each folder represents one entity. + +This layer defines: + +- listStaff +- getStaffById +- createStaff +- updateStaff +- deleteStaff +- etc. + +--- + +## 6. connector.yaml (SDK Generator) + +```yaml +connectorId: example +generate: + dartSdk: + - outputDir: ../../mobile/staff/staff_app_mvp/lib/dataconnect_generated + package: dataconnect_generated/generated.dart + - outputDir: ../../mobile/client/client_app_mvp/lib/dataconnect_generated + package: dataconnect_generated/generated.dart +``` + +This file generates the SDK for: + +- Staff Mobile App +- Client Mobile App + +--- + +## 7. What is the SDK? + +The SDK is generated using: + +```bash +firebase dataconnect:sdk:generate +``` + +It allows the apps to: + +- Call queries/mutations +- Use strong typing +- Avoid manual GraphQL +- Reduce runtime errors + +Example in Flutter: + +```dart +client.listStaff(); +client.createInvoice(); +``` + +--- + +## 8. Makefile – Automation Commands + +### Main Commands + +| Command | Purpose | +|--------|---------| +| dataconnect-enable-apis | Enable required APIs | +| dataconnect-init | Initialize Data Connect | +| dataconnect-deploy | Deploy schemas | +| dataconnect-sql-migrate | Apply DB migrations | +| dataconnect-generate-sdk | Generate SDK | +| dataconnect-sync | Full backend update | +| dataconnect-test | Test without breaking | +| dataconnect-seed | Insert seed data | +| dataconnect-bootstrap-db | Create Cloud SQL | + +--- + +## 9. Correct Backend Workflow + +### Production Flow + +```bash +make dataconnect-sync +``` + +Steps: + +1. Deploy schema +2. Run SQL migrations +3. Generate SDK + +--- + +### Safe Test Flow + +```bash +make dataconnect-test +``` + +This runs: + +- Deploy dry-run +- SQL diff +- Shows errors without changing DB + +--- + +## 10. Seed Data + +Current command: + +```make +dataconnect-seed: + @firebase dataconnect:execute seeds/seed_min.graphql --project=$(FIREBASE_ALIAS) +``` + +Purpose: + +- Validate schema +- Detect missing tables +- Prevent bad inserts + +--- + +## 11. UML Diagrams + +Located in: + +``` +docs/backend-diagrams/ +``` + +Divided by role: + +| File | Scope | +|------|-------| +| user_uml_diagram.mmd | User | +| staff_uml_diagram.mmd | Staff | +| vendor_uml_diagram_simplify.mmd | Vendor | +| business_uml_diagram.mmd | Business | +| team_uml_diagram.mmd | Teams | + +Used with Mermaid to visualize relationships. + +--- + +## 12. Core Business Workflow + +```text +Order + → Shift + → ShiftRole + → Application + → Workforce + → Assignment + → Invoice + → RecentPayment +``` + +This represents the full work & payment lifecycle. + +--- + +## 13. Final Notes + +This backend is designed to: + +- Scale efficiently +- Maintain data consistency +- Align Web & Mobile models +- Support reporting and billing +- Avoid duplicated data + +--- + +END OF MANUAL diff --git a/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md b/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md new file mode 100644 index 00000000..7585a341 --- /dev/null +++ b/internal/launchpad/assets/documents/data connect/schema_dataconnect_guide.md @@ -0,0 +1,1300 @@ +# Data Connect Architecture Guide V.3 + +## 1. Introduction +This guide consolidates the Data Connect domain documentation into a single reference for engineers, product stakeholders, and QA. Use it to understand the entity relationships, operational flows, and available API operations across the platform. + +## 2. Table of Contents +- [System Overview](#system-overview) +- [Identity Domain](#identity-domain) +- [Operations Domain](#operations-domain) +- [Billing Domain](#billing-domain) +- [Teams Domain](#teams-domain) +- [Messaging Domain](#messaging-domain) +- [Compliance Domain](#compliance-domain) +- [Learning Domain](#learning-domain) +- [Sequence Diagrams](#sequence-diagrams) +- [API Catalog](#api-catalog) + +## System Overview + +### Summary +Summarizes the high-level relationships between core entities in the system. +Highlights the user role model and how orders progress into staffing, assignments, and invoices. +Includes the communication entities that connect users, conversations, and messages. + +### Full Content +# System Overview Flowchart + +## Description +This flowchart illustrates the high-level relationships between the main entities in the system. It shows the core workflows for user roles, order processing, and communication. + +## Flowchart +```mermaid +flowchart LR + subgraph "User Roles" + U(User) --> S(Staff) + U --> V(Vendor) + U --> B(Business) + end + + subgraph "Order & Fulfillment" + B --> O(Order) + V --> O(Order) + O --> SH(Shift) + SH --> APP(Application) + S --> APP + APP --> AS(Assignment) + AS --> I(Invoice) + end + + subgraph "Communication" + C(Conversation) --> M(Message) + UC(UserConversation) --> U + UC --> C + end + + style S fill:#f9f,stroke:#333,stroke-width:2px + style V fill:#ccf,stroke:#333,stroke-width:2px + style B fill:#cfc,stroke:#333,stroke-width:2px +``` + +## Identity Domain + +### Summary +Explains how users map to staff, vendors, and businesses in the identity model. +Shows the role hierarchy from staff roles to roles and role categories. +Focuses on the core identity relationships used across the platform. + +### Full Content +# Identity Domain Flowchart + +## Description +Este diagrama de flujo detalla las relaciones de alto nivel entre las entidades de identidad clave del sistema, como usuarios, personal, proveedores, empresas y sus roles asociados. + +## Flowchart +```mermaid +flowchart LR + U(User) --> S(Staff) + U --> V(Vendor) + U --> B(Business) + + S --> SR(StaffRole) + SR --> R(Role) + R --> RC(RoleCategory) + + style U fill:#f9f,stroke:#333,stroke-width:2px + style S fill:#ccf,stroke:#333,stroke-width:2px + style V fill:#cfc,stroke:#333,stroke-width:2px + style B fill:#ffc,stroke:#333,stroke-width:2px +``` + +## Operations Domain + +### Summary +Describes the operational lifecycle from orders through shifts and applications. +Connects staffing and workforce records to assignments and invoicing outcomes. +Illustrates the end-to-end flow for fulfillment and billing readiness. + +### Full Content +# Operations Domain Flowchart + +## Description +This flowchart explains the lifecycle of an order, from its creation and staffing to the final invoice generation. + +## Flowchart +```mermaid +flowchart TD + O(Order) --> S(Shift) + S --> SR(ShiftRole) + SR --> A(Application) + U(User) --> A + A --> W(WorkForce) + W --> AS(Assignment) + AS --> I(Invoice) + + style O fill:#f9f,stroke:#333,stroke-width:2px + style S fill:#ccf,stroke:#333,stroke-width:2px + style A fill:#cfc,stroke:#333,stroke-width:2px + style AS fill:#ffc,stroke:#333,stroke-width:2px + style I fill:#f99,stroke:#333,stroke-width:2px +``` + +## Billing Domain + +### Summary +Centers the billing process on invoices linked to orders, businesses, and vendors. +Shows how recent payments attach to invoices and reference applications for context. +Provides the upstream operational context that feeds billing records. + +### Full Content +# Billing Domain Flowchart + +## Description +Based on the repository's schema, the billing process centers around the `Invoice` entity. An `Invoice` is generated in the context of an `Order` and is explicitly linked to both a `Business` (the client) and a `Vendor` (the provider). Each invoice captures essential details from these parent entities. Financial transactions are tracked through the `RecentPayment` entity, which is directly tied to a specific `Invoice`, creating a clear record of payments made against that invoice. + +## Verified Relationships (evidence) +- `Invoice.vendorId` -> `Vendor.id` (source: `dataconnect/schema/invoice.gql`) +- `Invoice.businessId` -> `Business.id` (source: `dataconnect/schema/invoice.gql`) +- `Invoice.orderId` -> `Order.id` (source: `dataconnect/schema/invoice.gql`) +- `RecentPayment.invoiceId` -> `Invoice.id` (source: `dataconnect/schema/recentPayment.gql`) +- `RecentPayment.applicationId` -> `Application.id` (source: `dataconnect/schema/recentPayment.gql`) + +## Flowchart +```mermaid +flowchart TD + %% ----------------------------- + %% Billing Core + %% ----------------------------- + B(Business) --> O(Order) + V(Vendor) --> O + O --> I(Invoice) + I --> RP(RecentPayment) + A(Application) --> RP + + %% ----------------------------- + %% Upstream Operations (Context) + %% ----------------------------- + subgraph OPS[Upstream Operations Context] + O --> S(Shift) + S --> SR(ShiftRole) + SR --> A + ST(Staff) --> A + A --> AS(Assignment) + W(Workforce) --> AS + ST --> W + end +``` + +## Teams Domain + +### Summary +Details how teams, members, hubs, and departments structure organizational data. +Covers task management via tasks, member assignments, and task comments. +Notes verified and missing relationships that affect traceability in the schema. + +### Full Content +# Teams Domain Flowchart + +## Description +The Teams domain in this repository organizes users and their associated tasks. The `Team` is the central entity, with a `User` joining via the `TeamMember` join table, which defines their role. Teams can be structured using `TeamHub` (locations) and `TeamHudDepartment` (departments). The domain also includes task management. A `Task` can be assigned to a `TeamMember` through the `MemberTask` entity. Communication on tasks is handled by `TaskComment`, which is linked directly to the `TeamMember` who made the comment, providing a clear link between team structure and actionable work. + +## Entities in Scope +- Team +- TeamMember +- User +- TeamHub +- TeamHudDepartment +- Task +- MemberTask +- TaskComment + +## Verified Relationships (evidence) +- `TeamMember.teamId` -> `Team.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamMember.userId` -> `User.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamMember.teamHubId` -> `TeamHub.id` (source: `dataconnect/schema/teamMember.gql`) +- `TeamHub.teamId` -> `Team.id` (source: `dataconnect/schema/teamHub.gql`, implicit via field name) +- `TeamHudDepartment.teamHubId` -> `TeamHub.id` (source: `dataconnect/schema/teamHudDeparment.gql`) +- `MemberTask.teamMemberId` -> `TeamMember.id` (source: `dataconnect/schema/memberTask.gql`) +- `MemberTask.taskId` -> `Task.id` (source: `dataconnect/schema/memberTask.gql`) +- `TaskComment.teamMemberId` -> `TeamMember.id` (source: `dataconnect/schema/task_comment.gql`) +- Not found: `Team.ownerId` is a generic `String` and does not have a `@ref` to `Vendor` or `Business`. +- Not found: `TaskComment.taskId` exists but has no `@ref` to `Task.id`. + +## Flowchart +```mermaid +--- +config: + layout: elk +--- +--- +config: + layout: elk +--- +flowchart TB + subgraph STRUCTURE[Team Structure] + T(Team) --> TM(TeamMember) + T --> TH(TeamHub) + TH --> THD(TeamHudDepartment) + U(User) --> TM + TM --> TH + end + + subgraph WORK[Work & Tasks] + TK(Task) --> MT(MemberTask) + TM --> MT + TM --> TC(TaskComment) + TK --> TC + end + +``` + +## Messaging Domain + +### Summary +Defines conversations as the container for chat metadata and history. +Links messages and user participation through user conversations. +Distinguishes verified and inferred relationships between entities. + +### Full Content +# Messaging Domain Flowchart + +## Description +The messaging system is designed around three core entities. The `Conversation` entity acts as the central container, holding metadata about a specific chat, such as its subject and type (e.g., group chat, client-vendor). The actual content of the conversation is stored in the `Message` entity, where each message is linked to its parent `Conversation` and the `User` who sent it. To track the state for each participant, the `UserConversation` entity links a `User` to a `Conversation` and stores per-user data, such as the number of unread messages and when they last read the chat. + +## Entities in Scope +- Conversation +- Message +- UserConversation +- User + +## Verified Relationships (evidence) +- `Message.senderId` -> `User.id` (source: `dataconnect/schema/message.gql`) +- `UserConversation.conversationId` -> `Conversation.id` (source: `dataconnect/schema/userConversation.gql`) +- `UserConversation.userId` -> `User.id` (source: `dataconnect/schema/userConversation.gql`) + +## Inferred Relationships (if any) +- `Message.conversationId` -> `Conversation.id` (source: `dataconnect/schema/message.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph "Conversation Metadata" + C(Conversation) + end + + subgraph "Message Content & User State" + M(Message) + UC(UserConversation) + U(User) + end + + C -- Inferred --- M + C -- Verified --- UC + + U -- Verified --- UC + U -- Verified --- M +``` + +## Compliance Domain + +### Summary +Explains how staff compliance is tracked through documents and submissions. +Includes required documents, tax forms, and certificates tied to staff records. +Separates verified links from inferred relationships for compliance entities. + +### Full Content +# Compliance Domain Flowchart + +## Description +The compliance domain manages the necessary documentation and certifications for staff members. The system defines a list of document types via the `Document` entity. Staff members submit their compliance files through `StaffDocument`, which links a specific staff member to a generic document definition. Additionally, `RequiredDoc`, `TaxForm`, and `Certificate` entities are used to track other specific compliance items, such as mandatory documents, tax forms (like W-4s), and professional certificates, all of which are linked back to a particular staff member. + +## Entities in Scope +- Document +- StaffDocument +- RequiredDoc +- TaxForm +- Certificate +- Staff + +## Verified Relationships (evidence) +- `StaffDocument.documentId` -> `Document.id` (source: `dataconnect/schema/staffDocument.gql`) +- `Certificate.staffId` -> `Staff.id` (source: `dataconnect/schema/certificate.gql`) + +## Inferred Relationships (if any) +- `StaffDocument.staffId` -> `Staff.id` (source: `dataconnect/schema/staffDocument.gql`, inferred from field name) +- `RequiredDoc.staffId` -> `Staff.id` (source: `dataconnect/schema/requiredDoc.gql`, inferred from field name) +- `TaxForm.staffId` -> `Staff.id` (source: `dataconnect/schema/taxForm.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph subGraph0["Compliance Requirements"] + D("Document") + end + subgraph subGraph1["Staff Submissions & Documents"] + S("Staff") + SD("StaffDocument") + TF("TaxForm") + C("Certificate") + end + D -- Verified --> SD + S -- Inferred --> SD & TF + S -- Verified --> C +``` + +## Learning Domain + +### Summary +Outlines the training model with courses, categories, and levels. +Shows how staff progress is captured via staff course records. +Calls out relationships that are inferred versus explicitly modeled. + +### Full Content +# Learning Domain Flowchart + +## Description +The learning domain provides a structured training system for staff. The core component is the `Course`, which represents an individual training module with a title, description, and associated `Category`. While the `Level` entity exists to define progression tiers (e.g., based on experience points), it is not directly linked to courses in the current schema. The `StaffCourse` entity tracks the progress of a staff member in a specific course, recording their completion status and timestamps. Certificates are not explicitly linked to course completion in the schema. + +## Entities in Scope +- Course +- Category +- Level +- StaffCourse +- Staff + +## Verified Relationships (evidence) +- `Course.categoryId` -> `Category.id` (source: `dataconnect/schema/course.gql`) + +## Inferred Relationships (if any) +- `StaffCourse.staffId` -> `Staff.id` (source: `dataconnect/schema/staffCourse.gql`, inferred from field name) +- `StaffCourse.courseId` -> `Course.id` (source: `dataconnect/schema/staffCourse.gql`, inferred from field name) + +## Flowchart +```mermaid +flowchart TB + subgraph "Training Structure" + C(Course) + CAT(Category) + L(Level) + end + + subgraph "Staff Participation" + S(Staff) + SC(StaffCourse) + end + + CAT -- Verified --> C + + S -- Verified --> SC + C -- Verified --> SC +``` + +## Sequence Diagrams + +### Summary +Walks through the order-to-invoice sequence based on connector operations. +Lists the verified mutation steps that drive the operational flow. +Visualizes participant interactions from creation through billing. + +### Full Content +# Operations Sequence Diagrams + +## Flow 1: Order to Invoice + +### Description +Based on the repository's connector operations, the operational flow begins when a user creates an `Order`. From this order, one or more `Shifts` are generated. A `Staff` member can then apply to a specific `Shift`, creating an `Application`. Subsequently, an `Assignment` is created, linking a `Workforce` member to that `Shift`. While this represents the staffing and fulfillment part of the process, the billing cycle is handled separately. An `Invoice` is generated directly from the parent `Order`, rather than from the individual assignments, consolidating all billing at the order level. + +### Verified Steps (Evidence) +- `createOrder` (source: `dataconnect/connector/order/mutations.gql`) +- `createShift` (source: `dataconnect/connector/shift/mutations.gql`) +- `createShiftRole` (source: `dataconnect/connector/shiftRole/mutations.gql`) +- `createApplication` (source: `dataconnect/connector/application/mutations.gql`) +- `CreateAssignment` (source: `dataconnect/connector/assignment/mutations.gql`) +- `createInvoice` (source: `dataconnect/connector/invoice/mutations.gql`) + +### Sequence Diagram +```mermaid +sequenceDiagram + participant Business as Business (Client) + participant Vendor as Vendor (Provider) + participant Order + participant Shift + participant ShiftRole + participant Staff + participant Application + participant Workforce + participant Assignment + participant Invoice + + Business->>Order: createOrder(businessId, vendorId, ...) + Order-->>Business: Order created (orderId) + + Vendor->>Shift: createShift(orderId, ...) + Shift-->>Vendor: Shift created (shiftId) + + Vendor->>ShiftRole: createShiftRole(shiftId, roleId, workersNeeded, rate, ...) + ShiftRole-->>Vendor: ShiftRole created (shiftRoleId) + + Staff->>Application: createApplication(shiftId OR shiftRoleId, staffId, ...) + Application-->>Staff: Application submitted (applicationId) + + Vendor->>Workforce: createWorkforce(applicationId / staffId / shiftId, ...) + Workforce-->>Vendor: Workforce created (workforceId) + + Vendor->>Assignment: createAssignment(shiftId, workforceId, staffId, ...) + Assignment-->>Vendor: Assignment created (assignmentId) + + Vendor->>Invoice: createInvoice(orderId, businessId, vendorId, ...) + Invoice-->>Vendor: Invoice created (invoiceId) + +``` + +## API Catalog + +### Summary +Lists every GraphQL query and mutation in the Data Connect connectors. +Provides parameters and top-level return/affect fields for each operation. +Organizes operations by entity folder for quick discovery and reference. + +### Full Content +# API Catalog – Data Connect + +## Overview +This catalog enumerates every GraphQL query and mutation defined in the Data Connect connector folders under `prototypes/dataconnect/connector/`. Use it to discover available operations, required parameters, and the top-level fields returned or affected by each operation. + +## account + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAccounts` | List accounts | — | `accounts` | +| `getAccountById` | Get account by id | `$id: UUID!` | `account` | +| `getAccountsByOwnerId` | Get accounts by owner id | `$ownerId: UUID!` | `accounts` | +| `filterAccounts` | Filter accounts | `$bank: String`
`$type: AccountType`
`$isPrimary: Boolean`
`$ownerId: UUID` | `accounts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createAccount` | Create account | `$bank: String!`
`$type: AccountType!`
`$last4: String!`
`$isPrimary: Boolean`
`$ownerId: UUID!`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_insert` | +| `updateAccount` | Update account | `$id: UUID!`
`$bank: String`
`$type: AccountType`
`$last4: String`
`$isPrimary: Boolean`
`$accountNumber: String`
`$routeNumber: String`
`$expiryTime: Timestamp` | `account_update` | +| `deleteAccount` | Delete account | `$id: UUID!` | `account_delete` | + +## activityLog + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listActivityLogs` | List activity logs | `$offset: Int`
`$limit: Int` | `activityLogs` | +| `getActivityLogById` | Get activity log by id | `$id: UUID!` | `activityLog` | +| `listActivityLogsByUserId` | List activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `listUnreadActivityLogsByUserId` | List unread activity logs by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `activityLogs` | +| `filterActivityLogs` | Filter activity logs | `$userId: String`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$isRead: Boolean`
`$activityType: ActivityType`
`$iconType: ActivityIconType`
`$offset: Int`
`$limit: Int` | `activityLogs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createActivityLog` | Create activity log | `$userId: String!`
`$date: Timestamp!`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String!`
`$description: String!`
`$isRead: Boolean`
`$activityType: ActivityType!` | `activityLog_insert` | +| `updateActivityLog` | Update activity log | `$id: UUID!`
`$userId: String`
`$date: Timestamp`
`$hourStart: String`
`$hourEnd: String`
`$totalhours: String`
`$iconType: ActivityIconType`
`$iconColor: String`
`$title: String`
`$description: String`
`$isRead: Boolean`
`$activityType: ActivityType` | `activityLog_update` | +| `markActivityLogAsRead` | Mark activity log as read | `$id: UUID!` | `activityLog_update` | +| `markActivityLogsAsRead` | Mark activity logs as read | `$ids: [UUID!]!` | `activityLog_updateMany` | +| `deleteActivityLog` | Delete activity log | `$id: UUID!` | `activityLog_delete` | + +## application + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listApplications` | List applications | — | `applications` | +| `getApplicationById` | Get application by id | `$id: UUID!` | `application` | +| `getApplicationsByShiftId` | Get applications by shift id | `$shiftId: UUID!` | `applications` | +| `getApplicationsByShiftIdAndStatus` | Get applications by shift id and status | `$shiftId: UUID!`
`$status: ApplicationStatus!`
`$offset: Int`
`$limit: Int` | `applications` | +| `getApplicationsByStaffId` | Get applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `vaidateDayStaffApplication` | Vaidate day staff application | `$staffId: UUID!`
`$offset: Int`
`$limit: Int`
`$dayStart: Timestamp`
`$dayEnd: Timestamp` | `applications` | +| `getApplicationByStaffShiftAndRole` | Get application by staff shift and role | `$staffId: UUID!`
`$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByShiftRoleKey` | List accepted applications by shift role key | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listAcceptedApplicationsByBusinessForDay` | List accepted applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listStaffsApplicationsByBusinessForDay` | List staffs applications by business for day | `$businessId: UUID!`
`$dayStart: Timestamp!`
`$dayEnd: Timestamp!`
`$offset: Int`
`$limit: Int` | `applications` | +| `listCompletedApplicationsByStaffId` | List completed applications by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `applications` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createApplication` | Create application | `$shiftId: UUID!`
`$staffId: UUID!`
`$status: ApplicationStatus!`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$origin: ApplicationOrigin!`
`$roleId: UUID!` | `application_insert` | +| `updateApplicationStatus` | Update application status | `$id: UUID!`
`$shiftId: UUID`
`$staffId: UUID`
`$status: ApplicationStatus`
`$checkInTime: Timestamp`
`$checkOutTime: Timestamp`
`$roleId: UUID` | `application_update` | +| `deleteApplication` | Delete application | `$id: UUID!` | `application_delete` | + +## assignment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAssignments` | List assignments | `$offset: Int`
`$limit: Int` | `assignments` | +| `getAssignmentById` | Get assignment by id | `$id: UUID!` | `assignment` | +| `listAssignmentsByWorkforceId` | List assignments by workforce id | `$workforceId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByWorkforceIds` | List assignments by workforce ids | `$workforceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `listAssignmentsByShiftRole` | List assignments by shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `assignments` | +| `represents` | Represents | — | `assignments` | +| `filterAssignments` | Filter assignments | `$shiftIds: [UUID!]!`
`$roleIds: [UUID!]!`
`$status: AssignmentStatus`
`$offset: Int`
`$limit: Int` | `assignments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateAssignment` | Create assignment | `$workforceId: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_insert` | +| `UpdateAssignment` | Update assignment | `$id: UUID!`
`$title: String`
`$description: String`
`$instructions: String`
`$status: AssignmentStatus`
`$tipsAvailable: Boolean`
`$travelTime: Boolean`
`$mealProvided: Boolean`
`$parkingAvailable: Boolean`
`$gasCompensation: Boolean`
`$managers: [Any!]`
`$roleId: UUID!`
`$shiftId: UUID!` | `assignment_update` | +| `DeleteAssignment` | Delete assignment | `$id: UUID!` | `assignment_delete` | + +## attireOption + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listAttireOptions` | List attire options | — | `attireOptions` | +| `getAttireOptionById` | Get attire option by id | `$id: UUID!` | `attireOption` | +| `filterAttireOptions` | Filter attire options | `$itemId: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOptions` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createAttireOption` | Create attire option | `$itemId: String!`
`$label: String!`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_insert` | +| `updateAttireOption` | Update attire option | `$id: UUID!`
`$itemId: String`
`$label: String`
`$icon: String`
`$imageUrl: String`
`$isMandatory: Boolean`
`$vendorId: UUID` | `attireOption_update` | +| `deleteAttireOption` | Delete attire option | `$id: UUID!` | `attireOption_delete` | + +## benefitsData + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listBenefitsData` | List benefits data | `$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `getBenefitsDataByKey` | Get benefits data by key | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData` | +| `listBenefitsDataByStaffId` | List benefits data by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanId` | List benefits data by vendor benefit plan id | `$vendorBenefitPlanId: UUID!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | +| `listBenefitsDataByVendorBenefitPlanIds` | List benefits data by vendor benefit plan ids | `$vendorBenefitPlanIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `benefitsDatas` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createBenefitsData` | Create benefits data | `$vendorBenefitPlanId: UUID!`
`$staffId: UUID!`
`$current: Int!` | `benefitsData_insert` | +| `updateBenefitsData` | Update benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!`
`$current: Int` | `benefitsData_update` | +| `deleteBenefitsData` | Delete benefits data | `$staffId: UUID!`
`$vendorBenefitPlanId: UUID!` | `benefitsData_delete` | + +## business + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listBusinesses` | List businesses | — | `businesses` | +| `getBusinessesByUserId` | Get businesses by user id | `$userId: String!` | `businesses` | +| `getBusinessById` | Get business by id | `$id: UUID!` | `business` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createBusiness` | Create business | `$businessName: String!`
`$contactName: String`
`$userId: String!`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup!`
`$status: BusinessStatus!`
`$notes: String` | `business_insert` | +| `updateBusiness` | Update business | `$id: UUID!`
`$businessName: String`
`$contactName: String`
`$companyLogoUrl: String`
`$phone: String`
`$email: String`
`$hubBuilding: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$area: BusinessArea`
`$sector: BusinessSector`
`$rateGroup: BusinessRateGroup`
`$status: BusinessStatus`
`$notes: String` | `business_update` | +| `deleteBusiness` | Delete business | `$id: UUID!` | `business_delete` | + +## category + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCategories` | List categories | — | `categories` | +| `getCategoryById` | Get category by id | `$id: UUID!` | `category` | +| `filterCategories` | Filter categories | `$categoryId: String`
`$label: String` | `categories` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCategory` | Create category | `$categoryId: String!`
`$label: String!`
`$icon: String` | `category_insert` | +| `updateCategory` | Update category | `$id: UUID!`
`$categoryId: String`
`$label: String`
`$icon: String` | `category_update` | +| `deleteCategory` | Delete category | `$id: UUID!` | `category_delete` | + +## certificate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCertificates` | List certificates | — | `certificates` | +| `getCertificateById` | Get certificate by id | `$id: UUID!` | `certificate` | +| `listCertificatesByStaffId` | List certificates by staff id | `$staffId: UUID!` | `certificates` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateCertificate` | Create certificate | `$name: String!`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus!`
`$fileUrl: String`
`$icon: String`
`$certificationType: ComplianceType`
`$issuer: String`
`$staffId: UUID!`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_insert` | +| `UpdateCertificate` | Update certificate | `$id: UUID!`
`$name: String`
`$description: String`
`$expiry: Timestamp`
`$status: CertificateStatus`
`$fileUrl: String`
`$icon: String`
`$staffId: UUID`
`$certificationType: ComplianceType`
`$issuer: String`
`$validationStatus: ValidationStatus`
`$certificateNumber: String` | `certificate_update` | +| `DeleteCertificate` | Delete certificate | `$id: UUID!` | `certificate_delete` | + +## clientFeedback + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listClientFeedbacks` | List client feedbacks | `$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `getClientFeedbackById` | Get client feedback by id | `$id: UUID!` | `clientFeedback` | +| `listClientFeedbacksByBusinessId` | List client feedbacks by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByVendorId` | List client feedbacks by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbacksByBusinessAndVendor` | List client feedbacks by business and vendor | `$businessId: UUID!`
`$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `filterClientFeedbacks` | Filter client feedbacks | `$businessId: UUID`
`$vendorId: UUID`
`$ratingMin: Int`
`$ratingMax: Int`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `clientFeedbacks` | +| `listClientFeedbackRatingsByVendorId` | List client feedback ratings by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp` | `clientFeedbacks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createClientFeedback` | Create client feedback | `$businessId: UUID!`
`$vendorId: UUID!`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_insert` | +| `updateClientFeedback` | Update client feedback | `$id: UUID!`
`$businessId: UUID`
`$vendorId: UUID`
`$rating: Int`
`$comment: String`
`$date: Timestamp`
`$createdBy: String` | `clientFeedback_update` | +| `deleteClientFeedback` | Delete client feedback | `$id: UUID!` | `clientFeedback_delete` | + +## conversation + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listConversations` | List conversations | `$offset: Int`
`$limit: Int` | `conversations` | +| `getConversationById` | Get conversation by id | `$id: UUID!` | `conversation` | +| `listConversationsByType` | List conversations by type | `$conversationType: ConversationType!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `listConversationsByStatus` | List conversations by status | `$status: ConversationStatus!`
`$offset: Int`
`$limit: Int` | `conversations` | +| `filterConversations` | Filter conversations | `$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$lastMessageAfter: Timestamp`
`$lastMessageBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `conversations` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createConversation` | Create conversation | `$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_insert` | +| `updateConversation` | Update conversation | `$id: UUID!`
`$subject: String`
`$status: ConversationStatus`
`$conversationType: ConversationType`
`$isGroup: Boolean`
`$groupName: String`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `updateConversationLastMessage` | Update conversation last message | `$id: UUID!`
`$lastMessage: String`
`$lastMessageAt: Timestamp` | `conversation_update` | +| `deleteConversation` | Delete conversation | `$id: UUID!` | `conversation_delete` | + +## course + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCourses` | List courses | — | `courses` | +| `getCourseById` | Get course by id | `$id: UUID!` | `course` | +| `filterCourses` | Filter courses | `$categoryId: UUID`
`$isCertification: Boolean`
`$levelRequired: String`
`$completed: Boolean` | `courses` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCourse` | Create course | `$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_insert` | +| `updateCourse` | Update course | `$id: UUID!`
`$title: String`
`$description: String`
`$thumbnailUrl: String`
`$durationMinutes: Int`
`$xpReward: Int`
`$categoryId: UUID!`
`$levelRequired: String`
`$isCertification: Boolean` | `course_update` | +| `deleteCourse` | Delete course | `$id: UUID!` | `course_delete` | + +## customRateCard + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listCustomRateCards` | List custom rate cards | — | `customRateCards` | +| `getCustomRateCardById` | Get custom rate card by id | `$id: UUID!` | `customRateCard` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createCustomRateCard` | Create custom rate card | `$name: String!`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_insert` | +| `updateCustomRateCard` | Update custom rate card | `$id: UUID!`
`$name: String`
`$baseBook: String`
`$discount: Float`
`$isDefault: Boolean` | `customRateCard_update` | +| `deleteCustomRateCard` | Delete custom rate card | `$id: UUID!` | `customRateCard_delete` | + +## document + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listDocuments` | List documents | — | `documents` | +| `getDocumentById` | Get document by id | `$id: UUID!` | `document` | +| `filterDocuments` | Filter documents | `$documentType: DocumentType` | `documents` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createDocument` | Create document | `$documentType: DocumentType!`
`$name: String!`
`$description: String` | `document_insert` | +| `updateDocument` | Update document | `$id: UUID!`
`$documentType: DocumentType`
`$name: String`
`$description: String` | `document_update` | +| `deleteDocument` | Delete document | `$id: UUID!` | `document_delete` | + +## emergencyContact + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listEmergencyContacts` | List emergency contacts | — | `emergencyContacts` | +| `getEmergencyContactById` | Get emergency contact by id | `$id: UUID!` | `emergencyContact` | +| `getEmergencyContactsByStaffId` | Get emergency contacts by staff id | `$staffId: UUID!` | `emergencyContacts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createEmergencyContact` | Create emergency contact | `$name: String!`
`$phone: String!`
`$relationship: RelationshipType!`
`$staffId: UUID!` | `emergencyContact_insert` | +| `updateEmergencyContact` | Update emergency contact | `$id: UUID!`
`$name: String`
`$phone: String`
`$relationship: RelationshipType` | `emergencyContact_update` | +| `deleteEmergencyContact` | Delete emergency contact | `$id: UUID!` | `emergencyContact_delete` | + +## faqData + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listFaqDatas` | List faq datas | — | `faqDatas` | +| `getFaqDataById` | Get faq data by id | `$id: UUID!` | `faqData` | +| `filterFaqDatas` | Filter faq datas | `$category: String` | `faqDatas` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createFaqData` | Create faq data | `$category: String!`
`$questions: [Any!]` | `faqData_insert` | +| `updateFaqData` | Update faq data | `$id: UUID!`
`$category: String`
`$questions: [Any!]` | `faqData_update` | +| `deleteFaqData` | Delete faq data | `$id: UUID!` | `faqData_delete` | + +## hub + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listHubs` | List hubs | — | `hubs` | +| `getHubById` | Get hub by id | `$id: UUID!` | `hub` | +| `getHubsByOwnerId` | Get hubs by owner id | `$ownerId: UUID!` | `hubs` | +| `filterHubs` | Filter hubs | `$ownerId: UUID`
`$name: String`
`$nfcTagId: String` | `hubs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createHub` | Create hub | `$name: String!`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID!` | `hub_insert` | +| `updateHub` | Update hub | `$id: UUID!`
`$name: String`
`$locationName: String`
`$address: String`
`$nfcTagId: String`
`$ownerId: UUID` | `hub_update` | +| `deleteHub` | Delete hub | `$id: UUID!` | `hub_delete` | + +## invoice + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listInvoices` | List invoices | `$offset: Int`
`$limit: Int` | `invoices` | +| `getInvoiceById` | Get invoice by id | `$id: UUID!` | `invoice` | +| `listInvoicesByVendorId` | List invoices by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByBusinessId` | List invoices by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByOrderId` | List invoices by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listInvoicesByStatus` | List invoices by status | `$status: InvoiceStatus!`
`$offset: Int`
`$limit: Int` | `invoices` | +| `filterInvoices` | Filter invoices | `$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$status: InvoiceStatus`
`$issueDateFrom: Timestamp`
`$issueDateTo: Timestamp`
`$dueDateFrom: Timestamp`
`$dueDateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `invoices` | +| `listOverdueInvoices` | List overdue invoices | `$now: Timestamp!`
`$offset: Int`
`$limit: Int` | `invoices` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createInvoice` | Create invoice | `$status: InvoiceStatus!`
`$vendorId: UUID!`
`$businessId: UUID!`
`$orderId: UUID!`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String!`
`$issueDate: Timestamp!`
`$dueDate: Timestamp!`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float!`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoice_insert` | +| `updateInvoice` | Update invoice | `$id: UUID!`
`$status: InvoiceStatus`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTerms`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int`
`$disputedItems: Any`
`$disputeReason: String`
`$disputeDetails: String` | `invoice_update` | +| `deleteInvoice` | Delete invoice | `$id: UUID!` | `invoice_delete` | + +## invoiceTemplate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listInvoiceTemplates` | List invoice templates | `$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `getInvoiceTemplateById` | Get invoice template by id | `$id: UUID!` | `invoiceTemplate` | +| `listInvoiceTemplatesByOwnerId` | List invoice templates by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByVendorId` | List invoice templates by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByBusinessId` | List invoice templates by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `listInvoiceTemplatesByOrderId` | List invoice templates by order id | `$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | +| `searchInvoiceTemplatesByOwnerAndName` | Search invoice templates by owner and name | `$ownerId: UUID!`
`$name: String!`
`$offset: Int`
`$limit: Int` | `invoiceTemplates` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createInvoiceTemplate` | Create invoice template | `$name: String!`
`$ownerId: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_insert` | +| `updateInvoiceTemplate` | Update invoice template | `$id: UUID!`
`$name: String`
`$ownerId: UUID`
`$vendorId: UUID`
`$businessId: UUID`
`$orderId: UUID`
`$paymentTerms: InovicePaymentTermsTemp`
`$invoiceNumber: String`
`$issueDate: Timestamp`
`$dueDate: Timestamp`
`$hub: String`
`$managerName: String`
`$vendorNumber: String`
`$roles: Any`
`$charges: Any`
`$otherCharges: Float`
`$subtotal: Float`
`$amount: Float`
`$notes: String`
`$staffCount: Int`
`$chargesCount: Int` | `invoiceTemplate_update` | +| `deleteInvoiceTemplate` | Delete invoice template | `$id: UUID!` | `invoiceTemplate_delete` | + +## level + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listLevels` | List levels | — | `levels` | +| `getLevelById` | Get level by id | `$id: UUID!` | `level` | +| `filterLevels` | Filter levels | `$name: String`
`$xpRequired: Int` | `levels` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createLevel` | Create level | `$name: String!`
`$xpRequired: Int!`
`$icon: String`
`$colors: Any` | `level_insert` | +| `updateLevel` | Update level | `$id: UUID!`
`$name: String`
`$xpRequired: Int`
`$icon: String`
`$colors: Any` | `level_update` | +| `deleteLevel` | Delete level | `$id: UUID!` | `level_delete` | + +## memberTask + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getMyTasks` | Get my tasks | `$teamMemberId: UUID!` | `memberTasks` | +| `getMemberTaskByIdKey` | Get member task by id key | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask` | +| `getMemberTasksByTaskId` | Get member tasks by task id | `$taskId: UUID!` | `memberTasks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createMemberTask` | Create member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_insert` | +| `deleteMemberTask` | Delete member task | `$teamMemberId: UUID!`
`$taskId: UUID!` | `memberTask_delete` | + +## message + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listMessages` | List messages | — | `messages` | +| `getMessageById` | Get message by id | `$id: UUID!` | `message` | +| `getMessagesByConversationId` | Get messages by conversation id | `$conversationId: UUID!` | `messages` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createMessage` | Create message | `$conversationId: UUID!`
`$senderId: String!`
`$content: String!`
`$isSystem: Boolean` | `message_insert` | +| `updateMessage` | Update message | `$id: UUID!`
`$conversationId: UUID`
`$senderId: String`
`$content: String`
`$isSystem: Boolean` | `message_update` | +| `deleteMessage` | Delete message | `$id: UUID!` | `message_delete` | + +## order + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listOrders` | List orders | `$offset: Int`
`$limit: Int` | `orders` | +| `getOrderById` | Get order by id | `$id: UUID!` | `order` | +| `getOrdersByBusinessId` | Get orders by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByVendorId` | Get orders by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByStatus` | Get orders by status | `$status: OrderStatus!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getOrdersByDateRange` | Get orders by date range | `$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `orders` | +| `getRapidOrders` | Get rapid orders | `$offset: Int`
`$limit: Int` | `orders` | +| `listOrdersByBusinessAndTeamHub` | List orders by business and team hub | `$businessId: UUID!`
`$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `orders` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createOrder` | Create order | `$vendorId: UUID`
`$businessId: UUID!`
`$orderType: OrderType!`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$duration: OrderDuration`
`$lunchBreak: Int`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentStartDate: Timestamp`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_insert` | +| `updateOrder` | Update order | `$id: UUID!`
`$vendorId: UUID`
`$businessId: UUID`
`$status: OrderStatus`
`$date: Timestamp`
`$startDate: Timestamp`
`$endDate: Timestamp`
`$total: Float`
`$eventName: String`
`$assignedStaff: Any`
`$shifts: Any`
`$requested: Int`
`$teamHubId: UUID!`
`$recurringDays: Any`
`$permanentDays: Any`
`$notes: String`
`$detectedConflicts: Any`
`$poReference: String` | `order_update` | +| `deleteOrder` | Delete order | `$id: UUID!` | `order_delete` | + +## recentPayment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRecentPayments` | List recent payments | `$offset: Int`
`$limit: Int` | `recentPayments` | +| `getRecentPaymentById` | Get recent payment by id | `$id: UUID!` | `recentPayment` | +| `listRecentPaymentsByStaffId` | List recent payments by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByApplicationId` | List recent payments by application id | `$applicationId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceId` | List recent payments by invoice id | `$invoiceId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByStatus` | List recent payments by status | `$status: RecentPaymentStatus!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByInvoiceIds` | List recent payments by invoice ids | `$invoiceIds: [UUID!]!`
`$offset: Int`
`$limit: Int` | `recentPayments` | +| `listRecentPaymentsByBusinessId` | List recent payments by business id | `$businessId: UUID!`
`$offset: Int`
`$limit: Int` | `recentPayments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRecentPayment` | Create recent payment | `$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID!`
`$applicationId: UUID!`
`$invoiceId: UUID!` | `recentPayment_insert` | +| `updateRecentPayment` | Update recent payment | `$id: UUID!`
`$workedTime: String`
`$status: RecentPaymentStatus`
`$staffId: UUID`
`$applicationId: UUID`
`$invoiceId: UUID` | `recentPayment_update` | +| `deleteRecentPayment` | Delete recent payment | `$id: UUID!` | `recentPayment_delete` | + +## reports + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listShiftsForCoverage` | List shifts for coverage | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForCoverage` | List applications for coverage | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForDailyOpsByBusiness` | List shifts for daily ops by business | `$businessId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listShiftsForDailyOpsByVendor` | List shifts for daily ops by vendor | `$vendorId: UUID!`
`$date: Timestamp!` | `shifts` | +| `listApplicationsForDailyOps` | List applications for daily ops | `$shiftIds: [UUID!]!` | `applications` | +| `listShiftsForForecastByBusiness` | List shifts for forecast by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForForecastByVendor` | List shifts for forecast by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByBusiness` | List shifts for no show range by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForNoShowRangeByVendor` | List shifts for no show range by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForNoShowRange` | List applications for no show range | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForNoShowReport` | List staff for no show report | `$staffIds: [UUID!]!` | `staffs` | +| `listInvoicesForSpendByBusiness` | List invoices for spend by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByVendor` | List invoices for spend by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listInvoicesForSpendByOrder` | List invoices for spend by order | `$orderId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `invoices` | +| `listTimesheetsForSpend` | List timesheets for spend | `$startTime: Timestamp!`
`$endTime: Timestamp!` | `shiftRoles` | +| `listShiftsForPerformanceByBusiness` | List shifts for performance by business | `$businessId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listShiftsForPerformanceByVendor` | List shifts for performance by vendor | `$vendorId: UUID!`
`$startDate: Timestamp!`
`$endDate: Timestamp!` | `shifts` | +| `listApplicationsForPerformance` | List applications for performance | `$shiftIds: [UUID!]!` | `applications` | +| `listStaffForPerformance` | List staff for performance | `$staffIds: [UUID!]!` | `staffs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| — | — | — | — | + +Notes: Used by Reports. + +## role + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRoles` | List roles | — | `roles` | +| `getRoleById` | Get role by id | `$id: UUID!` | `role` | +| `listRolesByVendorId` | List roles by vendor id | `$vendorId: UUID!` | `roles` | +| `listRolesByroleCategoryId` | List roles byrole category id | `$roleCategoryId: UUID!` | `roles` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRole` | Create role | `$name: String!`
`$costPerHour: Float!`
`$vendorId: UUID!`
`$roleCategoryId: UUID!` | `role_insert` | +| `updateRole` | Update role | `$id: UUID!`
`$name: String`
`$costPerHour: Float`
`$roleCategoryId: UUID!` | `role_update` | +| `deleteRole` | Delete role | `$id: UUID!` | `role_delete` | + +## roleCategory + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listRoleCategories` | List role categories | — | `roleCategories` | +| `getRoleCategoryById` | Get role category by id | `$id: UUID!` | `roleCategory` | +| `getRoleCategoriesByCategory` | Get role categories by category | `$category: RoleCategoryType!` | `roleCategories` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createRoleCategory` | Create role category | `$roleName: String!`
`$category: RoleCategoryType!` | `roleCategory_insert` | +| `updateRoleCategory` | Update role category | `$id: UUID!`
`$roleName: String`
`$category: RoleCategoryType` | `roleCategory_update` | +| `deleteRoleCategory` | Delete role category | `$id: UUID!` | `roleCategory_delete` | + +## shift + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listShifts` | List shifts | `$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftById` | Get shift by id | `$id: UUID!` | `shift` | +| `filterShifts` | Filter shifts | `$status: ShiftStatus`
`$orderId: UUID`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByBusinessId` | Get shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | +| `getShiftsByVendorId` | Get shifts by vendor id | `$vendorId: UUID!`
`$dateFrom: Timestamp`
`$dateTo: Timestamp`
`$offset: Int`
`$limit: Int` | `shifts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createShift` | Create shift | `$title: String!`
`$orderId: UUID!`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int`
`$createdBy: String` | `shift_insert` | +| `updateShift` | Update shift | `$id: UUID!`
`$title: String`
`$orderId: UUID`
`$date: Timestamp`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$cost: Float`
`$location: String`
`$locationAddress: String`
`$latitude: Float`
`$longitude: Float`
`$placeId: String`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$description: String`
`$status: ShiftStatus`
`$workersNeeded: Int`
`$filled: Int`
`$filledAt: Timestamp`
`$managers: [Any!]`
`$durationDays: Int` | `shift_update` | +| `deleteShift` | Delete shift | `$id: UUID!` | `shift_delete` | + +## shiftRole + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getShiftRoleById` | Get shift role by id | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole` | +| `listShiftRolesByShiftId` | List shift roles by shift id | `$shiftId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByRoleId` | List shift roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByShiftIdAndTimeRange` | List shift roles by shift id and time range | `$shiftId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByVendorId` | List shift roles by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDateRange` | List shift roles by business and date range | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int`
`$status: ShiftStatus` | `shiftRoles` | +| `listShiftRolesByBusinessAndOrder` | List shift roles by business and order | `$businessId: UUID!`
`$orderId: UUID!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessDateRangeCompletedOrders` | List shift roles by business date range completed orders | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `listShiftRolesByBusinessAndDatesSummary` | List shift roles by business and dates summary | `$businessId: UUID!`
`$start: Timestamp!`
`$end: Timestamp!`
`$offset: Int`
`$limit: Int` | `shiftRoles` | +| `getCompletedShiftsByBusinessId` | Get completed shifts by business id | `$businessId: UUID!`
`$dateFrom: Timestamp!`
`$dateTo: Timestamp!`
`$offset: Int`
`$limit: Int` | `shifts` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createShiftRole` | Create shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int!`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_insert` | +| `updateShiftRole` | Update shift role | `$shiftId: UUID!`
`$roleId: UUID!`
`$count: Int`
`$assigned: Int`
`$startTime: Timestamp`
`$endTime: Timestamp`
`$hours: Float`
`$department: String`
`$uniform: String`
`$breakType: BreakDuration`
`$isBreakPaid: Boolean`
`$totalValue: Float` | `shiftRole_update` | +| `deleteShiftRole` | Delete shift role | `$shiftId: UUID!`
`$roleId: UUID!` | `shiftRole_delete` | + +## staff + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaff` | List staff | — | `staffs` | +| `getStaffById` | Get staff by id | `$id: UUID!` | `staff` | +| `getStaffByUserId` | Get staff by user id | `$userId: String!` | `staffs` | +| `filterStaff` | Filter staff | `$ownerId: UUID`
`$fullName: String`
`$level: String`
`$email: String` | `staffs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateStaff` | Create staff | `$userId: String!`
`$fullName: String!`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_insert` | +| `UpdateStaff` | Update staff | `$id: UUID!`
`$userId: String`
`$fullName: String`
`$level: String`
`$role: String`
`$phone: String`
`$email: String`
`$photoUrl: String`
`$totalShifts: Int`
`$averageRating: Float`
`$onTimeRate: Int`
`$noShowCount: Int`
`$cancellationCount: Int`
`$reliabilityScore: Int`
`$bio: String`
`$skills: [String!]`
`$industries: [String!]`
`$preferredLocations: [String!]`
`$maxDistanceMiles: Int`
`$languages: Any`
`$itemsAttire: Any`
`$xp: Int`
`$badges: Any`
`$isRecommended: Boolean`
`$ownerId: UUID`
`$department: DepartmentType`
`$hubId: UUID`
`$manager: UUID`
`$english: EnglishProficiency`
`$backgroundCheckStatus: BackgroundCheckStatus`
`$employmentType: EmploymentType`
`$initial: String`
`$englishRequired: Boolean`
`$city: String`
`$addres: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String` | `staff_update` | +| `DeleteStaff` | Delete staff | `$id: UUID!` | `staff_delete` | + +## staffAvailability + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffAvailabilities` | List staff availabilities | `$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `listStaffAvailabilitiesByStaffId` | List staff availabilities by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | +| `getStaffAvailabilityByKey` | Get staff availability by key | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability` | +| `listStaffAvailabilitiesByDay` | List staff availabilities by day | `$day: DayOfWeek!`
`$offset: Int`
`$limit: Int` | `staffAvailabilities` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffAvailability` | Create staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_insert` | +| `updateStaffAvailability` | Update staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!`
`$status: AvailabilityStatus`
`$notes: String` | `staffAvailability_update` | +| `deleteStaffAvailability` | Delete staff availability | `$staffId: UUID!`
`$day: DayOfWeek!`
`$slot: AvailabilitySlot!` | `staffAvailability_delete` | + +## staffAvailabilityStats + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffAvailabilityStats` | List staff availability stats | `$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | +| `getStaffAvailabilityStatsByStaffId` | Get staff availability stats by staff id | `$staffId: UUID!` | `staffAvailabilityStats` | +| `filterStaffAvailabilityStats` | Filter staff availability stats | `$needWorkIndexMin: Int`
`$needWorkIndexMax: Int`
`$utilizationMin: Int`
`$utilizationMax: Int`
`$acceptanceRateMin: Int`
`$acceptanceRateMax: Int`
`$lastShiftAfter: Timestamp`
`$lastShiftBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `staffAvailabilityStatss` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffAvailabilityStats` | Create staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_insert` | +| `updateStaffAvailabilityStats` | Update staff availability stats | `$staffId: UUID!`
`$needWorkIndex: Int`
`$utilizationPercentage: Int`
`$predictedAvailabilityScore: Int`
`$scheduledHoursThisPeriod: Int`
`$desiredHoursThisPeriod: Int`
`$lastShiftDate: Timestamp`
`$acceptanceRate: Int` | `staffAvailabilityStats_update` | +| `deleteStaffAvailabilityStats` | Delete staff availability stats | `$staffId: UUID!` | `staffAvailabilityStats_delete` | + +## staffCourse + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getStaffCourseById` | Get staff course by id | `$id: UUID!` | `staffCourse` | +| `listStaffCoursesByStaffId` | List staff courses by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `listStaffCoursesByCourseId` | List staff courses by course id | `$courseId: UUID!`
`$offset: Int`
`$limit: Int` | `staffCourses` | +| `getStaffCourseByStaffAndCourse` | Get staff course by staff and course | `$staffId: UUID!`
`$courseId: UUID!` | `staffCourses` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffCourse` | Create staff course | `$staffId: UUID!`
`$courseId: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_insert` | +| `updateStaffCourse` | Update staff course | `$id: UUID!`
`$progressPercent: Int`
`$completed: Boolean`
`$completedAt: Timestamp`
`$startedAt: Timestamp`
`$lastAccessedAt: Timestamp` | `staffCourse_update` | +| `deleteStaffCourse` | Delete staff course | `$id: UUID!` | `staffCourse_delete` | + +## staffDocument + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getStaffDocumentByKey` | Get staff document by key | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument` | +| `listStaffDocumentsByStaffId` | List staff documents by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByDocumentType` | List staff documents by document type | `$documentType: DocumentType!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | +| `listStaffDocumentsByStatus` | List staff documents by status | `$status: DocumentStatus!`
`$offset: Int`
`$limit: Int` | `staffDocuments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffDocument` | Create staff document | `$staffId: UUID!`
`$staffName: String!`
`$documentId: UUID!`
`$status: DocumentStatus!`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_insert` | +| `updateStaffDocument` | Update staff document | `$staffId: UUID!`
`$documentId: UUID!`
`$status: DocumentStatus`
`$documentUrl: String`
`$expiryDate: Timestamp` | `staffDocument_update` | +| `deleteStaffDocument` | Delete staff document | `$staffId: UUID!`
`$documentId: UUID!` | `staffDocument_delete` | + +## staffRole + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listStaffRoles` | List staff roles | `$offset: Int`
`$limit: Int` | `staffRoles` | +| `getStaffRoleByKey` | Get staff role by key | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole` | +| `listStaffRolesByStaffId` | List staff roles by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `listStaffRolesByRoleId` | List staff roles by role id | `$roleId: UUID!`
`$offset: Int`
`$limit: Int` | `staffRoles` | +| `filterStaffRoles` | Filter staff roles | `$staffId: UUID`
`$roleId: UUID`
`$offset: Int`
`$limit: Int` | `staffRoles` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createStaffRole` | Create staff role | `$staffId: UUID!`
`$roleId: UUID!`
`$roleType: RoleType` | `staffRole_insert` | +| `deleteStaffRole` | Delete staff role | `$staffId: UUID!`
`$roleId: UUID!` | `staffRole_delete` | + +## task + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTasks` | List tasks | — | `tasks` | +| `getTaskById` | Get task by id | `$id: UUID!` | `task` | +| `getTasksByOwnerId` | Get tasks by owner id | `$ownerId: UUID!` | `tasks` | +| `filterTasks` | Filter tasks | `$status: TaskStatus`
`$priority: TaskPriority` | `tasks` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTask` | Create task | `$taskName: String!`
`$description: String`
`$priority: TaskPriority!`
`$status: TaskStatus!`
`$dueDate: Timestamp`
`$progress: Int`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any`
`$ownerId:UUID!` | `task_insert` | +| `updateTask` | Update task | `$id: UUID!`
`$taskName: String`
`$description: String`
`$priority: TaskPriority`
`$status: TaskStatus`
`$dueDate: Timestamp`
`$progress: Int`
`$assignedMembers: Any`
`$orderIndex: Int`
`$commentCount: Int`
`$attachmentCount: Int`
`$files: Any` | `task_update` | +| `deleteTask` | Delete task | `$id: UUID!` | `task_delete` | + +## task_comment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTaskComments` | List task comments | — | `taskComments` | +| `getTaskCommentById` | Get task comment by id | `$id: UUID!` | `taskComment` | +| `getTaskCommentsByTaskId` | Get task comments by task id | `$taskId: UUID!` | `taskComments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTaskComment` | Create task comment | `$taskId: UUID!`
`$teamMemberId: UUID!`
`$comment: String!`
`$isSystem: Boolean` | `taskComment_insert` | +| `updateTaskComment` | Update task comment | `$id: UUID!`
`$comment: String`
`$isSystem: Boolean` | `taskComment_update` | +| `deleteTaskComment` | Delete task comment | `$id: UUID!` | `taskComment_delete` | + +## taxForm + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTaxForms` | List tax forms | `$offset: Int`
`$limit: Int` | `taxForms` | +| `getTaxFormById` | Get tax form by id | `$id: UUID!` | `taxForm` | +| `getTaxFormsByStaffId` | Get tax forms by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `taxForms` | +| `listTaxFormsWhere` | List tax forms where | `$formType: TaxFormType`
`$status: TaxFormStatus`
`$staffId: UUID`
`$offset: Int`
`$limit: Int` | `taxForms` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTaxForm` | Create tax form | `$formType: TaxFormType!`
`$firstName: String!`
`$lastName: String!`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int!`
`$email: String`
`$phone: String`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus!`
`$staffId: UUID!`
`$createdBy: String` | `taxForm_insert` | +| `updateTaxForm` | Update tax form | `$id: UUID!`
`$formType: TaxFormType`
`$firstName: String`
`$lastName: String`
`$mInitial: String`
`$oLastName: String`
`$dob: Timestamp`
`$socialSN: Int`
`$email: String`
`$phone: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$apt: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$marital: MaritalStatus`
`$multipleJob: Boolean`
`$childrens: Int`
`$otherDeps: Int`
`$totalCredits: Float`
`$otherInconme: Float`
`$deductions: Float`
`$extraWithholding: Float`
`$citizen: CitizenshipStatus`
`$uscis: String`
`$passportNumber: String`
`$countryIssue: String`
`$prepartorOrTranslator: Boolean`
`$signature: String`
`$date: Timestamp`
`$status: TaxFormStatus` | `taxForm_update` | +| `deleteTaxForm` | Delete tax form | `$id: UUID!` | `taxForm_delete` | + +## team + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeams` | List teams | — | `teams` | +| `getTeamById` | Get team by id | `$id: UUID!` | `team` | +| `getTeamsByOwnerId` | Get teams by owner id | `$ownerId: UUID!` | `teams` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeam` | Create team | `$teamName: String!`
`$ownerId: UUID!`
`$ownerName: String!`
`$ownerRole: String!`
`$email: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_insert` | +| `updateTeam` | Update team | `$id: UUID!`
`$teamName: String`
`$ownerName: String`
`$ownerRole: String`
`$companyLogo: String`
`$totalMembers: Int`
`$activeMembers: Int`
`$totalHubs: Int`
`$departments: Any`
`$favoriteStaffCount: Int`
`$blockedStaffCount: Int`
`$favoriteStaff: Any`
`$blockedStaff: Any` | `team_update` | +| `deleteTeam` | Delete team | `$id: UUID!` | `team_delete` | + +## teamHub + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamHubs` | List team hubs | `$offset: Int`
`$limit: Int` | `teamHubs` | +| `getTeamHubById` | Get team hub by id | `$id: UUID!` | `teamHub` | +| `getTeamHubsByTeamId` | Get team hubs by team id | `$teamId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | +| `listTeamHubsByOwnerId` | List team hubs by owner id | `$ownerId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHubs` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamHub` | Create team hub | `$teamId: UUID!`
`$hubName: String!`
`$address: String!`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_insert` | +| `updateTeamHub` | Update team hub | `$id: UUID!`
`$teamId: UUID`
`$hubName: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$city: String`
`$state: String`
`$street: String`
`$country: String`
`$zipCode: String`
`$managerName: String`
`$isActive: Boolean`
`$departments: Any` | `teamHub_update` | +| `deleteTeamHub` | Delete team hub | `$id: UUID!` | `teamHub_delete` | + +## teamHudDeparment + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamHudDepartments` | List team hud departments | `$offset: Int`
`$limit: Int` | `teamHudDepartments` | +| `getTeamHudDepartmentById` | Get team hud department by id | `$id: UUID!` | `teamHudDepartment` | +| `listTeamHudDepartmentsByTeamHubId` | List team hud departments by team hub id | `$teamHubId: UUID!`
`$offset: Int`
`$limit: Int` | `teamHudDepartments` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamHudDepartment` | Create team hud department | `$name: String!`
`$costCenter: String`
`$teamHubId: UUID!` | `teamHudDepartment_insert` | +| `updateTeamHudDepartment` | Update team hud department | `$id: UUID!`
`$name: String`
`$costCenter: String`
`$teamHubId: UUID` | `teamHudDepartment_update` | +| `deleteTeamHudDepartment` | Delete team hud department | `$id: UUID!` | `teamHudDepartment_delete` | + +## teamMember + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listTeamMembers` | List team members | — | `teamMembers` | +| `getTeamMemberById` | Get team member by id | `$id: UUID!` | `teamMember` | +| `getTeamMembersByTeamId` | Get team members by team id | `$teamId: UUID!` | `teamMembers` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createTeamMember` | Create team member | `$teamId: UUID!`
`$role: TeamMemberRole!`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$userId: String!`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_insert` | +| `updateTeamMember` | Update team member | `$id: UUID!`
`$role: TeamMemberRole`
`$title: String`
`$department: String`
`$teamHubId: UUID`
`$isActive: Boolean`
`$inviteStatus: TeamMemberInviteStatus` | `teamMember_update` | +| `updateTeamMemberInviteStatus` | Update team member invite status | `$id: UUID!`
`$inviteStatus: TeamMemberInviteStatus!` | `teamMember_update` | +| `acceptInviteByCode` | Accept invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `cancelInviteByCode` | Cancel invite by code | `$inviteCode: UUID!` | `teamMember_updateMany` | +| `deleteTeamMember` | Delete team member | `$id: UUID!` | `teamMember_delete` | + +## user + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listUsers` | List users | — | `users` | +| `getUserById` | Get user by id | `$id: String!` | `user` | +| `filterUsers` | Filter users | `$id: String`
`$email: String`
`$role: UserBaseRole`
`$userRole: String` | `users` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `CreateUser` | Create user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole!`
`$userRole: String`
`$photoUrl: String` | `user_insert` | +| `UpdateUser` | Update user | `$id: String!`
`$email: String`
`$fullName: String`
`$role: UserBaseRole`
`$userRole: String`
`$photoUrl: String` | `user_update` | +| `DeleteUser` | Delete user | `$id: String!` | `user_delete` | + +## userConversation + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listUserConversations` | List user conversations | `$offset: Int`
`$limit: Int` | `userConversations` | +| `getUserConversationByKey` | Get user conversation by key | `$conversationId: UUID!`
`$userId: String!` | `userConversation` | +| `listUserConversationsByUserId` | List user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUnreadUserConversationsByUserId` | List unread user conversations by user id | `$userId: String!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `listUserConversationsByConversationId` | List user conversations by conversation id | `$conversationId: UUID!`
`$offset: Int`
`$limit: Int` | `userConversations` | +| `filterUserConversations` | Filter user conversations | `$userId: String`
`$conversationId: UUID`
`$unreadMin: Int`
`$unreadMax: Int`
`$lastReadAfter: Timestamp`
`$lastReadBefore: Timestamp`
`$offset: Int`
`$limit: Int` | `userConversations` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createUserConversation` | Create user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_insert` | +| `updateUserConversation` | Update user conversation | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `markConversationAsRead` | Mark conversation as read | `$conversationId: UUID!`
`$userId: String!`
`$lastReadAt: Timestamp` | `userConversation_update` | +| `incrementUnreadForUser` | Increment unread for user | `$conversationId: UUID!`
`$userId: String!`
`$unreadCount: Int!` | `userConversation_update` | +| `deleteUserConversation` | Delete user conversation | `$conversationId: UUID!`
`$userId: String!` | `userConversation_delete` | + +## vendor + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getVendorById` | Get vendor by id | `$id: UUID!` | `vendor` | +| `getVendorByUserId` | Get vendor by user id | `$userId: String!` | `vendors` | +| `listVendors` | List vendors | — | `vendors` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendor` | Create vendor | `$userId: String!`
`$companyName: String!`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_insert` | +| `updateVendor` | Update vendor | `$id: UUID!`
`$companyName: String`
`$email: String`
`$phone: String`
`$photoUrl: String`
`$address: String`
`$placeId: String`
`$latitude: Float`
`$longitude: Float`
`$street: String`
`$country: String`
`$zipCode: String`
`$billingAddress: String`
`$timezone: String`
`$legalName: String`
`$doingBusinessAs: String`
`$region: String`
`$state: String`
`$city: String`
`$serviceSpecialty: String`
`$approvalStatus: ApprovalStatus`
`$isActive: Boolean`
`$markup: Float`
`$fee: Float`
`$csat: Float`
`$tier: VendorTier` | `vendor_update` | +| `deleteVendor` | Delete vendor | `$id: UUID!` | `vendor_delete` | + +## vendorBenefitPlan + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listVendorBenefitPlans` | List vendor benefit plans | `$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `getVendorBenefitPlanById` | Get vendor benefit plan by id | `$id: UUID!` | `vendorBenefitPlan` | +| `listVendorBenefitPlansByVendorId` | List vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `listActiveVendorBenefitPlansByVendorId` | List active vendor benefit plans by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | +| `filterVendorBenefitPlans` | Filter vendor benefit plans | `$vendorId: UUID`
`$title: String`
`$isActive: Boolean`
`$offset: Int`
`$limit: Int` | `vendorBenefitPlans` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendorBenefitPlan` | Create vendor benefit plan | `$vendorId: UUID!`
`$title: String!`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_insert` | +| `updateVendorBenefitPlan` | Update vendor benefit plan | `$id: UUID!`
`$vendorId: UUID`
`$title: String`
`$description: String`
`$requestLabel: String`
`$total: Int`
`$isActive: Boolean`
`$createdBy: String` | `vendorBenefitPlan_update` | +| `deleteVendorBenefitPlan` | Delete vendor benefit plan | `$id: UUID!` | `vendorBenefitPlan_delete` | + +## vendorRate + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `listVendorRates` | List vendor rates | — | `vendorRates` | +| `getVendorRateById` | Get vendor rate by id | `$id: UUID!` | `vendorRate` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createVendorRate` | Create vendor rate | `$vendorId: UUID!`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_insert` | +| `updateVendorRate` | Update vendor rate | `$id: UUID!`
`$vendorId: UUID`
`$roleName: String`
`$category: CategoryType`
`$clientRate: Float`
`$employeeWage: Float`
`$markupPercentage: Float`
`$vendorFeePercentage: Float`
`$isActive: Boolean`
`$notes: String` | `vendorRate_update` | +| `deleteVendorRate` | Delete vendor rate | `$id: UUID!` | `vendorRate_delete` | + +## workForce + +### Queries +| Name | Purpose | Parameters | Returns | +|------|---------|------------|---------| +| `getWorkforceById` | Get workforce by id | `$id: UUID!` | `workforce` | +| `getWorkforceByVendorAndStaff` | Get workforce by vendor and staff | `$vendorId: UUID!`
`$staffId: UUID!` | `workforces` | +| `listWorkforceByVendorId` | List workforce by vendor id | `$vendorId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `listWorkforceByStaffId` | List workforce by staff id | `$staffId: UUID!`
`$offset: Int`
`$limit: Int` | `workforces` | +| `getWorkforceByVendorAndNumber` | Get workforce by vendor and number | `$vendorId: UUID!`
`$workforceNumber: String!` | `workforces` | + +### Mutations +| Name | Purpose | Parameters | Affects | +|------|---------|------------|---------| +| `createWorkforce` | Create workforce | `$vendorId: UUID!`
`$staffId: UUID!`
`$workforceNumber: String!`
`$employmentType: WorkforceEmploymentType` | `workforce_insert` | +| `updateWorkforce` | Update workforce | `$id: UUID!`
`$workforceNumber: String`
`$employmentType: WorkforceEmploymentType`
`$status: WorkforceStatus` | `workforce_update` | +| `deactivateWorkforce` | Deactivate workforce | `$id: UUID!` | `workforce_update` | diff --git a/internal/launchpad/assets/documents/documents-config.json b/internal/launchpad/assets/documents/documents-config.json index 16d16ebf..6219f696 100644 --- a/internal/launchpad/assets/documents/documents-config.json +++ b/internal/launchpad/assets/documents/documents-config.json @@ -65,10 +65,10 @@ }, { "title": "Dataconnect guide", - "path": "docs/DATACONNECT_GUIDES/DOCUMENTS/backend_manual.md" + "path": "./assets/documents/data connect/backend_manual.md" }, { "title": "Schema Dataconnect guide", - "path": "docs/DATACONNECT_GUIDES/DOCUMENTS/schema_dataconnect_guide.md" + "path": "./assets/documents/data connect/schema_dataconnect_guide.md" } ] diff --git a/internal/launchpad/prototypes/mobile/client/.keep b/internal/launchpad/prototypes/mobile/client/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/internal/launchpad/prototypes/mobile/staff/.keep b/internal/launchpad/prototypes/mobile/staff/.keep deleted file mode 100644 index e69de29b..00000000 diff --git a/scripts/create_issues.py b/scripts/create_issues.py new file mode 100644 index 00000000..bbe0b071 --- /dev/null +++ b/scripts/create_issues.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +import subprocess +import os +import re +import argparse + +# --- Configuration --- +INPUT_FILE = "issues-to-create.md" +DEFAULT_PROJECT_TITLE = None +DEFAULT_MILESTONE = "Milestone 4" +# --- + +def parse_issues(content): + """Parse issue blocks from markdown content. + + Each issue block starts with a '# Title' line, followed by an optional + 'Labels:' metadata line, then the body. Milestone is set globally, not per-issue. + """ + issue_blocks = re.split(r'\n(?=#\s)', content) + issues = [] + + for block in issue_blocks: + if not block.strip(): + continue + + lines = block.strip().split('\n') + + # Title: strip leading '#' characters and whitespace + title = re.sub(r'^#+\s*', '', lines[0]).strip() + + labels_line = "" + body_start_index = len(lines) # default: no body + + # Only 'Labels:' is parsed from the markdown; milestone is global + for i, line in enumerate(lines[1:], start=1): + stripped = line.strip() + if stripped.lower().startswith('labels:'): + labels_line = stripped.split(':', 1)[1].strip() + elif stripped == "": + continue # skip blank separator lines in the header + else: + body_start_index = i + break + + body = "\n".join(lines[body_start_index:]).strip() + labels = [label.strip() for label in labels_line.split(',') if label.strip()] + + if not title: + print("⚠️ Skipping block with no title.") + continue + + issues.append({ + "title": title, + "body": body, + "labels": labels, + }) + + return issues + + +def main(): + parser = argparse.ArgumentParser( + description="Bulk create GitHub issues from a markdown file.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Input file format (issues-to-create.md): +----------------------------------------- +# Issue Title One +Labels: bug, enhancement + +This is the body of the first issue. +It can span multiple lines. + +# Issue Title Two +Labels: documentation + +Body of the second issue. +----------------------------------------- +All issues share the same project and milestone, configured at the top of this script +or passed via --project and --milestone flags. + """ + ) + parser.add_argument( + "--file", "-f", + default=INPUT_FILE, + help=f"Path to the markdown input file (default: {INPUT_FILE})" + ) + parser.add_argument( + "--project", "-p", + default=DEFAULT_PROJECT_TITLE, + help=f"GitHub Project title for all issues (default: {DEFAULT_PROJECT_TITLE})" + ) + parser.add_argument( + "--milestone", "-m", + default=DEFAULT_MILESTONE, + help=f"Milestone to assign to all issues (default: {DEFAULT_MILESTONE})" + ) + parser.add_argument( + "--no-project", + action="store_true", + help="Do not add issues to any project." + ) + parser.add_argument( + "--no-milestone", + action="store_true", + help="Do not assign a milestone to any issue." + ) + parser.add_argument( + "--repo", "-r", + default=None, + help="Target GitHub repo in OWNER/REPO format (uses gh default if not set)." + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Parse the file and print issues without creating them." + ) + args = parser.parse_args() + + input_file = args.file + project_title = args.project if not args.no_project else None + milestone = args.milestone if not args.no_milestone else None + + print("🚀 Bulk GitHub Issue Creator") + print("=" * 40) + print(f" Input file: {input_file}") + print(f" Project: {project_title or '(none)'}") + print(f" Milestone: {milestone or '(none)'}") + if args.repo: + print(f" Repo: {args.repo}") + if args.dry_run: + print(" Mode: DRY RUN (no issues will be created)") + print("=" * 40) + + # --- Preflight checks --- + if subprocess.run(["which", "gh"], capture_output=True).returncode != 0: + print("❌ ERROR: GitHub CLI ('gh') is not installed or not in PATH.") + print(" Install it from: https://cli.github.com/") + exit(1) + + if not os.path.exists(input_file): + print(f"❌ ERROR: Input file '{input_file}' not found.") + exit(1) + + print("✅ Preflight checks passed.\n") + + # --- Parse --- + print(f"📄 Parsing '{input_file}'...") + with open(input_file, 'r') as f: + content = f.read() + + issues = parse_issues(content) + + if not issues: + print("⚠️ No issues found in the input file. Check the format.") + exit(0) + + print(f" Found {len(issues)} issue(s) to create.\n") + + # --- Create --- + success_count = 0 + fail_count = 0 + + for idx, issue in enumerate(issues, start=1): + print(f"[{idx}/{len(issues)}] {issue['title']}") + if issue['labels']: + print(f" Labels: {', '.join(issue['labels'])}") + print(f" Milestone: {milestone or '(none)'}") + print(f" Project: {project_title or '(none)'}") + + if args.dry_run: + print(" (dry-run — skipping creation)\n") + continue + + command = ["gh", "issue", "create"] + if args.repo: + command.extend(["--repo", args.repo]) + command.extend(["--title", issue["title"]]) + command.extend(["--body", issue["body"] or " "]) # gh requires non-empty body + + if project_title: + command.extend(["--project", project_title]) + if milestone: + command.extend(["--milestone", milestone]) + for label in issue["labels"]: + command.extend(["--label", label]) + + try: + result = subprocess.run(command, check=True, text=True, capture_output=True) + print(f" ✅ Created: {result.stdout.strip()}") + success_count += 1 + except subprocess.CalledProcessError as e: + print(f" ❌ Failed: {e.stderr.strip()}") + fail_count += 1 + + print() + + # --- Summary --- + print("=" * 40) + if args.dry_run: + print(f"🔍 Dry run complete. {len(issues)} issue(s) parsed, none created.") + else: + print(f"🎉 Done! {success_count} created, {fail_count} failed.") + + +if __name__ == "__main__": + main() diff --git a/scripts/issues-to-create.md b/scripts/issues-to-create.md new file mode 100644 index 00000000..8172f5bf --- /dev/null +++ b/scripts/issues-to-create.md @@ -0,0 +1,27 @@ +# +Labels: + + + +## Scope + +### +- + +## +- [ ] + +------- + +# +Labels: + + + +## Scope + +### +- + +## +- [ ]