feat: architecture overhaul, launchpad-style reports, and uber-style locations
- Strengthened Buffer Layer architecture to decouple Data Connect from Domain - Rewired Coverage, Performance, and Forecast reports to match Launchpad logic - Implemented Uber-style Preferred Locations search using Google Places API - Added session recovery logic to prevent crashes on app restart - Synchronized backend schemas & SDK for ShiftStatus enums - Fixed various build/compilation errors and localization duplicates
This commit is contained in:
@@ -1,493 +1,89 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
import '../../domain/entities/daily_ops_report.dart';
|
||||
import '../../domain/entities/spend_report.dart';
|
||||
import '../../domain/entities/coverage_report.dart';
|
||||
import '../../domain/entities/forecast_report.dart';
|
||||
import '../../domain/entities/performance_report.dart';
|
||||
import '../../domain/entities/no_show_report.dart';
|
||||
import '../../domain/entities/reports_summary.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/reports_repository.dart';
|
||||
|
||||
/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class ReportsRepositoryImpl implements ReportsRepository {
|
||||
final DataConnectService _service;
|
||||
final ReportsConnectorRepository _connectorRepository;
|
||||
|
||||
ReportsRepositoryImpl({DataConnectService? service})
|
||||
: _service = service ?? DataConnectService.instance;
|
||||
ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository})
|
||||
: _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository();
|
||||
|
||||
@override
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listShiftsForDailyOpsByBusiness(
|
||||
businessId: id,
|
||||
date: _service.toTimestamp(date),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shifts = response.data.shifts;
|
||||
|
||||
int scheduledShifts = shifts.length;
|
||||
int workersConfirmed = 0;
|
||||
int inProgressShifts = 0;
|
||||
int completedShifts = 0;
|
||||
|
||||
final List<DailyOpsShift> dailyOpsShifts = [];
|
||||
|
||||
for (final shift in shifts) {
|
||||
workersConfirmed += shift.filled ?? 0;
|
||||
final statusStr = shift.status?.stringValue ?? '';
|
||||
if (statusStr == 'IN_PROGRESS') inProgressShifts++;
|
||||
if (statusStr == 'COMPLETED') completedShifts++;
|
||||
|
||||
dailyOpsShifts.add(DailyOpsShift(
|
||||
id: shift.id,
|
||||
title: shift.title ?? '',
|
||||
location: shift.location ?? '',
|
||||
startTime: shift.startTime?.toDateTime() ?? DateTime.now(),
|
||||
endTime: shift.endTime?.toDateTime() ?? DateTime.now(),
|
||||
workersNeeded: shift.workersNeeded ?? 0,
|
||||
filled: shift.filled ?? 0,
|
||||
status: statusStr,
|
||||
));
|
||||
}
|
||||
|
||||
return DailyOpsReport(
|
||||
scheduledShifts: scheduledShifts,
|
||||
workersConfirmed: workersConfirmed,
|
||||
inProgressShifts: inProgressShifts,
|
||||
completedShifts: completedShifts,
|
||||
shifts: dailyOpsShifts,
|
||||
}) => _connectorRepository.getDailyOpsReport(
|
||||
businessId: businessId,
|
||||
date: date,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final invoices = response.data.invoices;
|
||||
|
||||
double totalSpend = 0.0;
|
||||
int paidInvoices = 0;
|
||||
int pendingInvoices = 0;
|
||||
int overdueInvoices = 0;
|
||||
|
||||
final List<SpendInvoice> spendInvoices = [];
|
||||
final Map<DateTime, double> dailyAggregates = {};
|
||||
final Map<String, double> industryAggregates = {};
|
||||
|
||||
for (final inv in invoices) {
|
||||
final amount = (inv.amount ?? 0.0).toDouble();
|
||||
totalSpend += amount;
|
||||
|
||||
final statusStr = inv.status.stringValue;
|
||||
if (statusStr == 'PAID') {
|
||||
paidInvoices++;
|
||||
} else if (statusStr == 'PENDING') {
|
||||
pendingInvoices++;
|
||||
} else if (statusStr == 'OVERDUE') {
|
||||
overdueInvoices++;
|
||||
}
|
||||
|
||||
final industry = inv.vendor?.serviceSpecialty ?? 'Other';
|
||||
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
|
||||
|
||||
final issueDateTime = inv.issueDate.toDateTime();
|
||||
spendInvoices.add(SpendInvoice(
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber ?? '',
|
||||
issueDate: issueDateTime,
|
||||
amount: amount,
|
||||
status: statusStr,
|
||||
vendorName: inv.vendor?.companyName ?? 'Unknown',
|
||||
industry: industry,
|
||||
));
|
||||
|
||||
// Chart data aggregation
|
||||
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
|
||||
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
|
||||
}
|
||||
|
||||
// Ensure chart data covers all days in range
|
||||
final Map<DateTime, double> completeDailyAggregates = {};
|
||||
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
final normalizedDate = DateTime(date.year, date.month, date.day);
|
||||
completeDailyAggregates[normalizedDate] =
|
||||
dailyAggregates[normalizedDate] ?? 0.0;
|
||||
}
|
||||
|
||||
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
|
||||
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||
.toList()
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
|
||||
.map((e) => SpendIndustryCategory(
|
||||
name: e.key,
|
||||
amount: e.value,
|
||||
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
final daysCount = endDate.difference(startDate).inDays + 1;
|
||||
|
||||
return SpendReport(
|
||||
totalSpend: totalSpend,
|
||||
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
|
||||
paidInvoices: paidInvoices,
|
||||
pendingInvoices: pendingInvoices,
|
||||
overdueInvoices: overdueInvoices,
|
||||
invoices: spendInvoices,
|
||||
chartData: chartData,
|
||||
industryBreakdown: industryBreakdown,
|
||||
}) => _connectorRepository.getSpendReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listShiftsForCoverage(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shifts = response.data.shifts;
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
final Map<DateTime, (int, int)> dailyStats = {};
|
||||
|
||||
for (final shift in shifts) {
|
||||
final shiftDate = shift.date?.toDateTime() ?? DateTime.now();
|
||||
final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
|
||||
|
||||
final needed = shift.workersNeeded ?? 0;
|
||||
final filled = shift.filled ?? 0;
|
||||
|
||||
totalNeeded += needed;
|
||||
totalFilled += filled;
|
||||
|
||||
final current = dailyStats[date] ?? (0, 0);
|
||||
dailyStats[date] = (current.$1 + needed, current.$2 + filled);
|
||||
}
|
||||
|
||||
final List<CoverageDay> dailyCoverage = dailyStats.entries.map((e) {
|
||||
final needed = e.value.$1;
|
||||
final filled = e.value.$2;
|
||||
return CoverageDay(
|
||||
date: e.key,
|
||||
needed: needed,
|
||||
filled: filled,
|
||||
percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
return CoverageReport(
|
||||
overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0,
|
||||
totalNeeded: totalNeeded,
|
||||
totalFilled: totalFilled,
|
||||
dailyCoverage: dailyCoverage,
|
||||
}) => _connectorRepository.getCoverageReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ForecastReport> getForecastReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listShiftsForForecastByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shifts = response.data.shifts;
|
||||
|
||||
double projectedSpend = 0.0;
|
||||
int projectedWorkers = 0;
|
||||
final Map<DateTime, (double, int)> dailyStats = {};
|
||||
|
||||
for (final shift in shifts) {
|
||||
final shiftDate = shift.date?.toDateTime() ?? DateTime.now();
|
||||
final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day);
|
||||
|
||||
final cost = (shift.cost ?? 0.0).toDouble();
|
||||
final workers = shift.workersNeeded ?? 0;
|
||||
|
||||
projectedSpend += cost;
|
||||
projectedWorkers += workers;
|
||||
|
||||
final current = dailyStats[date] ?? (0.0, 0);
|
||||
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
|
||||
}
|
||||
|
||||
final List<ForecastPoint> chartData = dailyStats.entries.map((e) {
|
||||
return ForecastPoint(
|
||||
date: e.key,
|
||||
projectedCost: e.value.$1,
|
||||
workersNeeded: e.value.$2,
|
||||
);
|
||||
}).toList()..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
return ForecastReport(
|
||||
projectedSpend: projectedSpend,
|
||||
projectedWorkers: projectedWorkers,
|
||||
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
|
||||
chartData: chartData,
|
||||
}) => _connectorRepository.getForecastReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listShiftsForPerformanceByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shifts = response.data.shifts;
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
int completedCount = 0;
|
||||
double totalFillTimeSeconds = 0.0;
|
||||
int filledShiftsWithTime = 0;
|
||||
|
||||
for (final shift in shifts) {
|
||||
totalNeeded += shift.workersNeeded ?? 0;
|
||||
totalFilled += shift.filled ?? 0;
|
||||
if ((shift.status?.stringValue ?? '') == 'COMPLETED') {
|
||||
completedCount++;
|
||||
}
|
||||
|
||||
if (shift.filledAt != null && shift.createdAt != null) {
|
||||
final createdAt = shift.createdAt!.toDateTime();
|
||||
final filledAt = shift.filledAt!.toDateTime();
|
||||
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
|
||||
filledShiftsWithTime++;
|
||||
}
|
||||
}
|
||||
|
||||
final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0;
|
||||
final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0;
|
||||
final double avgFillTimeHours = filledShiftsWithTime == 0
|
||||
? 0
|
||||
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600;
|
||||
|
||||
return PerformanceReport(
|
||||
fillRate: fillRate,
|
||||
completionRate: completionRate,
|
||||
onTimeRate: 95.0,
|
||||
avgFillTimeHours: avgFillTimeHours,
|
||||
keyPerformanceIndicators: [
|
||||
PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02),
|
||||
PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05),
|
||||
PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1),
|
||||
],
|
||||
}) => _connectorRepository.getPerformanceReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NoShowReport> getNoShowReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
|
||||
final shiftsResponse = await _service.connector
|
||||
.listShiftsForNoShowRangeByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shiftIds = shiftsResponse.data.shifts.map((s) => s.id).toList();
|
||||
if (shiftIds.isEmpty) {
|
||||
return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []);
|
||||
}
|
||||
|
||||
final appsResponse = await _service.connector
|
||||
.listApplicationsForNoShowRange(shiftIds: shiftIds)
|
||||
.execute();
|
||||
|
||||
final apps = appsResponse.data.applications;
|
||||
final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList();
|
||||
final noShowStaffIds = noShowApps.map((a) => a.staffId).toSet().toList();
|
||||
|
||||
if (noShowStaffIds.isEmpty) {
|
||||
return NoShowReport(
|
||||
totalNoShows: noShowApps.length,
|
||||
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
|
||||
flaggedWorkers: [],
|
||||
);
|
||||
}
|
||||
|
||||
final staffResponse = await _service.connector
|
||||
.listStaffForNoShowReport(staffIds: noShowStaffIds)
|
||||
.execute();
|
||||
|
||||
final staffList = staffResponse.data.staffs;
|
||||
|
||||
final List<NoShowWorker> flaggedWorkers = staffList.map((s) => NoShowWorker(
|
||||
id: s.id,
|
||||
fullName: s.fullName ?? '',
|
||||
noShowCount: s.noShowCount ?? 0,
|
||||
reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(),
|
||||
)).toList();
|
||||
|
||||
return NoShowReport(
|
||||
totalNoShows: noShowApps.length,
|
||||
noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0,
|
||||
flaggedWorkers: flaggedWorkers,
|
||||
}) => _connectorRepository.getNoShowReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ReportsSummary> getReportsSummary({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
|
||||
// Use forecast query for hours/cost data
|
||||
final shiftsResponse = await _service.connector
|
||||
.listShiftsForForecastByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// Use performance query for avgFillTime (has filledAt + createdAt)
|
||||
final perfResponse = await _service.connector
|
||||
.listShiftsForPerformanceByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final invoicesResponse = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final forecastShifts = shiftsResponse.data.shifts;
|
||||
final perfShifts = perfResponse.data.shifts;
|
||||
final invoices = invoicesResponse.data.invoices;
|
||||
|
||||
// Aggregate hours and fill rate from forecast shifts
|
||||
double totalHours = 0;
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
|
||||
for (final shift in forecastShifts) {
|
||||
totalHours += (shift.hours ?? 0).toDouble();
|
||||
totalNeeded += shift.workersNeeded ?? 0;
|
||||
// Forecast query doesn't have 'filled' — use workersNeeded as proxy
|
||||
// (fill rate will be computed from performance shifts below)
|
||||
}
|
||||
|
||||
// Aggregate fill rate from performance shifts (has 'filled' field)
|
||||
int perfNeeded = 0;
|
||||
int perfFilled = 0;
|
||||
double totalFillTimeSeconds = 0;
|
||||
int filledShiftsWithTime = 0;
|
||||
|
||||
for (final shift in perfShifts) {
|
||||
perfNeeded += shift.workersNeeded ?? 0;
|
||||
perfFilled += shift.filled ?? 0;
|
||||
|
||||
if (shift.filledAt != null && shift.createdAt != null) {
|
||||
final createdAt = shift.createdAt!.toDateTime();
|
||||
final filledAt = shift.filledAt!.toDateTime();
|
||||
totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds;
|
||||
filledShiftsWithTime++;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate total spend from invoices
|
||||
double totalSpend = 0;
|
||||
for (final inv in invoices) {
|
||||
totalSpend += (inv.amount ?? 0).toDouble();
|
||||
}
|
||||
|
||||
// Fetch no-show rate using forecast shift IDs
|
||||
final shiftIds = forecastShifts.map((s) => s.id).toList();
|
||||
double noShowRate = 0;
|
||||
if (shiftIds.isNotEmpty) {
|
||||
final appsResponse = await _service.connector
|
||||
.listApplicationsForNoShowRange(shiftIds: shiftIds)
|
||||
.execute();
|
||||
final apps = appsResponse.data.applications;
|
||||
final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList();
|
||||
noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0;
|
||||
}
|
||||
|
||||
final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0;
|
||||
|
||||
return ReportsSummary(
|
||||
totalHours: totalHours,
|
||||
otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it
|
||||
totalSpend: totalSpend,
|
||||
fillRate: fillRate,
|
||||
avgFillTimeHours: filledShiftsWithTime == 0
|
||||
? 0
|
||||
: (totalFillTimeSeconds / filledShiftsWithTime) / 3600,
|
||||
noShowRate: noShowRate,
|
||||
}) => _connectorRepository.getReportsSummary(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CoverageReport extends Equatable {
|
||||
final double overallCoverage;
|
||||
final int totalNeeded;
|
||||
final int totalFilled;
|
||||
final List<CoverageDay> dailyCoverage;
|
||||
|
||||
const CoverageReport({
|
||||
required this.overallCoverage,
|
||||
required this.totalNeeded,
|
||||
required this.totalFilled,
|
||||
required this.dailyCoverage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [overallCoverage, totalNeeded, totalFilled, dailyCoverage];
|
||||
}
|
||||
|
||||
class CoverageDay extends Equatable {
|
||||
final DateTime date;
|
||||
final int needed;
|
||||
final int filled;
|
||||
final double percentage;
|
||||
|
||||
const CoverageDay({
|
||||
required this.date,
|
||||
required this.needed,
|
||||
required this.filled,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date, needed, filled, percentage];
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class DailyOpsReport extends Equatable {
|
||||
final int scheduledShifts;
|
||||
final int workersConfirmed;
|
||||
final int inProgressShifts;
|
||||
final int completedShifts;
|
||||
final List<DailyOpsShift> shifts;
|
||||
|
||||
const DailyOpsReport({
|
||||
required this.scheduledShifts,
|
||||
required this.workersConfirmed,
|
||||
required this.inProgressShifts,
|
||||
required this.completedShifts,
|
||||
required this.shifts,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
scheduledShifts,
|
||||
workersConfirmed,
|
||||
inProgressShifts,
|
||||
completedShifts,
|
||||
shifts,
|
||||
];
|
||||
}
|
||||
|
||||
class DailyOpsShift extends Equatable {
|
||||
final String id;
|
||||
final String title;
|
||||
final String location;
|
||||
final DateTime startTime;
|
||||
final DateTime endTime;
|
||||
final int workersNeeded;
|
||||
final int filled;
|
||||
final String status;
|
||||
final double? hourlyRate;
|
||||
|
||||
const DailyOpsShift({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.endTime,
|
||||
required this.workersNeeded,
|
||||
required this.filled,
|
||||
required this.status,
|
||||
this.hourlyRate,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
location,
|
||||
startTime,
|
||||
endTime,
|
||||
workersNeeded,
|
||||
filled,
|
||||
status,
|
||||
hourlyRate,
|
||||
];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ForecastReport extends Equatable {
|
||||
final double projectedSpend;
|
||||
final int projectedWorkers;
|
||||
final double averageLaborCost;
|
||||
final List<ForecastPoint> chartData;
|
||||
|
||||
const ForecastReport({
|
||||
required this.projectedSpend,
|
||||
required this.projectedWorkers,
|
||||
required this.averageLaborCost,
|
||||
required this.chartData,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [projectedSpend, projectedWorkers, averageLaborCost, chartData];
|
||||
}
|
||||
|
||||
class ForecastPoint extends Equatable {
|
||||
final DateTime date;
|
||||
final double projectedCost;
|
||||
final int workersNeeded;
|
||||
|
||||
const ForecastPoint({
|
||||
required this.date,
|
||||
required this.projectedCost,
|
||||
required this.workersNeeded,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date, projectedCost, workersNeeded];
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class NoShowReport extends Equatable {
|
||||
final int totalNoShows;
|
||||
final double noShowRate;
|
||||
final List<NoShowWorker> flaggedWorkers;
|
||||
|
||||
const NoShowReport({
|
||||
required this.totalNoShows,
|
||||
required this.noShowRate,
|
||||
required this.flaggedWorkers,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [totalNoShows, noShowRate, flaggedWorkers];
|
||||
}
|
||||
|
||||
class NoShowWorker extends Equatable {
|
||||
final String id;
|
||||
final String fullName;
|
||||
final int noShowCount;
|
||||
final double reliabilityScore;
|
||||
|
||||
const NoShowWorker({
|
||||
required this.id,
|
||||
required this.fullName,
|
||||
required this.noShowCount,
|
||||
required this.reliabilityScore,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, fullName, noShowCount, reliabilityScore];
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class PerformanceReport extends Equatable {
|
||||
final double fillRate;
|
||||
final double completionRate;
|
||||
final double onTimeRate;
|
||||
final double avgFillTimeHours; // in hours
|
||||
final List<PerformanceMetric> keyPerformanceIndicators;
|
||||
|
||||
const PerformanceReport({
|
||||
required this.fillRate,
|
||||
required this.completionRate,
|
||||
required this.onTimeRate,
|
||||
required this.avgFillTimeHours,
|
||||
required this.keyPerformanceIndicators,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [fillRate, completionRate, onTimeRate, avgFillTimeHours, keyPerformanceIndicators];
|
||||
}
|
||||
|
||||
class PerformanceMetric extends Equatable {
|
||||
final String label;
|
||||
final String value;
|
||||
final double trend; // e.g. 0.05 for +5%
|
||||
|
||||
const PerformanceMetric({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.trend,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [label, value, trend];
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ReportsSummary extends Equatable {
|
||||
final double totalHours;
|
||||
final double otHours;
|
||||
final double totalSpend;
|
||||
final double fillRate;
|
||||
final double avgFillTimeHours;
|
||||
final double noShowRate;
|
||||
|
||||
const ReportsSummary({
|
||||
required this.totalHours,
|
||||
required this.otHours,
|
||||
required this.totalSpend,
|
||||
required this.fillRate,
|
||||
required this.avgFillTimeHours,
|
||||
required this.noShowRate,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
totalHours,
|
||||
otHours,
|
||||
totalSpend,
|
||||
fillRate,
|
||||
avgFillTimeHours,
|
||||
noShowRate,
|
||||
];
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class SpendReport extends Equatable {
|
||||
final double totalSpend;
|
||||
final double averageCost;
|
||||
final int paidInvoices;
|
||||
final int pendingInvoices;
|
||||
final int overdueInvoices;
|
||||
final List<SpendInvoice> invoices;
|
||||
final List<SpendChartPoint> chartData;
|
||||
|
||||
const SpendReport({
|
||||
required this.totalSpend,
|
||||
required this.averageCost,
|
||||
required this.paidInvoices,
|
||||
required this.pendingInvoices,
|
||||
required this.overdueInvoices,
|
||||
required this.invoices,
|
||||
required this.chartData,
|
||||
required this.industryBreakdown,
|
||||
});
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
totalSpend,
|
||||
averageCost,
|
||||
paidInvoices,
|
||||
pendingInvoices,
|
||||
overdueInvoices,
|
||||
invoices,
|
||||
chartData,
|
||||
industryBreakdown,
|
||||
];
|
||||
}
|
||||
|
||||
class SpendIndustryCategory extends Equatable {
|
||||
final String name;
|
||||
final double amount;
|
||||
final double percentage;
|
||||
|
||||
const SpendIndustryCategory({
|
||||
required this.name,
|
||||
required this.amount,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, amount, percentage];
|
||||
}
|
||||
|
||||
class SpendInvoice extends Equatable {
|
||||
final String id;
|
||||
final String invoiceNumber;
|
||||
final DateTime issueDate;
|
||||
final double amount;
|
||||
final String status;
|
||||
final String vendorName;
|
||||
|
||||
const SpendInvoice({
|
||||
required this.id,
|
||||
required this.invoiceNumber,
|
||||
required this.issueDate,
|
||||
required this.amount,
|
||||
required this.status,
|
||||
required this.vendorName,
|
||||
this.industry,
|
||||
});
|
||||
|
||||
final String? industry;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry];
|
||||
}
|
||||
|
||||
class SpendChartPoint extends Equatable {
|
||||
final DateTime date;
|
||||
final double amount;
|
||||
|
||||
const SpendChartPoint({required this.date, required this.amount});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [date, amount];
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
import '../entities/daily_ops_report.dart';
|
||||
import '../entities/spend_report.dart';
|
||||
import '../entities/coverage_report.dart';
|
||||
import '../entities/forecast_report.dart';
|
||||
import '../entities/performance_report.dart';
|
||||
import '../entities/no_show_report.dart';
|
||||
import '../entities/reports_summary.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ReportsRepository {
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/daily_ops_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class DailyOpsState extends Equatable {
|
||||
const DailyOpsState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/forecast_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ForecastState extends Equatable {
|
||||
const ForecastState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/no_show_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class NoShowState extends Equatable {
|
||||
const NoShowState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/performance_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class PerformanceState extends Equatable {
|
||||
const PerformanceState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/spend_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class SpendState extends Equatable {
|
||||
const SpendState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/reports_summary.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ReportsSummaryState extends Equatable {
|
||||
const ReportsSummaryState();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.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:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class CoverageReportPage extends StatefulWidget {
|
||||
const CoverageReportPage({super.key});
|
||||
|
||||
@override
|
||||
State<CoverageReportPage> createState() => _CoverageReportPageState();
|
||||
}
|
||||
|
||||
class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
final DateTime _startDate = DateTime.now();
|
||||
final DateTime _endDate = DateTime.now().add(const Duration(days: 14));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<CoverageBloc>()
|
||||
..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<CoverageBloc, CoverageState>(
|
||||
builder: (context, state) {
|
||||
if (state is CoverageLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (state is CoverageError) {
|
||||
return Center(child: Text(state.message));
|
||||
}
|
||||
|
||||
if (state is CoverageLoaded) {
|
||||
final report = state.report;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 32,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, UiColors.tagInProgress],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
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: [
|
||||
Text(
|
||||
context.t.client_reports.coverage_report.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.coverage_report
|
||||
.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Summary Cards
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _CoverageSummaryCard(
|
||||
label: context.t.client_reports.coverage_report.metrics.avg_coverage,
|
||||
value: '${report.overallCoverage.toStringAsFixed(1)}%',
|
||||
icon: UiIcons.chart,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _CoverageSummaryCard(
|
||||
label: context.t.client_reports.coverage_report.metrics.full,
|
||||
value: '${report.totalFilled}/${report.totalNeeded}',
|
||||
icon: UiIcons.users,
|
||||
color: UiColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Daily List
|
||||
Text(
|
||||
context.t.client_reports.coverage_report.next_7_days,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (report.dailyCoverage.isEmpty)
|
||||
Center(child: Text(context.t.client_reports.coverage_report.empty_state))
|
||||
else
|
||||
...report.dailyCoverage.map((day) => _CoverageListItem(
|
||||
date: DateFormat('EEE, MMM dd').format(day.date),
|
||||
needed: day.needed,
|
||||
filled: day.filled,
|
||||
percentage: day.percentage,
|
||||
)),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageSummaryCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _CoverageSummaryCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageListItem extends StatelessWidget {
|
||||
final String date;
|
||||
final int needed;
|
||||
final int filled;
|
||||
final double percentage;
|
||||
|
||||
const _CoverageListItem({
|
||||
required this.date,
|
||||
required this.needed,
|
||||
required this.filled,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color statusColor;
|
||||
if (percentage >= 100) {
|
||||
statusColor = UiColors.success;
|
||||
} else if (percentage >= 80) {
|
||||
statusColor = UiColors.textWarning;
|
||||
} else {
|
||||
statusColor = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
// Progress Bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$filled/$needed',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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:client_reports/src/domain/entities/forecast_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
@@ -18,8 +18,8 @@ class ForecastReportPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
DateTime _startDate = DateTime.now();
|
||||
DateTime _endDate = DateTime.now().add(const Duration(days: 14));
|
||||
final DateTime _startDate = DateTime.now();
|
||||
final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -44,159 +44,48 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 32,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, UiColors.tagInProgress],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
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: [
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.forecast_report
|
||||
.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildHeader(context),
|
||||
|
||||
// Content
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -16),
|
||||
offset: const Offset(0, -20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Summary Cards
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ForecastSummaryCard(
|
||||
label: context.t.client_reports.forecast_report.metrics.projected_spend,
|
||||
value: NumberFormat.currency(symbol: r'$')
|
||||
.format(report.projectedSpend),
|
||||
icon: UiIcons.dollar,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ForecastSummaryCard(
|
||||
label: context.t.client_reports.forecast_report.metrics.workers_needed,
|
||||
value: report.projectedWorkers.toString(),
|
||||
icon: UiIcons.users,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Chart
|
||||
Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.chart_title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: _ForecastChart(
|
||||
points: report.chartData,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Daily List
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.daily_projections,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
// Metrics Grid
|
||||
_buildMetricsGrid(context, report),
|
||||
const SizedBox(height: 16),
|
||||
if (report.chartData.isEmpty)
|
||||
Center(child: Text(context.t.client_reports.forecast_report.empty_state))
|
||||
|
||||
// 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,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Weekly Breakdown List
|
||||
if (report.weeklyBreakdown.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(
|
||||
context.t.client_reports.forecast_report.empty_state,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...report.chartData.map((point) => _ForecastListItem(
|
||||
date: DateFormat('EEE, MMM dd').format(point.date),
|
||||
cost: NumberFormat.currency(symbol: r'$')
|
||||
.format(point.projectedCost),
|
||||
workers: point.workersNeeded.toString(),
|
||||
)),
|
||||
const SizedBox(height: 100),
|
||||
...report.weeklyBreakdown.map(
|
||||
(week) => _WeeklyBreakdownItem(week: week),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -211,25 +100,135 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastSummaryCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _ForecastSummaryCard({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
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: [
|
||||
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 t = context.t.client_reports.forecast_report;
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.3,
|
||||
children: [
|
||||
_MetricCard(
|
||||
icon: UiIcons.dollar,
|
||||
label: t.metrics.four_week_forecast,
|
||||
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend),
|
||||
badgeText: t.badges.total_projected,
|
||||
iconColor: UiColors.textWarning,
|
||||
badgeColor: UiColors.tagPending, // Yellow-ish
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.trendingUp,
|
||||
label: t.metrics.avg_weekly,
|
||||
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend),
|
||||
badgeText: t.badges.per_week,
|
||||
iconColor: UiColors.primary,
|
||||
badgeColor: UiColors.tagInProgress, // Blue-ish
|
||||
),
|
||||
_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
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.users,
|
||||
label: t.metrics.total_hours,
|
||||
value: report.totalHours.toStringAsFixed(0),
|
||||
badgeText: t.badges.worker_hours,
|
||||
iconColor: UiColors.success,
|
||||
badgeColor: UiColors.tagSuccess,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection(BuildContext context, ForecastReport report) {
|
||||
return Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -243,24 +242,178 @@ class _ForecastSummaryCard extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 16, color: color),
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.chart_title,
|
||||
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),
|
||||
Expanded(
|
||||
child: _ForecastChart(points: report.chartData),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// X Axis labels manually if chart doesn't handle them perfectly or for custom look
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
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)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String badgeText;
|
||||
final Color iconColor;
|
||||
final Color badgeColor;
|
||||
|
||||
const _MetricCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.badgeText,
|
||||
required this.iconColor,
|
||||
required this.badgeColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.04),
|
||||
blurRadius: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: iconColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
badgeText,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.textPrimary, // Or specific text color
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeeklyBreakdownItem extends StatelessWidget {
|
||||
final ForecastWeek week;
|
||||
|
||||
const _WeeklyBreakdownItem({required this.week});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t.client_reports.forecast_report.weekly_breakdown;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
t.week(index: week.weekNumber),
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
Container(
|
||||
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),
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_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)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStat(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: UiTypography.body1m),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastChart extends StatelessWidget {
|
||||
final List<ForecastPoint> points;
|
||||
|
||||
@@ -268,51 +421,51 @@ class _ForecastChart extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If no data, show empty or default line?
|
||||
if (points.isEmpty) return const SizedBox();
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
getTitlesWidget: (value, meta) {
|
||||
if (value.toInt() < 0 || value.toInt() >= points.length) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (value.toInt() % 3 != 0) return const SizedBox();
|
||||
return SideTitleWidget(
|
||||
axisSide: meta.axisSide,
|
||||
child: Text(
|
||||
DateFormat('dd').format(points[value.toInt()].date),
|
||||
style: const TextStyle(fontSize: 10, color: UiColors.textSecondary),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 5000, // Dynamic?
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: UiColors.borderInactive,
|
||||
strokeWidth: 1,
|
||||
dashArray: [5, 5],
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: points.length.toDouble() - 1,
|
||||
// minY: 0, // Let it scale automatically
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: points
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost))
|
||||
.toList(),
|
||||
spots: points.asMap().entries.map((e) {
|
||||
return FlSpot(e.key.toDouble(), e.value.projectedCost);
|
||||
}).toList(),
|
||||
isCurved: true,
|
||||
color: UiColors.primary,
|
||||
color: UiColors.textWarning, // Orange-ish
|
||||
barWidth: 4,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: UiColors.textWarning,
|
||||
strokeWidth: 2,
|
||||
strokeColor: UiColors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: UiColors.primary.withOpacity(0.1),
|
||||
color: UiColors.tagPending.withOpacity(0.5), // Light orange fill
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -320,40 +473,3 @@ class _ForecastChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastListItem extends StatelessWidget {
|
||||
final String date;
|
||||
final String cost;
|
||||
final String workers;
|
||||
|
||||
const _ForecastListItem({
|
||||
required this.date,
|
||||
required this.cost,
|
||||
required this.workers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:client_reports/src/domain/entities/no_show_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.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';
|
||||
|
||||
@@ -8,7 +8,7 @@ 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:client_reports/src/domain/entities/spend_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class SpendReportPage extends StatefulWidget {
|
||||
const SpendReportPage({super.key});
|
||||
|
||||
@@ -50,6 +50,14 @@ class QuickReportsSection extends StatelessWidget {
|
||||
iconColor: UiColors.success,
|
||||
route: './spend',
|
||||
),
|
||||
// Coverage Report
|
||||
ReportCard(
|
||||
icon: UiIcons.users,
|
||||
name: context.t.client_reports.quick_reports.cards.coverage,
|
||||
iconBgColor: UiColors.tagInProgress,
|
||||
iconColor: UiColors.primary,
|
||||
route: './coverage',
|
||||
),
|
||||
// No-Show Rates
|
||||
ReportCard(
|
||||
icon: UiIcons.warning,
|
||||
@@ -58,6 +66,14 @@ class QuickReportsSection extends StatelessWidget {
|
||||
iconColor: UiColors.destructive,
|
||||
route: './no-show',
|
||||
),
|
||||
// Forecast Report
|
||||
ReportCard(
|
||||
icon: UiIcons.trendingUp,
|
||||
name: context.t.client_reports.quick_reports.cards.forecast,
|
||||
iconBgColor: UiColors.tagPending,
|
||||
iconColor: UiColors.textWarning,
|
||||
route: './forecast',
|
||||
),
|
||||
// Performance Reports
|
||||
ReportCard(
|
||||
icon: UiIcons.chart,
|
||||
|
||||
@@ -12,6 +12,8 @@ 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';
|
||||
|
||||
@@ -24,6 +26,7 @@ class ReportsModule extends Module {
|
||||
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
|
||||
i.add<DailyOpsBloc>(DailyOpsBloc.new);
|
||||
i.add<SpendBloc>(SpendBloc.new);
|
||||
i.add<CoverageBloc>(CoverageBloc.new);
|
||||
i.add<ForecastBloc>(ForecastBloc.new);
|
||||
i.add<PerformanceBloc>(PerformanceBloc.new);
|
||||
i.add<NoShowBloc>(NoShowBloc.new);
|
||||
@@ -35,6 +38,7 @@ class ReportsModule extends Module {
|
||||
r.child('/', child: (_) => const ReportsPage());
|
||||
r.child('/daily-ops', child: (_) => const DailyOpsReportPage());
|
||||
r.child('/spend', child: (_) => const SpendReportPage());
|
||||
r.child('/coverage', child: (_) => const CoverageReportPage());
|
||||
r.child('/forecast', child: (_) => const ForecastReportPage());
|
||||
r.child('/performance', child: (_) => const PerformanceReportPage());
|
||||
r.child('/no-show', child: (_) => const NoShowReportPage());
|
||||
|
||||
Reference in New Issue
Block a user