feat: Migrate staff profile features from Data Connect to V2 REST API

- Removed data_connect package from mobile pubspec.yaml.
- Added documentation for V2 profile migration status and QA findings.
- Implemented new session management with ClientSessionStore and StaffSessionStore.
- Created V2SessionService for handling user sessions via the V2 API.
- Developed use cases for cancelling late worker assignments and submitting worker reviews.
- Added arguments and use cases for payment chart retrieval and profile completion checks.
- Implemented repository interfaces and their implementations for staff main and profile features.
- Ensured proper error handling and validation in use cases.
This commit is contained in:
Achintha Isuru
2026-03-16 22:45:06 -04:00
parent 4834266986
commit b31a615092
478 changed files with 10512 additions and 19854 deletions

View File

@@ -1,89 +1,119 @@
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/reports_repository.dart';
/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository].
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
/// V2 API implementation of [ReportsRepository].
///
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
/// connector repository from the data_connect package.
/// Each method hits its corresponding `V2ApiEndpoints.clientReports*` endpoint,
/// passing date-range query parameters, and deserialises the JSON response
/// into the relevant domain entity.
class ReportsRepositoryImpl implements ReportsRepository {
/// Creates a [ReportsRepositoryImpl].
ReportsRepositoryImpl({required BaseApiService apiService})
: _apiService = apiService;
ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository})
: _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository();
final ReportsConnectorRepository _connectorRepository;
/// The API service used for network requests.
final BaseApiService _apiService;
// ── Helpers ──────────────────────────────────────────────────────────────
/// Converts a [DateTime] to an ISO-8601 date string (yyyy-MM-dd).
String _iso(DateTime dt) => dt.toIso8601String().split('T').first;
/// Standard date-range query parameters.
Map<String, dynamic> _rangeParams(DateTime start, DateTime end) =>
<String, dynamic>{'startDate': _iso(start), 'endDate': _iso(end)};
// ── Reports ──────────────────────────────────────────────────────────────
@override
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
}) => _connectorRepository.getDailyOpsReport(
businessId: businessId,
date: date,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsDailyOps,
params: <String, dynamic>{'date': _iso(date)},
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return DailyOpsReport.fromJson(data);
}
@override
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getSpendReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsSpend,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return SpendReport.fromJson(data);
}
@override
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getCoverageReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsCoverage,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return CoverageReport.fromJson(data);
}
@override
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getForecastReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsForecast,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return ForecastReport.fromJson(data);
}
@override
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getPerformanceReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsPerformance,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return PerformanceReport.fromJson(data);
}
@override
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getNoShowReport(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsNoShow,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return NoShowReport.fromJson(data);
}
@override
Future<ReportsSummary> getReportsSummary({
String? businessId,
Future<ReportSummary> getReportsSummary({
required DateTime startDate,
required DateTime endDate,
}) => _connectorRepository.getReportsSummary(
businessId: businessId,
startDate: startDate,
endDate: endDate,
);
}) async {
final ApiResponse response = await _apiService.get(
V2ApiEndpoints.clientReportsSummary,
params: _rangeParams(startDate, endDate),
);
final Map<String, dynamic> data = response.data as Map<String, dynamic>;
return ReportSummary.fromJson(data);
}
}

View File

