diff --git a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart index 6955b964..d6fb3634 100644 --- a/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart +++ b/apps/mobile/packages/core/lib/src/services/api_service/endpoints/staff_endpoints.dart @@ -14,6 +14,10 @@ abstract final class StaffEndpoints { static const ApiEndpoint profileCompletion = ApiEndpoint('/staff/profile-completion'); + /// Staff reliability and performance statistics. + static const ApiEndpoint profileStats = + ApiEndpoint('/staff/profile/stats'); + /// Staff availability schedule. static const ApiEndpoint availability = ApiEndpoint('/staff/availability'); diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 62f8dd73..c772ba45 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -99,6 +99,7 @@ export 'src/entities/profile/accessibility.dart'; // Ratings export 'src/entities/ratings/staff_rating.dart'; +export 'src/entities/ratings/staff_reliability_stats.dart'; // Home export 'src/entities/home/client_dashboard.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart index 88ee6ebc..a16e8a41 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/assigned_worker.dart @@ -13,6 +13,7 @@ class AssignedWorker extends Equatable { required this.fullName, required this.status, this.checkInAt, + this.hasReview = false, }); /// Deserialises an [AssignedWorker] from a V2 API JSON map. @@ -25,6 +26,7 @@ class AssignedWorker extends Equatable { checkInAt: json['checkInAt'] != null ? DateTime.parse(json['checkInAt'] as String) : null, + hasReview: json['hasReview'] as bool? ?? false, ); } @@ -43,6 +45,9 @@ class AssignedWorker extends Equatable { /// When the worker clocked in (null if not yet). final DateTime? checkInAt; + /// Whether this worker has already been reviewed for this assignment. + final bool hasReview; + /// Serialises this [AssignedWorker] to a JSON map. Map toJson() { return { @@ -51,6 +56,7 @@ class AssignedWorker extends Equatable { 'fullName': fullName, 'status': status.toJson(), 'checkInAt': checkInAt?.toIso8601String(), + 'hasReview': hasReview, }; } @@ -61,5 +67,6 @@ class AssignedWorker extends Equatable { fullName, status, checkInAt, + hasReview, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart index 476334f8..9a91f6b6 100644 --- a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/shift_with_workers.dart @@ -15,6 +15,8 @@ class ShiftWithWorkers extends Equatable { required this.requiredWorkerCount, required this.assignedWorkerCount, this.assignedWorkers = const [], + this.locationName = '', + this.locationAddress = '', }); /// Deserialises a [ShiftWithWorkers] from a V2 API JSON map. @@ -30,6 +32,8 @@ class ShiftWithWorkers extends Equatable { return ShiftWithWorkers( shiftId: json['shiftId'] as String, roleName: json['roleName'] as String? ?? '', + locationName: json['locationName'] as String? ?? '', + locationAddress: json['locationAddress'] as String? ?? '', timeRange: TimeRange.fromJson(json['timeRange'] as Map), requiredWorkerCount: (json['requiredWorkerCount'] as num).toInt(), assignedWorkerCount: (json['assignedWorkerCount'] as num).toInt(), @@ -55,11 +59,19 @@ class ShiftWithWorkers extends Equatable { /// List of assigned workers with their statuses. final List assignedWorkers; + /// Location or hub name for this shift. + final String locationName; + + /// Street address for this shift. + final String locationAddress; + /// Serialises this [ShiftWithWorkers] to a JSON map. Map toJson() { return { 'shiftId': shiftId, 'roleName': roleName, + 'locationName': locationName, + 'locationAddress': locationAddress, 'timeRange': timeRange.toJson(), 'requiredWorkerCount': requiredWorkerCount, 'assignedWorkerCount': assignedWorkerCount, @@ -76,5 +88,7 @@ class ShiftWithWorkers extends Equatable { requiredWorkerCount, assignedWorkerCount, assignedWorkers, + locationName, + locationAddress, ]; } diff --git a/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart new file mode 100644 index 00000000..e0e22601 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/ratings/staff_reliability_stats.dart @@ -0,0 +1,84 @@ +import 'package:equatable/equatable.dart'; + +/// Aggregated reliability and performance statistics for a staff member. +/// +/// Returned by `GET /staff/profile/stats`. +class StaffReliabilityStats extends Equatable { + /// Creates a [StaffReliabilityStats] instance. + const StaffReliabilityStats({ + required this.staffId, + this.totalShifts = 0, + this.averageRating = 0, + this.ratingCount = 0, + this.onTimeRate = 0, + this.noShowCount = 0, + this.cancellationCount = 0, + this.reliabilityScore = 0, + }); + + /// Deserialises from a V2 API JSON map. + factory StaffReliabilityStats.fromJson(Map json) { + return StaffReliabilityStats( + staffId: json['staffId'] as String, + totalShifts: (json['totalShifts'] as num?)?.toInt() ?? 0, + averageRating: (json['averageRating'] as num?)?.toDouble() ?? 0, + ratingCount: (json['ratingCount'] as num?)?.toInt() ?? 0, + onTimeRate: (json['onTimeRate'] as num?)?.toDouble() ?? 0, + noShowCount: (json['noShowCount'] as num?)?.toInt() ?? 0, + cancellationCount: (json['cancellationCount'] as num?)?.toInt() ?? 0, + reliabilityScore: (json['reliabilityScore'] as num?)?.toDouble() ?? 0, + ); + } + + /// The staff member's unique identifier. + final String staffId; + + /// Total completed shifts. + final int totalShifts; + + /// Average rating from client reviews (0-5). + final double averageRating; + + /// Number of ratings received. + final int ratingCount; + + /// Percentage of shifts clocked in on time (0-100). + final double onTimeRate; + + /// Number of no-show incidents. + final int noShowCount; + + /// Number of worker-initiated cancellations. + final int cancellationCount; + + /// Composite reliability score (0-100). + /// + /// Weighted: 45% on-time rate + 35% completion rate + 20% rating score. + final double reliabilityScore; + + /// Serialises to a JSON map. + Map toJson() { + return { + 'staffId': staffId, + 'totalShifts': totalShifts, + 'averageRating': averageRating, + 'ratingCount': ratingCount, + 'onTimeRate': onTimeRate, + 'noShowCount': noShowCount, + 'cancellationCount': cancellationCount, + 'reliabilityScore': reliabilityScore, + }; + } + + @override + List get props => [ + staffId, + totalShifts, + averageRating, + ratingCount, + onTimeRate, + noShowCount, + cancellationCount, + reliabilityScore, + ]; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 8e284dc1..1c305986 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -161,6 +161,7 @@ class _CoverageShiftListState extends State { children: [ ShiftHeader( title: shift.roleName, + locationName: shift.locationName, startTime: _formatTime(shift.timeRange.startsAt), current: shift.assignedWorkerCount, total: shift.requiredWorkerCount, @@ -226,9 +227,10 @@ class _CoverageShiftListState extends State { worker: worker, shiftStartTime: _formatTime(shift.timeRange.startsAt), showRateButton: - worker.status == AssignmentStatus.checkedIn || - worker.status == AssignmentStatus.checkedOut || - worker.status == AssignmentStatus.completed, + !worker.hasReview && + (worker.status == AssignmentStatus.checkedIn || + worker.status == AssignmentStatus.checkedOut || + worker.status == AssignmentStatus.completed), showCancelButton: DateTime.now().isAfter(shift.timeRange.startsAt) && (worker.status == AssignmentStatus.noShow || diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart index b0a81658..3027449b 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/shift_header.dart @@ -21,6 +21,7 @@ class ShiftHeader extends StatelessWidget { required this.lateCount, required this.isExpanded, required this.onToggle, + this.locationName, super.key, }); @@ -57,6 +58,9 @@ class ShiftHeader extends StatelessWidget { /// Callback invoked when the header is tapped to expand or collapse. final VoidCallback onToggle; + /// Optional location or hub name for the shift. + final String? locationName; + /// Returns the status colour based on [coveragePercent]. /// /// Green for >= 100 %, yellow for >= 80 %, red otherwise. @@ -110,6 +114,29 @@ class ShiftHeader extends StatelessWidget { title, style: UiTypography.body1b.textPrimary, ), + if (locationName != null && + locationName!.isNotEmpty) ...[ + const SizedBox(height: 2), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + locationName!, + style: UiTypography.body3r.copyWith( + color: UiColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], const SizedBox(height: UiConstants.space1), Row( children: [ diff --git a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart index 9c107915..f911f200 100644 --- a/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart +++ b/apps/mobile/packages/features/staff/clock_in/lib/src/presentation/bloc/clock_in/clock_in_bloc.dart @@ -228,12 +228,20 @@ class ClockInBloc extends Bloc event: event, activeShiftId: newStatus.activeShiftId, ); - } on AppException catch (_) { - // The clock-in API call failed. Re-fetch attendance status to - // reconcile: if the worker is already clocked in (e.g. duplicate - // session from Postgres constraint 23505), treat it as success. + } on AppException catch (e) { + // The backend returns 409 ALREADY_CLOCKED_IN when the worker has + // an active attendance session. This is a normal idempotency + // signal — re-fetch the authoritative status and emit success + // without surfacing an error snackbar. + final bool isAlreadyClockedIn = + e is ApiException && e.apiCode == 'ALREADY_CLOCKED_IN'; + + // Re-fetch attendance status to reconcile local state with + // the backend (handles both ALREADY_CLOCKED_IN and legacy + // Postgres constraint 23505 duplicates). final AttendanceStatus currentStatus = await _getAttendanceStatus(); - if (currentStatus.isClockedIn) { + + if (isAlreadyClockedIn || currentStatus.isClockedIn) { emit(state.copyWith( status: ClockInStatus.success, attendance: currentStatus, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart index f8d3020d..6d913283 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/data/repositories/profile_repository_impl.dart @@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface { return ProfileSectionStatus.fromJson(json); } + @override + Future getReliabilityStats() async { + final ApiResponse response = + await _api.get(StaffEndpoints.profileStats); + final Map json = + response.data as Map; + return StaffReliabilityStats.fromJson(json); + } + @override Future signOut() async { await _api.post(AuthEndpoints.signOut); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart index b28b963d..55cd30de 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/repositories/profile_repository_interface.dart @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Abstract interface for the staff profile repository. /// /// Defines the contract for fetching staff profile data, -/// section completion statuses, and signing out. +/// section completion statuses, reliability stats, and signing out. abstract interface class ProfileRepositoryInterface { /// Fetches the staff profile from the backend. Future getStaffProfile(); @@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface { /// Fetches the profile section completion statuses. Future getProfileSections(); + /// Fetches reliability and performance statistics for the staff member. + Future getReliabilityStats(); + /// Signs out the current user. Future signOut(); } diff --git a/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart new file mode 100644 index 00000000..5c77f6e4 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile/lib/src/domain/usecases/get_reliability_stats_usecase.dart @@ -0,0 +1,18 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; + +/// Use case for retrieving the staff member's reliability statistics. +class GetReliabilityStatsUseCase + implements NoInputUseCase { + /// Creates a [GetReliabilityStatsUseCase] with the required [repository]. + GetReliabilityStatsUseCase(this._repository); + + final ProfileRepositoryInterface _repository; + + @override + Future call() { + return _repository.getReliabilityStats(); + } +} diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart index 4b68e2ee..2b1b220e 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_cubit.dart @@ -3,6 +3,7 @@ import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; @@ -10,21 +11,24 @@ import 'package:staff_profile/src/presentation/blocs/profile_state.dart'; /// Cubit for managing the Profile feature state. /// /// Delegates all data fetching to use cases, following Clean Architecture. -/// Loads the staff profile and section completion statuses in a single flow. +/// Loads the staff profile, section statuses, and reliability stats. class ProfileCubit extends Cubit with BlocErrorHandler { /// Creates a [ProfileCubit] with the required use cases. ProfileCubit({ required GetStaffProfileUseCase getStaffProfileUseCase, required GetProfileSectionsUseCase getProfileSectionsUseCase, + required GetReliabilityStatsUseCase getReliabilityStatsUseCase, required SignOutUseCase signOutUseCase, }) : _getStaffProfileUseCase = getStaffProfileUseCase, _getProfileSectionsUseCase = getProfileSectionsUseCase, + _getReliabilityStatsUseCase = getReliabilityStatsUseCase, _signOutUseCase = signOutUseCase, super(const ProfileState()); final GetStaffProfileUseCase _getStaffProfileUseCase; final GetProfileSectionsUseCase _getProfileSectionsUseCase; + final GetReliabilityStatsUseCase _getReliabilityStatsUseCase; final SignOutUseCase _signOutUseCase; /// Loads the staff member's profile. @@ -62,6 +66,19 @@ class ProfileCubit extends Cubit ); } + /// Loads reliability and performance statistics for the staff member. + Future loadReliabilityStats() async { + await handleError( + emit: emit, + action: () async { + final StaffReliabilityStats stats = + await _getReliabilityStatsUseCase(); + emit(state.copyWith(reliabilityStats: stats)); + }, + onError: (String _) => state, + ); + } + /// Signs out the current user. Future signOut() async { if (state.status == ProfileStatus.loading) { diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart index 5c0b3903..f6c76068 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/blocs/profile_state.dart @@ -28,6 +28,7 @@ class ProfileState extends Equatable { const ProfileState({ this.status = ProfileStatus.initial, this.profile, + this.reliabilityStats, this.errorMessage, this.personalInfoComplete, this.emergencyContactsComplete, @@ -37,40 +38,45 @@ class ProfileState extends Equatable { this.documentsComplete, this.certificatesComplete, }); - /// Current status of the profile feature + + /// Current status of the profile feature. final ProfileStatus status; - - /// The staff member's profile object (null if not loaded) + + /// The staff member's profile object (null if not loaded). final Staff? profile; - - /// Error message if status is error + + /// Reliability and performance statistics (null if not loaded). + final StaffReliabilityStats? reliabilityStats; + + /// Error message if status is error. final String? errorMessage; - - /// Whether personal information is complete + + /// Whether personal information is complete. final bool? personalInfoComplete; - - /// Whether emergency contacts are complete + + /// Whether emergency contacts are complete. final bool? emergencyContactsComplete; - - /// Whether experience information is complete + + /// Whether experience information is complete. final bool? experienceComplete; - - /// Whether tax forms are complete + + /// Whether tax forms are complete. final bool? taxFormsComplete; - - /// Whether attire options are complete + + /// Whether attire options are complete. final bool? attireComplete; - - /// Whether documents are complete + + /// Whether documents are complete. final bool? documentsComplete; - - /// Whether certificates are complete + + /// Whether certificates are complete. final bool? certificatesComplete; /// Creates a copy of this state with updated values. ProfileState copyWith({ ProfileStatus? status, Staff? profile, + StaffReliabilityStats? reliabilityStats, String? errorMessage, bool? personalInfoComplete, bool? emergencyContactsComplete, @@ -83,6 +89,7 @@ class ProfileState extends Equatable { return ProfileState( status: status ?? this.status, profile: profile ?? this.profile, + reliabilityStats: reliabilityStats ?? this.reliabilityStats, errorMessage: errorMessage ?? this.errorMessage, personalInfoComplete: personalInfoComplete ?? this.personalInfoComplete, emergencyContactsComplete: emergencyContactsComplete ?? this.emergencyContactsComplete, @@ -98,6 +105,7 @@ class ProfileState extends Equatable { List get props => [ status, profile, + reliabilityStats, errorMessage, personalInfoComplete, emergencyContactsComplete, diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 5dc8ef39..97c69e9b 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget { value: cubit, child: BlocConsumer( listener: (BuildContext context, ProfileState state) { - // Load section statuses when profile loads successfully + // Load section statuses and reliability stats when profile loads if (state.status == ProfileStatus.loaded && state.personalInfoComplete == null) { cubit.loadSectionStatuses(); + cubit.loadReliabilityStats(); } if (state.status == ProfileStatus.signedOut) { @@ -100,16 +101,16 @@ class StaffProfilePage extends StatelessWidget { children: [ // Reliability Stats ReliabilityStatsCard( - totalShifts: 0, - averageRating: profile.averageRating, - onTimeRate: 0, - noShowCount: 0, - cancellationCount: 0, + totalShifts: state.reliabilityStats?.totalShifts, + averageRating: state.reliabilityStats?.averageRating, + onTimeRate: state.reliabilityStats?.onTimeRate.round(), + noShowCount: state.reliabilityStats?.noShowCount, + cancellationCount: state.reliabilityStats?.cancellationCount, ), // Reliability Score Bar - const ReliabilityScoreBar( - reliabilityScore: 0, + ReliabilityScoreBar( + reliabilityScore: state.reliabilityStats?.reliabilityScore.round(), ), // Ordered sections diff --git a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart index e9854ab8..c118900c 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/staff_profile_module.dart @@ -6,6 +6,7 @@ import 'package:krow_domain/krow_domain.dart'; import 'package:staff_profile/src/data/repositories/profile_repository_impl.dart'; import 'package:staff_profile/src/domain/repositories/profile_repository_interface.dart'; import 'package:staff_profile/src/domain/usecases/get_profile_sections_usecase.dart'; +import 'package:staff_profile/src/domain/usecases/get_reliability_stats_usecase.dart'; import 'package:staff_profile/src/domain/usecases/get_staff_profile_usecase.dart'; import 'package:staff_profile/src/domain/usecases/sign_out_usecase.dart'; import 'package:staff_profile/src/presentation/blocs/profile_cubit.dart'; @@ -44,12 +45,18 @@ class StaffProfileModule extends Module { i.get(), ), ); + i.addLazySingleton( + () => GetReliabilityStatsUseCase( + i.get(), + ), + ); // Cubit i.addLazySingleton( () => ProfileCubit( getStaffProfileUseCase: i.get(), getProfileSectionsUseCase: i.get(), + getReliabilityStatsUseCase: i.get(), signOutUseCase: i.get(), ), );