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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user