@@ -1,43 +1,44 @@
import 'package:krow_domain/krow_domain.dart';
/// Contract for fetching report data from the V2 API.
abstract class ReportsRepository {
/// Fetches the daily operations report for a given [date].
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
});
/// Fetches the spend report for a date range.
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the coverage report for a date range.
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the forecast report for a date range.
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the performance report for a date range.
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
/// Fetches the no-show report for a date range.
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<ReportsSummary> getReportsSummary({
String? businessId,
/// Fetches the high-level report summary for a date range.
Future<ReportSummary> getReportsSummary({
required DateTime startDate,
required DateTime endDate,
});

View File

@@ -1,34 +1,39 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/coverage_report.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'coverage_event.dart';
import 'coverage_state.dart';
class CoverageBloc extends Bloc<CoverageEvent, CoverageState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [CoverageReport].
class CoverageBloc extends Bloc<CoverageEvent, CoverageState>
with BlocErrorHandler<CoverageState> {
/// Creates a [CoverageBloc].
CoverageBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(CoverageInitial()) {
on<LoadCoverageReport>(_onLoadCoverageReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadCoverageReport(
LoadCoverageReport event,
Emitter<CoverageState> emit,
) async {
emit(CoverageLoading());
try {
final CoverageReport report = await _reportsRepository.getCoverageReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(CoverageLoaded(report));
} catch (e) {
emit(CoverageError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(CoverageLoading());
final CoverageReport report =
await _reportsRepository.getCoverageReport(
startDate: event.startDate,
endDate: event.endDate,
);
emit(CoverageLoaded(report));
},
onError: (String errorKey) => CoverageError(errorKey),
);
}
}

View File

@@ -1,25 +1,28 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:equatable/equatable.dart';
/// Base event for the coverage report BLoC.
abstract class CoverageEvent extends Equatable {
/// Creates a [CoverageEvent].
const CoverageEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the coverage report for a date range.
class LoadCoverageReport extends CoverageEvent {
/// Creates a [LoadCoverageReport] event.
const LoadCoverageReport({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,32 +1,38 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart';
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'daily_ops_event.dart';
import 'daily_ops_state.dart';
class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState> {
/// BLoC that loads the [DailyOpsReport].
class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState>
with BlocErrorHandler<DailyOpsState> {
/// Creates a [DailyOpsBloc].
DailyOpsBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(DailyOpsInitial()) {
on<LoadDailyOpsReport>(_onLoadDailyOpsReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadDailyOpsReport(
LoadDailyOpsReport event,
Emitter<DailyOpsState> emit,
) async {
emit(DailyOpsLoading());
try {
final DailyOpsReport report = await _reportsRepository.getDailyOpsReport(
businessId: event.businessId,
date: event.date,
);
emit(DailyOpsLoaded(report));
} catch (e) {
emit(DailyOpsError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(DailyOpsLoading());
final DailyOpsReport report =
await _reportsRepository.getDailyOpsReport(
date: event.date,
);
emit(DailyOpsLoaded(report));
},
onError: (String errorKey) => DailyOpsError(errorKey),
);
}
}

View File

@@ -1,21 +1,22 @@
import 'package:equatable/equatable.dart';
/// Base event for the daily ops BLoC.
abstract class DailyOpsEvent extends Equatable {
/// Creates a [DailyOpsEvent].
const DailyOpsEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the daily operations report for a given [date].
class LoadDailyOpsReport extends DailyOpsEvent {
/// Creates a [LoadDailyOpsReport] event.
const LoadDailyOpsReport({required this.date});
const LoadDailyOpsReport({
this.businessId,
required this.date,
});
final String? businessId;
/// The date to fetch the report for.
final DateTime date;
@override
List<Object?> get props => <Object?>[businessId, date];
List<Object?> get props => <Object?>[date];
}

View File

@@ -1,32 +1,39 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/forecast_report.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'forecast_event.dart';
import 'forecast_state.dart';
class ForecastBloc extends Bloc<ForecastEvent, ForecastState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [ForecastReport].
class ForecastBloc extends Bloc<ForecastEvent, ForecastState>
with BlocErrorHandler<ForecastState> {
/// Creates a [ForecastBloc].
ForecastBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(ForecastInitial()) {
on<LoadForecastReport>(_onLoadForecastReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadForecastReport(
LoadForecastReport event,
Emitter<ForecastState> emit,
) async {
emit(ForecastLoading());
try {
final ForecastReport report = await _reportsRepository.getForecastReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(ForecastLoaded(report));
} catch (e) {
emit(ForecastError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(ForecastLoading());
final ForecastReport report =
await _reportsRepository.getForecastReport(
startDate: event.startDate,
endDate: event.endDate,
);
emit(ForecastLoaded(report));
},
onError: (String errorKey) => ForecastError(errorKey),
);
}
}

View File

@@ -7,17 +7,20 @@ abstract class ForecastEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the forecast report for a date range.
class LoadForecastReport extends ForecastEvent {
/// Creates a [LoadForecastReport] event.
const LoadForecastReport({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,32 +1,38 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/no_show_report.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'no_show_event.dart';
import 'no_show_state.dart';
class NoShowBloc extends Bloc<NoShowEvent, NoShowState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [NoShowReport].
class NoShowBloc extends Bloc<NoShowEvent, NoShowState>
with BlocErrorHandler<NoShowState> {
/// Creates a [NoShowBloc].
NoShowBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(NoShowInitial()) {
on<LoadNoShowReport>(_onLoadNoShowReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadNoShowReport(
LoadNoShowReport event,
Emitter<NoShowState> emit,
) async {
emit(NoShowLoading());
try {
final NoShowReport report = await _reportsRepository.getNoShowReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(NoShowLoaded(report));
} catch (e) {
emit(NoShowError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(NoShowLoading());
final NoShowReport report = await _reportsRepository.getNoShowReport(
startDate: event.startDate,
endDate: event.endDate,
);
emit(NoShowLoaded(report));
},
onError: (String errorKey) => NoShowError(errorKey),
);
}
}

View File

@@ -7,17 +7,20 @@ abstract class NoShowEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the no-show report for a date range.
class LoadNoShowReport extends NoShowEvent {
/// Creates a [LoadNoShowReport] event.
const LoadNoShowReport({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,32 +1,39 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/performance_report.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'performance_event.dart';
import 'performance_state.dart';
class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [PerformanceReport].
class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState>
with BlocErrorHandler<PerformanceState> {
/// Creates a [PerformanceBloc].
PerformanceBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(PerformanceInitial()) {
on<LoadPerformanceReport>(_onLoadPerformanceReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadPerformanceReport(
LoadPerformanceReport event,
Emitter<PerformanceState> emit,
) async {
emit(PerformanceLoading());
try {
final PerformanceReport report = await _reportsRepository.getPerformanceReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(PerformanceLoaded(report));
} catch (e) {
emit(PerformanceError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(PerformanceLoading());
final PerformanceReport report =
await _reportsRepository.getPerformanceReport(
startDate: event.startDate,
endDate: event.endDate,
);
emit(PerformanceLoaded(report));
},
onError: (String errorKey) => PerformanceError(errorKey),
);
}
}

View File

@@ -7,17 +7,20 @@ abstract class PerformanceEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the performance report for a date range.
class LoadPerformanceReport extends PerformanceEvent {
/// Creates a [LoadPerformanceReport] event.
const LoadPerformanceReport({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,32 +1,38 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/spend_report.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'spend_event.dart';
import 'spend_state.dart';
class SpendBloc extends Bloc<SpendEvent, SpendState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the [SpendReport].
class SpendBloc extends Bloc<SpendEvent, SpendState>
with BlocErrorHandler<SpendState> {
/// Creates a [SpendBloc].
SpendBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(SpendInitial()) {
on<LoadSpendReport>(_onLoadSpendReport);
}
/// The repository used to fetch report data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadSpendReport(
LoadSpendReport event,
Emitter<SpendState> emit,
) async {
emit(SpendLoading());
try {
final SpendReport report = await _reportsRepository.getSpendReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(SpendLoaded(report));
} catch (e) {
emit(SpendError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(SpendLoading());
final SpendReport report = await _reportsRepository.getSpendReport(
startDate: event.startDate,
endDate: event.endDate,
);
emit(SpendLoaded(report));
},
onError: (String errorKey) => SpendError(errorKey),
);
}
}

View File

@@ -7,17 +7,20 @@ abstract class SpendEvent extends Equatable {
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the spend report for a date range.
class LoadSpendReport extends SpendEvent {
/// Creates a [LoadSpendReport] event.
const LoadSpendReport({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,32 +1,40 @@
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart';
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/src/entities/reports/reports_summary.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'reports_summary_event.dart';
import 'reports_summary_state.dart';
class ReportsSummaryBloc extends Bloc<ReportsSummaryEvent, ReportsSummaryState> {
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// BLoC that loads the high-level [ReportSummary] for the reports dashboard.
class ReportsSummaryBloc
extends Bloc<ReportsSummaryEvent, ReportsSummaryState>
with BlocErrorHandler<ReportsSummaryState> {
/// Creates a [ReportsSummaryBloc].
ReportsSummaryBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(ReportsSummaryInitial()) {
on<LoadReportsSummary>(_onLoadReportsSummary);
}
/// The repository used to fetch summary data.
final ReportsRepository _reportsRepository;
Future<void> _onLoadReportsSummary(
LoadReportsSummary event,
Emitter<ReportsSummaryState> emit,
) async {
emit(ReportsSummaryLoading());
try {
final ReportsSummary summary = await _reportsRepository.getReportsSummary(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(ReportsSummaryLoaded(summary));
} catch (e) {
emit(ReportsSummaryError(e.toString()));
}
await handleError(
emit: emit,
action: () async {
emit(ReportsSummaryLoading());
final ReportSummary summary =
await _reportsRepository.getReportsSummary(
startDate: event.startDate,
endDate: event.endDate,
);
emit(ReportsSummaryLoaded(summary));
},
onError: (String errorKey) => ReportsSummaryError(errorKey),
);
}
}

View File

@@ -1,23 +1,28 @@
import 'package:equatable/equatable.dart';
/// Base event for the reports summary BLoC.
abstract class ReportsSummaryEvent extends Equatable {
/// Creates a [ReportsSummaryEvent].
const ReportsSummaryEvent();
@override
List<Object?> get props => <Object?>[];
}
/// Triggers loading of the report summary for a date range.
class LoadReportsSummary extends ReportsSummaryEvent {
/// Creates a [LoadReportsSummary] event.
const LoadReportsSummary({
this.businessId,
required this.startDate,
required this.endDate,
});
final String? businessId;
/// Start of the reporting period.
final DateTime startDate;
/// End of the reporting period.
final DateTime endDate;
@override
List<Object?> get props => <Object?>[businessId, startDate, endDate];
List<Object?> get props => <Object?>[startDate, endDate];
}

View File

@@ -1,33 +1,41 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
/// Base state for the reports summary BLoC.
abstract class ReportsSummaryState extends Equatable {
/// Creates a [ReportsSummaryState].
const ReportsSummaryState();
@override
List<Object?> get props => <Object?>[];
}
/// Initial state before any data is loaded.
class ReportsSummaryInitial extends ReportsSummaryState {}
/// Summary data is being fetched.
class ReportsSummaryLoading extends ReportsSummaryState {}
/// Summary data loaded successfully.
class ReportsSummaryLoaded extends ReportsSummaryState {
/// Creates a [ReportsSummaryLoaded] state.
const ReportsSummaryLoaded(this.summary);
final ReportsSummary summary;
/// The loaded report summary.
final ReportSummary summary;
@override
List<Object?> get props => <Object?>[summary];
}
/// An error occurred while fetching the summary.
class ReportsSummaryError extends ReportsSummaryState {
/// Creates a [ReportsSummaryError] state.
const ReportsSummaryError(this.message);
/// Human-readable error description.
final String message;
@override
List<Object?> get props => <Object?>[message];
}

View File

@@ -10,7 +10,7 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
class CoverageReportPage extends StatefulWidget {
const CoverageReportPage({super.key});
@@ -122,7 +122,7 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
Expanded(
child: _CoverageSummaryCard(
label: context.t.client_reports.coverage_report.metrics.avg_coverage,
value: '${report.overallCoverage.toStringAsFixed(1)}%',
value: '${report.averageCoveragePercentage}%',
icon: UiIcons.chart,
color: UiColors.primary,
),
@@ -131,7 +131,7 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
Expanded(
child: _CoverageSummaryCard(
label: context.t.client_reports.coverage_report.metrics.full,
value: '${report.totalFilled}/${report.totalNeeded}',
value: '${report.filledWorkers}/${report.neededWorkers}',
icon: UiIcons.users,
color: UiColors.success,
),
@@ -151,14 +151,14 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
),
),
const SizedBox(height: 16),
if (report.dailyCoverage.isEmpty)
if (report.chart.isEmpty)
Center(child: Text(context.t.client_reports.coverage_report.empty_state))
else
...report.dailyCoverage.map((CoverageDay day) => _CoverageListItem(
date: DateFormat('EEE, MMM dd').format(day.date),
...report.chart.map((CoverageDayPoint day) => _CoverageListItem(
date: DateFormat('EEE, MMM dd').format(day.day),
needed: day.needed,
filled: day.filled,
percentage: day.percentage,
percentage: day.coveragePercentage,
)),
const SizedBox(height: 100),
],

View File

@@ -10,7 +10,7 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
class DailyOpsReportPage extends StatefulWidget {
const DailyOpsReportPage({super.key});
@@ -254,7 +254,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
_OpsStatCard(
label: context.t.client_reports
.daily_ops_report.metrics.scheduled.label,
value: report.scheduledShifts.toString(),
value: report.totalShifts.toString(),
subValue: context
.t
.client_reports
@@ -268,7 +268,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
_OpsStatCard(
label: context.t.client_reports
.daily_ops_report.metrics.workers.label,
value: report.workersConfirmed.toString(),
value: report.totalWorkersDeployed.toString(),
subValue: context
.t
.client_reports
@@ -276,7 +276,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
.metrics
.workers
.sub_value,
color: UiColors.primary,
color: UiColors.primary,
icon: UiIcons.users,
),
_OpsStatCard(
@@ -287,7 +287,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
.metrics
.in_progress
.label,
value: report.inProgressShifts.toString(),
value: report.totalHoursWorked.toString(),
subValue: context
.t
.client_reports
@@ -295,7 +295,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
.metrics
.in_progress
.sub_value,
color: UiColors.textWarning,
color: UiColors.textWarning,
icon: UiIcons.clock,
),
_OpsStatCard(
@@ -306,7 +306,7 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
.metrics
.completed
.label,
value: report.completedShifts.toString(),
value: '${report.onTimeArrivalPercentage}%',
subValue: context
.t
.client_reports
@@ -343,22 +343,20 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
),
)
else
...report.shifts.map((DailyOpsShift shift) => _ShiftListItem(
title: shift.title,
location: shift.location,
...report.shifts.map((ShiftWithWorkers shift) => _ShiftListItem(
title: shift.roleName,
location: shift.shiftId,
time:
'${DateFormat('HH:mm').format(shift.startTime)} - ${DateFormat('HH:mm').format(shift.endTime)}',
'${DateFormat('HH:mm').format(shift.timeRange.startsAt)} - ${DateFormat('HH:mm').format(shift.timeRange.endsAt)}',
workers:
'${shift.filled}/${shift.workersNeeded}',
rate: shift.hourlyRate != null
? '\$${shift.hourlyRate!.toStringAsFixed(0)}/hr'
: '-',
status: shift.status.replaceAll('_', ' '),
statusColor: shift.status == 'COMPLETED'
'${shift.assignedWorkerCount}/${shift.requiredWorkerCount}',
rate: '-',
status: shift.assignedWorkerCount >= shift.requiredWorkerCount
? 'FILLED'
: 'OPEN',
statusColor: shift.assignedWorkerCount >= shift.requiredWorkerCount
? UiColors.success
: shift.status == 'IN_PROGRESS'
? UiColors.textWarning
: UiColors.primary,
: UiColors.textWarning,
)),
const SizedBox(height: 100),

View File

@@ -1,9 +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
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:fl_chart/fl_chart.dart';
@@ -11,10 +9,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
/// Page displaying the staffing and spend forecast report.
class ForecastReportPage extends StatefulWidget {
/// Creates a [ForecastReportPage].
const ForecastReportPage({super.key});
@override
@@ -23,11 +23,11 @@ class ForecastReportPage extends StatefulWidget {
class _ForecastReportPageState extends State<ForecastReportPage> {
final DateTime _startDate = DateTime.now();
final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks
final DateTime _endDate = DateTime.now().add(const Duration(days: 28));
@override
Widget build(BuildContext context) {
return BlocProvider(
return BlocProvider<ForecastBloc>(
create: (BuildContext context) => Modular.get<ForecastBloc>()
..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
@@ -46,10 +46,7 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
return SingleChildScrollView(
child: Column(
children: <Widget>[
// Header
_buildHeader(context),
// Content
Transform.translate(
offset: const Offset(0, -20),
child: Padding(
@@ -57,37 +54,36 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Metrics Grid
_buildMetricsGrid(context, report),
const SizedBox(height: 16),
// Chart Section
_buildChartSection(context, report),
const SizedBox(height: 24),
// Weekly Breakdown Title
Text(
context.t.client_reports.forecast_report.weekly_breakdown.title,
style: UiTypography.titleUppercase2m.textSecondary,
context.t.client_reports.forecast_report
.weekly_breakdown.title,
style:
UiTypography.titleUppercase2m.textSecondary,
),
const SizedBox(height: 12),
// Weekly Breakdown List
if (report.weeklyBreakdown.isEmpty)
if (report.weeks.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(32.0),
child: Text(
context.t.client_reports.forecast_report.empty_state,
context.t.client_reports.forecast_report
.empty_state,
style: UiTypography.body2r.textSecondary,
),
),
)
else
...report.weeklyBreakdown.map(
(ForecastWeek week) => _WeeklyBreakdownItem(week: week),
),
...report.weeks.asMap().entries.map(
(MapEntry<int, ForecastWeek> entry) =>
_WeeklyBreakdownItem(
week: entry.value,
weekIndex: entry.key + 1,
),
),
const SizedBox(height: UiConstants.space24),
],
),
@@ -106,84 +102,60 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
Widget _buildHeader(BuildContext context) {
return Container(
padding: const EdgeInsets.only(
top: 60,
left: 20,
right: 20,
bottom: 40,
),
padding: const EdgeInsets.only(top: 60, left: 20, right: 20, bottom: 40),
decoration: const BoxDecoration(
color: UiColors.primary,
gradient: LinearGradient(
colors: <Color>[UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient
colors: <Color>[UiColors.primary, Color(0xFF0020A0)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.popSafe(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
GestureDetector(
onTap: () => Modular.to.popSafe(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_reports.forecast_report.title,
style: UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
context.t.client_reports.forecast_report.subtitle,
style: UiTypography.body2m.copyWith(
color: UiColors.white.withOpacity(0.7),
),
),
],
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_reports.forecast_report.title,
style:
UiTypography.headline3m.copyWith(color: UiColors.white),
),
Text(
context.t.client_reports.forecast_report.subtitle,
style: UiTypography.body2m.copyWith(
color: UiColors.white.withOpacity(0.7),
),
),
],
),
/*
UiButton.secondary(
text: context.t.client_reports.forecast_report.buttons.export,
leadingIcon: UiIcons.download,
onPressed: () {
// Placeholder export action
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.t.client_reports.forecast_report.placeholders.export_message),
),
);
},
// If button variants are limited, we might need a custom button or adjust design system usage
// Since I can't easily see UiButton implementation details beyond exports, I'll stick to a standard usage.
// If UiButton doesn't look right on blue bg, I count rely on it being white/transparent based on tokens.
),
*/
],
),
);
}
Widget _buildMetricsGrid(BuildContext context, ForecastReport report) {
final TranslationsClientReportsForecastReportEn t = context.t.client_reports.forecast_report;
final TranslationsClientReportsForecastReportEn t =
context.t.client_reports.forecast_report;
final NumberFormat currFmt =
NumberFormat.currency(symbol: r'$', decimalDigits: 0);
return GridView.count(
crossAxisCount: 2,
padding: EdgeInsets.zero,
@@ -196,31 +168,31 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
_MetricCard(
icon: UiIcons.dollar,
label: t.metrics.four_week_forecast,
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend),
value: currFmt.format(report.forecastSpendCents / 100),
badgeText: t.badges.total_projected,
iconColor: UiColors.textWarning,
badgeColor: UiColors.tagPending, // Yellow-ish
badgeColor: UiColors.tagPending,
),
_MetricCard(
icon: UiIcons.trendingUp,
label: t.metrics.avg_weekly,
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend),
value: currFmt.format(report.averageWeeklySpendCents / 100),
badgeText: t.badges.per_week,
iconColor: UiColors.primary,
badgeColor: UiColors.tagInProgress, // Blue-ish
badgeColor: UiColors.tagInProgress,
),
_MetricCard(
icon: UiIcons.calendar,
label: t.metrics.total_shifts,
value: report.totalShifts.toString(),
badgeText: t.badges.scheduled,
iconColor: const Color(0xFF9333EA), // Purple
badgeColor: const Color(0xFFF3E8FF), // Purple light
iconColor: const Color(0xFF9333EA),
badgeColor: const Color(0xFFF3E8FF),
),
_MetricCard(
icon: UiIcons.users,
label: t.metrics.total_hours,
value: report.totalHours.toStringAsFixed(0),
value: report.totalWorkerHours.toStringAsFixed(0),
badgeText: t.badges.worker_hours,
iconColor: UiColors.success,
badgeColor: UiColors.tagSuccess,
@@ -248,29 +220,25 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
children: <Widget>[
Text(
context.t.client_reports.forecast_report.chart_title,
style: UiTypography.headline4m,
style: UiTypography.headline4m,
),
const SizedBox(height: 8),
Text(
r'$15k', // Example Y-axis label placeholder or dynamic max
style: UiTypography.footnote1r.textSecondary,
),
const SizedBox(height: 24),
const SizedBox(height: 32),
Expanded(
child: _ForecastChart(points: report.chartData),
child: _ForecastChart(weeks: report.weeks),
),
const SizedBox(height: 8),
// X Axis labels manually if chart doesn't handle them perfectly or for custom look
const Row(
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text('W1', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W1', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W2', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W2', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W3', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
Text('W3', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
Text('W4', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
for (int i = 0; i < report.weeks.length; i++) ...<Widget>[
Text('W${i + 1}',
style: const TextStyle(
color: UiColors.textSecondary, fontSize: 12)),
if (i < report.weeks.length - 1)
const Text('',
style: TextStyle(
color: UiColors.transparent, fontSize: 12)),
],
],
),
],
@@ -280,7 +248,6 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
}
class _MetricCard extends StatelessWidget {
const _MetricCard({
required this.icon,
required this.label,
@@ -289,6 +256,7 @@ class _MetricCard extends StatelessWidget {
required this.iconColor,
required this.badgeColor,
});
final IconData icon;
final String label;
final String value;
@@ -329,7 +297,8 @@ class _MetricCard extends StatelessWidget {
),
Text(
value,
style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold),
style:
UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -340,7 +309,7 @@ class _MetricCard extends StatelessWidget {
child: Text(
badgeText,
style: UiTypography.footnote1r.copyWith(
color: UiColors.textPrimary, // Or specific text color
color: UiColors.textPrimary,
fontSize: 10,
fontWeight: FontWeight.w600,
),
@@ -352,15 +321,23 @@ class _MetricCard extends StatelessWidget {
}
}
/// Weekly breakdown item using V2 [ForecastWeek] fields.
class _WeeklyBreakdownItem extends StatelessWidget {
const _WeeklyBreakdownItem({
required this.week,
required this.weekIndex,
});
const _WeeklyBreakdownItem({required this.week});
final ForecastWeek week;
final int weekIndex;
@override
Widget build(BuildContext context) {
final TranslationsClientReportsForecastReportWeeklyBreakdownEn t = context.t.client_reports.forecast_report.weekly_breakdown;
final TranslationsClientReportsForecastReportWeeklyBreakdownEn t =
context.t.client_reports.forecast_report.weekly_breakdown;
final NumberFormat currFmt =
NumberFormat.currency(symbol: r'$', decimalDigits: 0);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
@@ -374,17 +351,18 @@ class _WeeklyBreakdownItem extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.week(index: week.weekNumber),
t.week(index: weekIndex),
style: UiTypography.headline4m,
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: UiColors.tagPending,
borderRadius: BorderRadius.circular(8),
),
child: Text(
NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.totalCost),
currFmt.format(week.forecastSpendCents / 100),
style: UiTypography.body2b.copyWith(
color: UiColors.textWarning,
),
@@ -396,9 +374,11 @@ class _WeeklyBreakdownItem extends StatelessWidget {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
_buildStat(t.shifts, week.shiftsCount.toString()),
_buildStat(t.hours, week.hoursCount.toStringAsFixed(0)),
_buildStat(t.avg_shift, NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.avgCostPerShift)),
_buildStat(t.shifts, week.shiftCount.toString()),
_buildStat(t.hours, week.workerHours.toStringAsFixed(0)),
_buildStat(
t.avg_shift,
currFmt.format(week.averageShiftCostCents / 100)),
],
),
],
@@ -418,24 +398,24 @@ class _WeeklyBreakdownItem extends StatelessWidget {
}
}
/// Line chart using [ForecastWeek] data (dollars from cents).
class _ForecastChart extends StatelessWidget {
const _ForecastChart({required this.weeks});
const _ForecastChart({required this.points});
final List<ForecastPoint> points;
final List<ForecastWeek> weeks;
@override
Widget build(BuildContext context) {
// If no data, show empty or default line?
if (points.isEmpty) return const SizedBox();
if (weeks.isEmpty) return const SizedBox();
return LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 5000, // Dynamic?
horizontalInterval: 5000,
getDrawingHorizontalLine: (double value) {
return const FlLine(
return const FlLine(
color: UiColors.borderInactive,
strokeWidth: 1,
dashArray: <int>[5, 5],
@@ -445,31 +425,34 @@ class _ForecastChart extends StatelessWidget {
titlesData: const FlTitlesData(show: false),
borderData: FlBorderData(show: false),
minX: 0,
maxX: points.length.toDouble() - 1,
// minY: 0, // Let it scale automatically
maxX: weeks.length.toDouble() - 1,
lineBarsData: <LineChartBarData>[
LineChartBarData(
spots: points.asMap().entries.map((MapEntry<int, ForecastPoint> e) {
return FlSpot(e.key.toDouble(), e.value.projectedCost);
}).toList(),
spots: weeks
.asMap()
.entries
.map((MapEntry<int, ForecastWeek> e) => FlSpot(
e.key.toDouble(), e.value.forecastSpendCents / 100))
.toList(),
isCurved: true,
color: UiColors.textWarning, // Orange-ish
color: UiColors.textWarning,
barWidth: 4,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (FlSpot spot, double percent, LineChartBarData barData, int index) {
return FlDotCirclePainter(
radius: 4,
color: UiColors.textWarning,
strokeWidth: 2,
strokeColor: UiColors.white,
);
getDotPainter: (FlSpot spot, double percent,
LineChartBarData barData, int index) {
return FlDotCirclePainter(
radius: 4,
color: UiColors.textWarning,
strokeWidth: 2,
strokeColor: UiColors.white,
);
},
),
belowBarData: BarAreaData(
show: true,
color: UiColors.tagPending.withOpacity(0.5), // Light orange fill
color: UiColors.tagPending.withOpacity(0.5),
),
),
],
@@ -477,4 +460,3 @@ class _ForecastChart extends StatelessWidget {
);
}
}

View File

@@ -11,7 +11,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import '../widgets/report_detail_skeleton.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
class NoShowReportPage extends StatefulWidget {
const NoShowReportPage({super.key});
@@ -42,7 +42,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
if (state is NoShowLoaded) {
final NoShowReport report = state.report;
final int uniqueWorkers = report.flaggedWorkers.length;
final int uniqueWorkers = report.workersWhoNoShowed;
return SingleChildScrollView(
child: Column(
children: <Widget>[
@@ -167,7 +167,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
icon: UiIcons.warning,
iconColor: UiColors.error,
label: context.t.client_reports.no_show_report.metrics.no_shows,
value: report.totalNoShows.toString(),
value: report.totalNoShowCount.toString(),
),
),
const SizedBox(width: 12),
@@ -177,7 +177,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
iconColor: UiColors.textWarning,
label: context.t.client_reports.no_show_report.metrics.rate,
value:
'${report.noShowRate.toStringAsFixed(1)}%',
'${report.noShowRatePercentage}%',
),
),
const SizedBox(width: 12),
@@ -208,7 +208,7 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
const SizedBox(height: 16),
// Worker cards with risk badges
if (report.flaggedWorkers.isEmpty)
if (report.items.isEmpty)
Container(
padding: const EdgeInsets.all(40),
alignment: Alignment.center,
@@ -220,8 +220,8 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
),
)
else
...report.flaggedWorkers.map(
(NoShowWorker worker) => _WorkerCard(worker: worker),
...report.items.map(
(NoShowWorkerItem worker) => _WorkerCard(worker: worker),
),
const SizedBox(height: 40),
@@ -309,31 +309,31 @@ class _SummaryChip extends StatelessWidget {
class _WorkerCard extends StatelessWidget {
const _WorkerCard({required this.worker});
final NoShowWorker worker;
final NoShowWorkerItem worker;
String _riskLabel(BuildContext context, int count) {
if (count >= 3) return context.t.client_reports.no_show_report.risks.high;
if (count == 2) return context.t.client_reports.no_show_report.risks.medium;
String _riskLabel(BuildContext context, String riskStatus) {
if (riskStatus == 'HIGH') return context.t.client_reports.no_show_report.risks.high;
if (riskStatus == 'MEDIUM') return context.t.client_reports.no_show_report.risks.medium;
return context.t.client_reports.no_show_report.risks.low;
}
Color _riskColor(int count) {
if (count >= 3) return UiColors.error;
if (count == 2) return UiColors.textWarning;
Color _riskColor(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.error;
if (riskStatus == 'MEDIUM') return UiColors.textWarning;
return UiColors.success;
}
Color _riskBg(int count) {
if (count >= 3) return UiColors.tagError;
if (count == 2) return UiColors.tagPending;
Color _riskBg(String riskStatus) {
if (riskStatus == 'HIGH') return UiColors.tagError;
if (riskStatus == 'MEDIUM') return UiColors.tagPending;
return UiColors.tagSuccess;
}
@override
Widget build(BuildContext context) {
final String riskLabel = _riskLabel(context, worker.noShowCount);
final Color riskColor = _riskColor(worker.noShowCount);
final Color riskBg = _riskBg(worker.noShowCount);
final String riskLabel = _riskLabel(context, worker.riskStatus);
final Color riskColor = _riskColor(worker.riskStatus);
final Color riskBg = _riskBg(worker.riskStatus);
return Container(
margin: const EdgeInsets.only(bottom: 12),
@@ -373,7 +373,7 @@ class _WorkerCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
worker.fullName,
worker.staffName,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
@@ -381,7 +381,7 @@ class _WorkerCard extends StatelessWidget {
),
),
Text(
context.t.client_reports.no_show_report.no_show_count(count: worker.noShowCount.toString()),
context.t.client_reports.no_show_report.no_show_count(count: worker.incidentCount.toString()),
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
@@ -426,14 +426,10 @@ class _WorkerCard extends StatelessWidget {
),
),
Text(
// Use reliabilityScore as a proxy for last incident date offset
DateFormat('MMM dd, yyyy').format(
DateTime.now().subtract(
Duration(
days: ((1.0 - worker.reliabilityScore) * 60).round(),
),
),
),
worker.incidents.isNotEmpty
? DateFormat('MMM dd, yyyy')
.format(worker.incidents.first.date)
: '-',
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,

View File

@@ -9,7 +9,7 @@ import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
class PerformanceReportPage extends StatefulWidget {
const PerformanceReportPage({super.key});
@@ -41,14 +41,22 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
if (state is PerformanceLoaded) {
final PerformanceReport report = state.report;
// Compute overall score (0 - 100) from the 4 KPIs
final double overallScore = ((report.fillRate * 0.3) +
(report.completionRate * 0.3) +
(report.onTimeRate * 0.25) +
// avg fill time: 3h target invert to score
((report.avgFillTimeHours <= 3
// Convert V2 fields to local doubles for scoring.
final double fillRate = report.fillRatePercentage.toDouble();
final double completionRate =
report.completionRatePercentage.toDouble();
final double onTimeRate =
report.onTimeRatePercentage.toDouble();
final double avgFillTimeHours =
report.averageFillTimeMinutes / 60;
// Compute overall score (0 - 100) from the 4 KPIs.
final double overallScore = ((fillRate * 0.3) +
(completionRate * 0.3) +
(onTimeRate * 0.25) +
((avgFillTimeHours <= 3
? 100
: (3 / report.avgFillTimeHours) * 100) *
: (3 / avgFillTimeHours) * 100) *
0.15))
.clamp(0.0, 100.0);
@@ -75,48 +83,47 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
iconColor: UiColors.primary,
label: context.t.client_reports.performance_report.kpis.fill_rate,
target: context.t.client_reports.performance_report.kpis.target_percent(percent: '95'),
value: report.fillRate,
displayValue: '${report.fillRate.toStringAsFixed(0)}%',
value: fillRate,
displayValue: '${fillRate.toStringAsFixed(0)}%',
barColor: UiColors.primary,
met: report.fillRate >= 95,
close: report.fillRate >= 90,
met: fillRate >= 95,
close: fillRate >= 90,
),
_KpiData(
icon: UiIcons.checkCircle,
iconColor: UiColors.success,
label: context.t.client_reports.performance_report.kpis.completion_rate,
target: context.t.client_reports.performance_report.kpis.target_percent(percent: '98'),
value: report.completionRate,
displayValue: '${report.completionRate.toStringAsFixed(0)}%',
value: completionRate,
displayValue: '${completionRate.toStringAsFixed(0)}%',
barColor: UiColors.success,
met: report.completionRate >= 98,
close: report.completionRate >= 93,
met: completionRate >= 98,
close: completionRate >= 93,
),
_KpiData(
icon: UiIcons.clock,
iconColor: const Color(0xFF9B59B6),
label: context.t.client_reports.performance_report.kpis.on_time_rate,
target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'),
value: report.onTimeRate,
displayValue: '${report.onTimeRate.toStringAsFixed(0)}%',
value: onTimeRate,
displayValue: '${onTimeRate.toStringAsFixed(0)}%',
barColor: const Color(0xFF9B59B6),
met: report.onTimeRate >= 97,
close: report.onTimeRate >= 92,
met: onTimeRate >= 97,
close: onTimeRate >= 92,
),
_KpiData(
icon: UiIcons.trendingUp,
iconColor: const Color(0xFFF39C12),
label: context.t.client_reports.performance_report.kpis.avg_fill_time,
target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'),
// invert: lower is better show as % of target met
value: report.avgFillTimeHours == 0
value: avgFillTimeHours == 0
? 100
: (3 / report.avgFillTimeHours * 100).clamp(0, 100),
: (3 / avgFillTimeHours * 100).clamp(0, 100),
displayValue:
'${report.avgFillTimeHours.toStringAsFixed(1)} hrs',
'${avgFillTimeHours.toStringAsFixed(1)} hrs',
barColor: const Color(0xFFF39C12),
met: report.avgFillTimeHours <= 3,
close: report.avgFillTimeHours <= 4,
met: avgFillTimeHours <= 3,
close: avgFillTimeHours <= 4,
),
];

View File

@@ -4,7 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import '../widgets/reports_page/index.dart';
import 'package:client_reports/src/presentation/widgets/reports_page/index.dart';
/// The main Reports page for the client application.
///

View File

@@ -1,6 +1,7 @@
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart';
import 'package:client_reports/src/presentation/widgets/report_detail_skeleton.dart';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:fl_chart/fl_chart.dart';
@@ -11,9 +12,9 @@ import 'package:intl/intl.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/report_detail_skeleton.dart';
/// Page displaying the spend report with chart and category breakdown.
class SpendReportPage extends StatefulWidget {
/// Creates a [SpendReportPage].
const SpendReportPage({super.key});
@override
@@ -28,11 +29,11 @@ class _SpendReportPageState extends State<SpendReportPage> {
void initState() {
super.initState();
final DateTime now = DateTime.now();
// Monday alignment logic
final int diff = now.weekday - DateTime.monday;
final DateTime monday = now.subtract(Duration(days: diff));
_startDate = DateTime(monday.year, monday.month, monday.day);
_endDate = _startDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
_endDate = _startDate
.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
}
@override
@@ -53,6 +54,11 @@ class _SpendReportPageState extends State<SpendReportPage> {
if (state is SpendLoaded) {
final SpendReport report = state.report;
final double totalSpendDollars = report.totalSpendCents / 100;
final int dayCount =
report.chart.isNotEmpty ? report.chart.length : 1;
final double avgDailyDollars = totalSpendDollars / dayCount;
return SingleChildScrollView(
child: Column(
children: <Widget>[
@@ -62,124 +68,72 @@ class _SpendReportPageState extends State<SpendReportPage> {
top: 60,
left: 20,
right: 20,
bottom: 80, // Overlap space
bottom: 80,
),
decoration: const BoxDecoration(
color: UiColors.primary, // Blue background per prototype
color: UiColors.primary,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Row(
children: <Widget>[
GestureDetector(
onTap: () => Modular.to.popSafe(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_reports.spend_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.spend_report
.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
/*
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.t.client_reports.spend_report
.placeholders.export_message,
),
duration: const Duration(seconds: 2),
),
);
},
onTap: () => Modular.to.popSafe(),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
width: 40,
height: 40,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
color: UiColors.white.withOpacity(0.2),
shape: BoxShape.circle,
),
child: Row(
children: [
const Icon(
UiIcons.download,
size: 14,
color: UiColors.primary,
),
const SizedBox(width: 6),
Text(
context.t.client_reports.quick_reports
.export_all
.split(' ')
.first,
style: const TextStyle(
color: UiColors.primary,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
child: const Icon(
UiIcons.arrowLeft,
color: UiColors.white,
size: 20,
),
),
),
*/
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_reports.spend_report.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.spend_report.subtitle,
style: TextStyle(
fontSize: 12,
color: UiColors.white.withOpacity(0.7),
),
),
],
),
],
),
),
// Content
Transform.translate(
offset: const Offset(0, -60), // Pull up to overlap
offset: const Offset(0, -60),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Summary Cards (New Style)
// Summary Cards
Row(
children: <Widget>[
Expanded(
child: _SpendStatCard(
label: context.t.client_reports.spend_report
.summary.total_spend,
label: context.t.client_reports
.spend_report.summary.total_spend,
value: NumberFormat.currency(
symbol: r'$', decimalDigits: 0)
.format(report.totalSpend),
.format(totalSpendDollars),
pillText: context.t.client_reports
.spend_report.summary.this_week,
themeColor: UiColors.success,
@@ -189,11 +143,11 @@ class _SpendReportPageState extends State<SpendReportPage> {
const SizedBox(width: 12),
Expanded(
child: _SpendStatCard(
label: context.t.client_reports.spend_report
.summary.avg_daily,
label: context.t.client_reports
.spend_report.summary.avg_daily,
value: NumberFormat.currency(
symbol: r'$', decimalDigits: 0)
.format(report.averageCost),
.format(avgDailyDollars),
pillText: context.t.client_reports
.spend_report.summary.per_day,
themeColor: UiColors.primary,
@@ -223,7 +177,8 @@ class _SpendReportPageState extends State<SpendReportPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
context.t.client_reports.spend_report.chart_title,
context.t.client_reports.spend_report
.chart_title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
@@ -233,7 +188,7 @@ class _SpendReportPageState extends State<SpendReportPage> {
const SizedBox(height: 32),
Expanded(
child: _SpendBarChart(
chartData: report.chartData),
chartData: report.chart),
),
],
),
@@ -241,9 +196,9 @@ class _SpendReportPageState extends State<SpendReportPage> {
const SizedBox(height: 24),
// Spend by Industry
_SpendByIndustryCard(
industries: report.industryBreakdown,
// Spend by Category
_SpendByCategoryCard(
categories: report.breakdown,
),
const SizedBox(height: 100),
@@ -263,25 +218,31 @@ class _SpendReportPageState extends State<SpendReportPage> {
}
}
/// Bar chart rendering [SpendDataPoint] entries (cents converted to dollars).
class _SpendBarChart extends StatelessWidget {
const _SpendBarChart({required this.chartData});
final List<dynamic> chartData;
final List<SpendDataPoint> chartData;
@override
Widget build(BuildContext context) {
if (chartData.isEmpty) return const SizedBox();
final double maxDollars = chartData.fold<double>(
0,
(double prev, SpendDataPoint p) =>
(p.amountCents / 100) > prev ? p.amountCents / 100 : prev) *
1.2;
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (chartData.fold<double>(0,
(double prev, element) =>
element.amount > prev ? element.amount : prev) *
1.2)
.ceilToDouble(),
maxY: maxDollars.ceilToDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
getTooltipItem: (BarChartGroupData group, int groupIndex, BarChartRodData rod, int rodIndex) {
getTooltipItem: (BarChartGroupData group, int groupIndex,
BarChartRodData rod, int rodIndex) {
return BarTooltipItem(
'\$${rod.toY.round()}',
const TextStyle(
@@ -299,8 +260,10 @@ class _SpendBarChart extends StatelessWidget {
showTitles: true,
reservedSize: 30,
getTitlesWidget: (double value, TitleMeta meta) {
if (value.toInt() >= chartData.length) return const SizedBox();
final date = chartData[value.toInt()].date;
if (value.toInt() >= chartData.length) {
return const SizedBox();
}
final DateTime date = chartData[value.toInt()].bucket;
return SideTitleWidget(
axisSide: meta.axisSide,
space: 8,
@@ -334,12 +297,10 @@ class _SpendBarChart extends StatelessWidget {
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles:
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
gridData: FlGridData(
show: true,
@@ -351,13 +312,13 @@ class _SpendBarChart extends StatelessWidget {
),
),
borderData: FlBorderData(show: false),
barGroups: List.generate(
barGroups: List<BarChartGroupData>.generate(
chartData.length,
(int index) => BarChartGroupData(
x: index,
barRods: <BarChartRodData>[
BarChartRodData(
toY: chartData[index].amount,
toY: chartData[index].amountCents / 100,
color: UiColors.success,
width: 12,
borderRadius: const BorderRadius.vertical(
@@ -373,7 +334,6 @@ class _SpendBarChart extends StatelessWidget {
}
class _SpendStatCard extends StatelessWidget {
const _SpendStatCard({
required this.label,
required this.value,
@@ -381,6 +341,7 @@ class _SpendStatCard extends StatelessWidget {
required this.themeColor,
required this.icon,
});
final String label;
final String value;
final String pillText;
@@ -454,10 +415,11 @@ class _SpendStatCard extends StatelessWidget {
}
}
class _SpendByIndustryCard extends StatelessWidget {
/// Card showing spend breakdown by category using [SpendItem].
class _SpendByCategoryCard extends StatelessWidget {
const _SpendByCategoryCard({required this.categories});
const _SpendByIndustryCard({required this.industries});
final List<SpendIndustryCategory> industries;
final List<SpendItem> categories;
@override
Widget build(BuildContext context) {
@@ -486,7 +448,7 @@ class _SpendByIndustryCard extends StatelessWidget {
),
),
const SizedBox(height: 24),
if (industries.isEmpty)
if (categories.isEmpty)
Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
@@ -497,7 +459,7 @@ class _SpendByIndustryCard extends StatelessWidget {
),
)
else
...industries.map((SpendIndustryCategory ind) => Padding(
...categories.map((SpendItem item) => Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -506,15 +468,16 @@ class _SpendByIndustryCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
ind.name,
item.category,
style: const TextStyle(
fontSize: 13,
color: UiColors.textSecondary,
),
),
Text(
NumberFormat.currency(symbol: r'$', decimalDigits: 0)
.format(ind.amount),
NumberFormat.currency(
symbol: r'$', decimalDigits: 0)
.format(item.amountCents / 100),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
@@ -527,7 +490,7 @@ class _SpendByIndustryCard extends StatelessWidget {
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: ind.percentage / 100,
value: item.percentage / 100,
backgroundColor: UiColors.bgSecondary,
color: UiColors.success,
minHeight: 6,
@@ -535,7 +498,8 @@ class _SpendByIndustryCard extends StatelessWidget {
),
const SizedBox(height: 6),
Text(
context.t.client_reports.spend_report.percent_total(percent: ind.percentage.toStringAsFixed(1)),
context.t.client_reports.spend_report.percent_total(
percent: item.percentage.toStringAsFixed(1)),
style: const TextStyle(
fontSize: 10,
color: UiColors.textDescription,
@@ -549,4 +513,3 @@ class _SpendByIndustryCard extends StatelessWidget {
);
}
}

View File

@@ -7,21 +7,22 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import 'package:krow_domain/krow_domain.dart';
import 'metric_card.dart';
import 'metrics_grid_skeleton.dart';
import 'package:client_reports/src/presentation/widgets/reports_page/metric_card.dart';
import 'package:client_reports/src/presentation/widgets/reports_page/metrics_grid_skeleton.dart';
/// A grid of key metrics driven by the ReportsSummaryBloc.
/// A grid of key metrics driven by the [ReportsSummaryBloc].
///
/// Displays 6 metrics in a 2-column grid:
/// - Total Hours
/// - OT Hours
/// - Total Spend
/// - Fill Rate
/// - Average Fill Time
/// - No-Show Rate
/// - Total Shifts
/// - Total Spend (from cents)
/// - Avg Coverage %
/// - Performance Score
/// - No-Show Count
/// - Forecast Accuracy %
///
/// Handles loading, error, and success states.
class MetricsGrid extends StatelessWidget {
/// Creates a [MetricsGrid].
const MetricsGrid({super.key});
@override
@@ -48,7 +49,8 @@ class MetricsGrid extends StatelessWidget {
Expanded(
child: Text(
state.message,
style: const TextStyle(color: UiColors.error, fontSize: 12),
style:
const TextStyle(color: UiColors.error, fontSize: 12),
),
),
],
@@ -57,9 +59,11 @@ class MetricsGrid extends StatelessWidget {
}
// Loaded State
final ReportsSummary summary = (state as ReportsSummaryLoaded).summary;
final ReportSummary summary =
(state as ReportsSummaryLoaded).summary;
final NumberFormat currencyFmt =
NumberFormat.currency(symbol: '\$', decimalDigits: 0);
NumberFormat.currency(symbol: r'$', decimalDigits: 0);
final double totalSpendDollars = summary.totalSpendCents / 100;
return GridView.count(
padding: const EdgeInsets.symmetric(
@@ -72,70 +76,70 @@ class MetricsGrid extends StatelessWidget {
crossAxisSpacing: 12,
childAspectRatio: 1.32,
children: <Widget>[
// Total Hour
// Total Shifts
MetricCard(
icon: UiIcons.clock,
label: context.t.client_reports.metrics.total_hrs.label,
value: summary.totalHours >= 1000
? '${(summary.totalHours / 1000).toStringAsFixed(1)}k'
: summary.totalHours.toStringAsFixed(0),
value: summary.totalShifts >= 1000
? '${(summary.totalShifts / 1000).toStringAsFixed(1)}k'
: summary.totalShifts.toString(),
badgeText: context.t.client_reports.metrics.total_hrs.badge,
badgeColor: UiColors.tagRefunded,
badgeTextColor: UiColors.primary,
iconColor: UiColors.primary,
),
// OT Hours
// Coverage %
MetricCard(
icon: UiIcons.trendingUp,
label: context.t.client_reports.metrics.ot_hours.label,
value: summary.otHours.toStringAsFixed(0),
value: '${summary.averageCoveragePercentage}%',
badgeText: context.t.client_reports.metrics.ot_hours.badge,
badgeColor: UiColors.tagValue,
badgeTextColor: UiColors.textSecondary,
iconColor: UiColors.textWarning,
),
// Total Spend
// Total Spend (from cents)
MetricCard(
icon: UiIcons.dollar,
label: context.t.client_reports.metrics.total_spend.label,
value: summary.totalSpend >= 1000
? '\$${(summary.totalSpend / 1000).toStringAsFixed(1)}k'
: currencyFmt.format(summary.totalSpend),
value: totalSpendDollars >= 1000
? '\$${(totalSpendDollars / 1000).toStringAsFixed(1)}k'
: currencyFmt.format(totalSpendDollars),
badgeText: context.t.client_reports.metrics.total_spend.badge,
badgeColor: UiColors.tagSuccess,
badgeTextColor: UiColors.textSuccess,
iconColor: UiColors.success,
),
// Fill Rate
// Performance Score
MetricCard(
icon: UiIcons.trendingUp,
label: context.t.client_reports.metrics.fill_rate.label,
value: '${summary.fillRate.toStringAsFixed(0)}%',
value: summary.averagePerformanceScore.toStringAsFixed(1),
badgeText: context.t.client_reports.metrics.fill_rate.badge,
badgeColor: UiColors.tagInProgress,
badgeTextColor: UiColors.textLink,
iconColor: UiColors.iconActive,
),
// Average Fill Time
// Forecast Accuracy %
MetricCard(
icon: UiIcons.clock,
label: context.t.client_reports.metrics.avg_fill_time.label,
value: '${summary.avgFillTimeHours.toStringAsFixed(1)} hrs',
value: '${summary.forecastAccuracyPercentage}%',
badgeText: context.t.client_reports.metrics.avg_fill_time.badge,
badgeColor: UiColors.tagInProgress,
badgeTextColor: UiColors.textLink,
iconColor: UiColors.iconActive,
),
// No-Show Rate
// No-Show Count
MetricCard(
icon: UiIcons.warning,
label: context.t.client_reports.metrics.no_show_rate.label,
value: '${summary.noShowRate.toStringAsFixed(1)}%',
value: summary.noShowCount.toString(),
badgeText: context.t.client_reports.metrics.no_show_rate.badge,
badgeColor: summary.noShowRate < 5
badgeColor: summary.noShowCount < 5
? UiColors.tagSuccess
: UiColors.tagError,
badgeTextColor: summary.noShowRate < 5
badgeTextColor: summary.noShowCount < 5
? UiColors.textSuccess
: UiColors.error,
iconColor: UiColors.destructive,

View File

@@ -1,30 +1,33 @@
// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs
import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart';
import 'package:client_reports/src/domain/repositories/reports_repository.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart';
import 'package:client_reports/src/presentation/pages/coverage_report_page.dart';
import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart';
import 'package:client_reports/src/presentation/pages/forecast_report_page.dart';
import 'package:client_reports/src/presentation/pages/no_show_report_page.dart';
import 'package:client_reports/src/presentation/pages/performance_report_page.dart';
import 'package:client_reports/src/presentation/pages/reports_page.dart';
import 'package:client_reports/src/presentation/pages/spend_report_page.dart';
import 'package:client_reports/src/presentation/pages/coverage_report_page.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
/// Feature module for the client reports section.
class ReportsModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
List<Module> get imports => <Module>[CoreModule()];
@override
void binds(Injector i) {
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
i.addLazySingleton<ReportsRepository>(
() => ReportsRepositoryImpl(apiService: i.get<BaseApiService>()),
);
i.add<DailyOpsBloc>(DailyOpsBloc.new);
i.add<SpendBloc>(SpendBloc.new);
i.add<CoverageBloc>(CoverageBloc.new);
@@ -45,4 +48,3 @@ class ReportsModule extends Module {
r.child('/no-show', child: (_) => const NoShowReportPage());
}
}