Add staff reliability stats & shift location
Introduce staff reliability statistics and location fields across domain, data, and UI. Changes include:
- New API endpoint StaffEndpoints.profileStats ('/staff/profile/stats').
- New domain entity StaffReliabilityStats with JSON (de)serialization and export.
- Profile repository: getReliabilityStats implementation and interface addition.
- New GetReliabilityStatsUseCase and DI registration in StaffProfileModule.
- ProfileCubit/state: load and store reliabilityStats; UI wired to display ReliabilityStatsCard and ReliabilityScoreBar using state values.
- Coverage/shift updates: Added AssignedWorker.hasReview to track if a worker was reviewed; added locationName/locationAddress to ShiftWithWorkers and show location in ShiftHeader; hide rate button if worker.hasReview.
- Clock-in handling: treat backend ALREADY_CLOCKED_IN (409) as idempotent by re-fetching attendance and emitting success when appropriate.
These changes wire backend stats through repository/usecase/cubit to the profile UI and add shift location and review-awareness to client views.
This commit is contained in:
@@ -161,6 +161,7 @@ class _CoverageShiftListState extends State<CoverageShiftList> {
|
||||
children: <Widget>[
|
||||
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<CoverageShiftList> {
|
||||
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 ||
|
||||
|
||||
@@ -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) ...<Widget>[
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
|
||||
@@ -228,12 +228,20 @@ class ClockInBloc extends Bloc<ClockInEvent, ClockInState>
|
||||
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,
|
||||
|
||||
@@ -31,6 +31,15 @@ class ProfileRepositoryImpl implements ProfileRepositoryInterface {
|
||||
return ProfileSectionStatus.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<StaffReliabilityStats> getReliabilityStats() async {
|
||||
final ApiResponse response =
|
||||
await _api.get(StaffEndpoints.profileStats);
|
||||
final Map<String, dynamic> json =
|
||||
response.data as Map<String, dynamic>;
|
||||
return StaffReliabilityStats.fromJson(json);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> signOut() async {
|
||||
await _api.post(AuthEndpoints.signOut);
|
||||
|
||||
@@ -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<Staff> getStaffProfile();
|
||||
@@ -11,6 +11,9 @@ abstract interface class ProfileRepositoryInterface {
|
||||
/// Fetches the profile section completion statuses.
|
||||
Future<ProfileSectionStatus> getProfileSections();
|
||||
|
||||
/// Fetches reliability and performance statistics for the staff member.
|
||||
Future<StaffReliabilityStats> getReliabilityStats();
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut();
|
||||
}
|
||||
|
||||
@@ -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<StaffReliabilityStats> {
|
||||
/// Creates a [GetReliabilityStatsUseCase] with the required [repository].
|
||||
GetReliabilityStatsUseCase(this._repository);
|
||||
|
||||
final ProfileRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<StaffReliabilityStats> call() {
|
||||
return _repository.getReliabilityStats();
|
||||
}
|
||||
}
|
||||
@@ -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<ProfileState>
|
||||
with BlocErrorHandler<ProfileState> {
|
||||
/// 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<ProfileState>
|
||||
);
|
||||
}
|
||||
|
||||
/// Loads reliability and performance statistics for the staff member.
|
||||
Future<void> 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<void> signOut() async {
|
||||
if (state.status == ProfileStatus.loading) {
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
status,
|
||||
profile,
|
||||
reliabilityStats,
|
||||
errorMessage,
|
||||
personalInfoComplete,
|
||||
emergencyContactsComplete,
|
||||
|
||||
@@ -37,10 +37,11 @@ class StaffProfilePage extends StatelessWidget {
|
||||
value: cubit,
|
||||
child: BlocConsumer<ProfileCubit, ProfileState>(
|
||||
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: <Widget>[
|
||||
// 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
|
||||
|
||||
@@ -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<ProfileRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
i.addLazySingleton<GetReliabilityStatsUseCase>(
|
||||
() => GetReliabilityStatsUseCase(
|
||||
i.get<ProfileRepositoryInterface>(),
|
||||
),
|
||||
);
|
||||
|
||||
// Cubit
|
||||
i.addLazySingleton<ProfileCubit>(
|
||||
() => ProfileCubit(
|
||||
getStaffProfileUseCase: i.get<GetStaffProfileUseCase>(),
|
||||
getProfileSectionsUseCase: i.get<GetProfileSectionsUseCase>(),
|
||||
getReliabilityStatsUseCase: i.get<GetReliabilityStatsUseCase>(),
|
||||
signOutUseCase: i.get<SignOutUseCase>(),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user