reports page implementation

This commit is contained in:
2026-02-18 15:10:01 +05:30
parent fe87291651
commit d589c9bca2
51 changed files with 5325 additions and 11 deletions

View File

@@ -1,4 +1,5 @@
import 'package:billing/billing.dart';
import 'package:client_reports/client_reports.dart';
import 'package:client_home/client_home.dart';
import 'package:client_coverage/client_coverage.dart';
import 'package:flutter/material.dart';
@@ -8,7 +9,6 @@ import 'package:view_orders/view_orders.dart';
import 'presentation/blocs/client_main_cubit.dart';
import 'presentation/pages/client_main_page.dart';
import 'presentation/pages/placeholder_page.dart';
class ClientMainModule extends Module {
@override
@@ -38,10 +38,9 @@ class ClientMainModule extends Module {
ClientPaths.childRoute(ClientPaths.main, ClientPaths.orders),
module: ViewOrdersModule(),
),
ChildRoute<dynamic>(
ModuleRoute<dynamic>(
ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports),
child: (BuildContext context) =>
const PlaceholderPage(title: 'Reports'),
module: ReportsModule(),
),
],
);

View File

@@ -36,6 +36,7 @@ class ClientMainBottomBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Translations.of(context);
// Client App colors from design system
const Color activeColor = UiColors.textPrimary;
const Color inactiveColor = UiColors.textInactive;
@@ -99,6 +100,13 @@ class ClientMainBottomBar extends StatelessWidget {
activeColor: activeColor,
inactiveColor: inactiveColor,
),
_buildNavItem(
index: 4,
icon: UiIcons.chart,
label: t.client_main.tabs.reports,
activeColor: activeColor,
inactiveColor: inactiveColor,
),
],
),
),

View File

@@ -24,6 +24,8 @@ dependencies:
path: ../home
client_coverage:
path: ../client_coverage
client_reports:
path: ../reports
view_orders:
path: ../view_orders
billing:

View File

@@ -0,0 +1,4 @@
library client_reports;
export 'src/reports_module.dart';
export 'src/presentation/pages/reports_page.dart';

View File

@@ -0,0 +1,467 @@
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 '../../domain/repositories/reports_repository.dart';
class ReportsRepositoryImpl implements ReportsRepository {
final DataConnectService _service;
ReportsRepositoryImpl({DataConnectService? service})
: _service = service ?? DataConnectService.instance;
@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,
);
});
}
@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 = {};
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 issueDateTime = inv.issueDate.toDateTime();
spendInvoices.add(SpendInvoice(
id: inv.id,
invoiceNumber: inv.invoiceNumber ?? '',
issueDate: issueDateTime,
amount: amount,
status: statusStr,
vendorName: inv.vendor?.companyName ?? 'Unknown',
));
// Chart data aggregation
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
}
final List<SpendChartPoint> chartData = dailyAggregates.entries
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
.toList()
..sort((a, b) => a.date.compareTo(b.date));
return SpendReport(
totalSpend: totalSpend,
averageCost: invoices.isEmpty ? 0 : totalSpend / invoices.length,
paidInvoices: paidInvoices,
pendingInvoices: pendingInvoices,
overdueInvoices: overdueInvoices,
invoices: spendInvoices,
chartData: chartData,
);
});
}
@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,
);
});
}
@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,
);
});
}
@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),
],
);
});
}
@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,
);
});
}
@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,
);
});
}
}

View File

@@ -0,0 +1,35 @@
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];
}

View File

@@ -0,0 +1,60 @@
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;
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,
});
@override
List<Object?> get props => [
id,
title,
location,
startTime,
endTime,
workersNeeded,
filled,
status,
];
}

View File

@@ -0,0 +1,33 @@
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];
}

View File

@@ -0,0 +1,33 @@
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];
}

View File

@@ -0,0 +1,35 @@
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];
}

View File

@@ -0,0 +1,29 @@
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,
];
}

View File

@@ -0,0 +1,63 @@
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,
});
@override
List<Object?> get props => [
totalSpend,
averageCost,
paidInvoices,
pendingInvoices,
overdueInvoices,
invoices,
chartData,
];
}
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,
});
@override
List<Object?> get props => [id, invoiceNumber, issueDate, amount, status, vendorName];
}
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];
}

View File

@@ -0,0 +1,50 @@
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';
abstract class ReportsRepository {
Future<DailyOpsReport> getDailyOpsReport({
String? businessId,
required DateTime date,
});
Future<SpendReport> getSpendReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<CoverageReport> getCoverageReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<ForecastReport> getForecastReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<PerformanceReport> getPerformanceReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<NoShowReport> getNoShowReport({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
Future<ReportsSummary> getReportsSummary({
String? businessId,
required DateTime startDate,
required DateTime endDate,
});
}

View File

@@ -0,0 +1,30 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'daily_ops_event.dart';
import 'daily_ops_state.dart';
class DailyOpsBloc extends Bloc<DailyOpsEvent, DailyOpsState> {
final ReportsRepository _reportsRepository;
DailyOpsBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(DailyOpsInitial()) {
on<LoadDailyOpsReport>(_onLoadDailyOpsReport);
}
Future<void> _onLoadDailyOpsReport(
LoadDailyOpsReport event,
Emitter<DailyOpsState> emit,
) async {
emit(DailyOpsLoading());
try {
final report = await _reportsRepository.getDailyOpsReport(
businessId: event.businessId,
date: event.date,
);
emit(DailyOpsLoaded(report));
} catch (e) {
emit(DailyOpsError(e.toString()));
}
}
}

View File

@@ -0,0 +1,21 @@
import 'package:equatable/equatable.dart';
abstract class DailyOpsEvent extends Equatable {
const DailyOpsEvent();
@override
List<Object?> get props => [];
}
class LoadDailyOpsReport extends DailyOpsEvent {
final String? businessId;
final DateTime date;
const LoadDailyOpsReport({
this.businessId,
required this.date,
});
@override
List<Object?> get props => [businessId, date];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/daily_ops_report.dart';
abstract class DailyOpsState extends Equatable {
const DailyOpsState();
@override
List<Object?> get props => [];
}
class DailyOpsInitial extends DailyOpsState {}
class DailyOpsLoading extends DailyOpsState {}
class DailyOpsLoaded extends DailyOpsState {
final DailyOpsReport report;
const DailyOpsLoaded(this.report);
@override
List<Object?> get props => [report];
}
class DailyOpsError extends DailyOpsState {
final String message;
const DailyOpsError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'forecast_event.dart';
import 'forecast_state.dart';
class ForecastBloc extends Bloc<ForecastEvent, ForecastState> {
final ReportsRepository _reportsRepository;
ForecastBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(ForecastInitial()) {
on<LoadForecastReport>(_onLoadForecastReport);
}
Future<void> _onLoadForecastReport(
LoadForecastReport event,
Emitter<ForecastState> emit,
) async {
emit(ForecastLoading());
try {
final report = await _reportsRepository.getForecastReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(ForecastLoaded(report));
} catch (e) {
emit(ForecastError(e.toString()));
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class ForecastEvent extends Equatable {
const ForecastEvent();
@override
List<Object?> get props => [];
}
class LoadForecastReport extends ForecastEvent {
final String? businessId;
final DateTime startDate;
final DateTime endDate;
const LoadForecastReport({
this.businessId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [businessId, startDate, endDate];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/forecast_report.dart';
abstract class ForecastState extends Equatable {
const ForecastState();
@override
List<Object?> get props => [];
}
class ForecastInitial extends ForecastState {}
class ForecastLoading extends ForecastState {}
class ForecastLoaded extends ForecastState {
final ForecastReport report;
const ForecastLoaded(this.report);
@override
List<Object?> get props => [report];
}
class ForecastError extends ForecastState {
final String message;
const ForecastError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'no_show_event.dart';
import 'no_show_state.dart';
class NoShowBloc extends Bloc<NoShowEvent, NoShowState> {
final ReportsRepository _reportsRepository;
NoShowBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(NoShowInitial()) {
on<LoadNoShowReport>(_onLoadNoShowReport);
}
Future<void> _onLoadNoShowReport(
LoadNoShowReport event,
Emitter<NoShowState> emit,
) async {
emit(NoShowLoading());
try {
final report = await _reportsRepository.getNoShowReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(NoShowLoaded(report));
} catch (e) {
emit(NoShowError(e.toString()));
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class NoShowEvent extends Equatable {
const NoShowEvent();
@override
List<Object?> get props => [];
}
class LoadNoShowReport extends NoShowEvent {
final String? businessId;
final DateTime startDate;
final DateTime endDate;
const LoadNoShowReport({
this.businessId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [businessId, startDate, endDate];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/no_show_report.dart';
abstract class NoShowState extends Equatable {
const NoShowState();
@override
List<Object?> get props => [];
}
class NoShowInitial extends NoShowState {}
class NoShowLoading extends NoShowState {}
class NoShowLoaded extends NoShowState {
final NoShowReport report;
const NoShowLoaded(this.report);
@override
List<Object?> get props => [report];
}
class NoShowError extends NoShowState {
final String message;
const NoShowError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'performance_event.dart';
import 'performance_state.dart';
class PerformanceBloc extends Bloc<PerformanceEvent, PerformanceState> {
final ReportsRepository _reportsRepository;
PerformanceBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(PerformanceInitial()) {
on<LoadPerformanceReport>(_onLoadPerformanceReport);
}
Future<void> _onLoadPerformanceReport(
LoadPerformanceReport event,
Emitter<PerformanceState> emit,
) async {
emit(PerformanceLoading());
try {
final report = await _reportsRepository.getPerformanceReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(PerformanceLoaded(report));
} catch (e) {
emit(PerformanceError(e.toString()));
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class PerformanceEvent extends Equatable {
const PerformanceEvent();
@override
List<Object?> get props => [];
}
class LoadPerformanceReport extends PerformanceEvent {
final String? businessId;
final DateTime startDate;
final DateTime endDate;
const LoadPerformanceReport({
this.businessId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [businessId, startDate, endDate];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/performance_report.dart';
abstract class PerformanceState extends Equatable {
const PerformanceState();
@override
List<Object?> get props => [];
}
class PerformanceInitial extends PerformanceState {}
class PerformanceLoading extends PerformanceState {}
class PerformanceLoaded extends PerformanceState {
final PerformanceReport report;
const PerformanceLoaded(this.report);
@override
List<Object?> get props => [report];
}
class PerformanceError extends PerformanceState {
final String message;
const PerformanceError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'spend_event.dart';
import 'spend_state.dart';
class SpendBloc extends Bloc<SpendEvent, SpendState> {
final ReportsRepository _reportsRepository;
SpendBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(SpendInitial()) {
on<LoadSpendReport>(_onLoadSpendReport);
}
Future<void> _onLoadSpendReport(
LoadSpendReport event,
Emitter<SpendState> emit,
) async {
emit(SpendLoading());
try {
final report = await _reportsRepository.getSpendReport(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(SpendLoaded(report));
} catch (e) {
emit(SpendError(e.toString()));
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class SpendEvent extends Equatable {
const SpendEvent();
@override
List<Object?> get props => [];
}
class LoadSpendReport extends SpendEvent {
final String? businessId;
final DateTime startDate;
final DateTime endDate;
const LoadSpendReport({
this.businessId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [businessId, startDate, endDate];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/spend_report.dart';
abstract class SpendState extends Equatable {
const SpendState();
@override
List<Object?> get props => [];
}
class SpendInitial extends SpendState {}
class SpendLoading extends SpendState {}
class SpendLoaded extends SpendState {
final SpendReport report;
const SpendLoaded(this.report);
@override
List<Object?> get props => [report];
}
class SpendError extends SpendState {
final String message;
const SpendError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,31 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../domain/repositories/reports_repository.dart';
import 'reports_summary_event.dart';
import 'reports_summary_state.dart';
class ReportsSummaryBloc extends Bloc<ReportsSummaryEvent, ReportsSummaryState> {
final ReportsRepository _reportsRepository;
ReportsSummaryBloc({required ReportsRepository reportsRepository})
: _reportsRepository = reportsRepository,
super(ReportsSummaryInitial()) {
on<LoadReportsSummary>(_onLoadReportsSummary);
}
Future<void> _onLoadReportsSummary(
LoadReportsSummary event,
Emitter<ReportsSummaryState> emit,
) async {
emit(ReportsSummaryLoading());
try {
final summary = await _reportsRepository.getReportsSummary(
businessId: event.businessId,
startDate: event.startDate,
endDate: event.endDate,
);
emit(ReportsSummaryLoaded(summary));
} catch (e) {
emit(ReportsSummaryError(e.toString()));
}
}
}

View File

@@ -0,0 +1,23 @@
import 'package:equatable/equatable.dart';
abstract class ReportsSummaryEvent extends Equatable {
const ReportsSummaryEvent();
@override
List<Object?> get props => [];
}
class LoadReportsSummary extends ReportsSummaryEvent {
final String? businessId;
final DateTime startDate;
final DateTime endDate;
const LoadReportsSummary({
this.businessId,
required this.startDate,
required this.endDate,
});
@override
List<Object?> get props => [businessId, startDate, endDate];
}

View File

@@ -0,0 +1,31 @@
import 'package:equatable/equatable.dart';
import '../../../domain/entities/reports_summary.dart';
abstract class ReportsSummaryState extends Equatable {
const ReportsSummaryState();
@override
List<Object?> get props => [];
}
class ReportsSummaryInitial extends ReportsSummaryState {}
class ReportsSummaryLoading extends ReportsSummaryState {}
class ReportsSummaryLoaded extends ReportsSummaryState {
final ReportsSummary summary;
const ReportsSummaryLoaded(this.summary);
@override
List<Object?> get props => [summary];
}
class ReportsSummaryError extends ReportsSummaryState {
final String message;
const ReportsSummaryError(this.message);
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,471 @@
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: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> {
DateTime _startDate = DateTime.now();
DateTime _endDate = DateTime.now().add(const Duration(days: 6));
@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.buttonPrimaryHover
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Modular.to.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),
),
),
],
),
],
),
GestureDetector(
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.t.client_reports.coverage_report
.placeholders.export_message,
),
duration: const Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
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,
),
),
],
),
),
),
],
),
),
// Content
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CoverageOverviewCard(
percentage: report.overallCoverage,
needed: report.totalNeeded,
filled: report.totalFilled,
),
const SizedBox(height: 24),
Text(
'DAILY BREAKDOWN',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
if (report.dailyCoverage.isEmpty)
const Center(child: Text('No shifts scheduled'))
else
...report.dailyCoverage.map((day) => _DailyCoverageItem(
date: DateFormat('EEEE, MMM dd').format(day.date),
percentage: day.percentage,
details: '${day.filled}/${day.needed} workers filled',
)),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _CoverageOverviewCard extends StatelessWidget {
final double percentage;
final int needed;
final int filled;
const _CoverageOverviewCard({
required this.percentage,
required this.needed,
required this.filled,
});
@override
Widget build(BuildContext context) {
final color = percentage >= 90
? UiColors.success
: percentage >= 70
? UiColors.textWarning
: UiColors.error;
return Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Overall Coverage',
style: const TextStyle(
fontSize: 14,
color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
'${percentage.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
_CircularProgress(
percentage: percentage / 100,
color: color,
size: 70,
),
],
),
const SizedBox(height: 24),
const Divider(height: 1, color: UiColors.bgSecondary),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _MetricItem(
label: 'Total Needed',
value: needed.toString(),
icon: UiIcons.users,
color: UiColors.primary,
),
),
Expanded(
child: _MetricItem(
label: 'Total Filled',
value: filled.toString(),
icon: UiIcons.checkCircle,
color: UiColors.success,
),
),
],
),
],
),
);
}
}
class _MetricItem extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _MetricItem({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 16, color: color),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
],
),
],
);
}
}
class _DailyCoverageItem extends StatelessWidget {
final String date;
final double percentage;
final String details;
const _DailyCoverageItem({
required this.date,
required this.percentage,
required this.details,
});
@override
Widget build(BuildContext context) {
final color = percentage >= 95
? UiColors.success
: percentage >= 80
? UiColors.textWarning
: UiColors.error;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 4,
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
date,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: UiColors.textPrimary,
),
),
Text(
'${percentage.toStringAsFixed(0)}%',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13,
color: color,
),
),
],
),
const SizedBox(height: 12),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: percentage / 100,
backgroundColor: UiColors.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(color),
minHeight: 6,
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: Text(
details,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
),
),
],
),
);
}
}
class _CircularProgress extends StatelessWidget {
final double percentage;
final Color color;
final double size;
const _CircularProgress({
required this.percentage,
required this.color,
required this.size,
});
@override
Widget build(BuildContext context) {
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox(
width: size,
height: size,
child: CircularProgressIndicator(
value: percentage,
strokeWidth: 8,
backgroundColor: UiColors.bgSecondary,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
),
Icon(
percentage >= 1.0 ? UiIcons.checkCircle : UiIcons.trendingUp,
color: color,
size: size * 0.4,
),
],
),
);
}
}

View File

@@ -0,0 +1,562 @@
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.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: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 DailyOpsReportPage extends StatefulWidget {
const DailyOpsReportPage({super.key});
@override
State<DailyOpsReportPage> createState() => _DailyOpsReportPageState();
}
class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
final DateTime _selectedDate = DateTime.now();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<DailyOpsBloc>()
..add(LoadDailyOpsReport(date: _selectedDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<DailyOpsBloc, DailyOpsState>(
builder: (context, state) {
if (state is DailyOpsLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is DailyOpsError) {
return Center(child: Text(state.message));
}
if (state is DailyOpsLoaded) {
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.buttonPrimaryHover
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Modular.to.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.daily_ops_report
.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
Text(
context.t.client_reports.daily_ops_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.daily_ops_report
.placeholders.export_message,
),
duration: const Duration(seconds: 2),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
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,
),
),
],
),
),
),
],
),
),
// Content
Transform.translate(
offset: const Offset(0, -16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Date Selector
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 4,
),
],
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
const Icon(
UiIcons.calendar,
size: 16,
color: UiColors.primary,
),
const SizedBox(width: 8),
Text(
DateFormat('MMM dd, yyyy')
.format(_selectedDate),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
],
),
const Icon(
UiIcons.chevronDown,
size: 16,
color: UiColors.textSecondary,
),
],
),
),
const SizedBox(height: 16),
// Stats Grid
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.4,
children: [
_OpsStatCard(
label: context.t.client_reports
.daily_ops_report.metrics.scheduled.label,
value: report.scheduledShifts.toString(),
subValue: context
.t
.client_reports
.daily_ops_report
.metrics
.scheduled
.sub_value,
color: UiColors.primary,
icon: UiIcons.calendar,
),
_OpsStatCard(
label: context.t.client_reports
.daily_ops_report.metrics.workers.label,
value: report.workersConfirmed.toString(),
subValue: context
.t
.client_reports
.daily_ops_report
.metrics
.workers
.sub_value,
color: UiColors.primary,
icon: UiIcons.users,
),
_OpsStatCard(
label: context
.t
.client_reports
.daily_ops_report
.metrics
.in_progress
.label,
value: report.inProgressShifts.toString(),
subValue: context
.t
.client_reports
.daily_ops_report
.metrics
.in_progress
.sub_value,
color: UiColors.textWarning,
icon: UiIcons.clock,
),
_OpsStatCard(
label: context
.t
.client_reports
.daily_ops_report
.metrics
.completed
.label,
value: report.completedShifts.toString(),
subValue: context
.t
.client_reports
.daily_ops_report
.metrics
.completed
.sub_value,
color: UiColors.success,
icon: UiIcons.checkCircle,
),
],
),
const SizedBox(height: 24),
Text(
context.t.client_reports.daily_ops_report
.all_shifts_title
.toUpperCase(),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 12),
// Shift List
if (report.shifts.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Text('No shifts scheduled for today'),
),
)
else
...report.shifts.map((shift) => _ShiftListItem(
title: shift.title,
location: shift.location,
time:
'${DateFormat('hh:mm a').format(shift.startTime)} - ${DateFormat('hh:mm a').format(shift.endTime)}',
workers:
'${shift.filled}/${shift.workersNeeded}',
status: shift.status.replaceAll('_', ' '),
statusColor: shift.status == 'COMPLETED'
? UiColors.success
: shift.status == 'IN_PROGRESS'
? UiColors.textWarning
: UiColors.primary,
)),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _OpsStatCard extends StatelessWidget {
final String label;
final String value;
final String subValue;
final Color color;
final IconData icon;
const _OpsStatCard({
required this.label,
required this.value,
required this.subValue,
required this.color,
required this.icon,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
border: Border(left: BorderSide(color: color, width: 4)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
Icon(icon, size: 14, color: color),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
Text(
subValue,
style: const TextStyle(
fontSize: 10,
color: UiColors.textSecondary,
),
),
],
),
],
),
);
}
}
class _ShiftListItem extends StatelessWidget {
final String title;
final String location;
final String time;
final String workers;
final String status;
final Color statusColor;
const _ShiftListItem({
required this.title,
required this.location,
required this.time,
required this.workers,
required this.status,
required this.statusColor,
});
@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),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 2,
),
],
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
UiIcons.mapPin,
size: 10,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Expanded(
child: Text(
location,
style: const TextStyle(
fontSize: 11,
color: UiColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
status.toUpperCase(),
style: TextStyle(
color: statusColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 12),
const Divider(height: 1, color: UiColors.bgSecondary),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_infoItem(
context,
UiIcons.clock,
context.t.client_reports.daily_ops_report.shift_item.time,
time),
_infoItem(
context,
UiIcons.users,
context.t.client_reports.daily_ops_report.shift_item.workers,
workers),
],
),
],
),
);
}
Widget _infoItem(
BuildContext context, IconData icon, String label, String value) {
return Row(
children: [
Icon(icon, size: 12, color: UiColors.textSecondary),
const SizedBox(width: 6),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 10, color: UiColors.pinInactive),
),
Text(
value,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: UiColors.textDescription,
),
),
],
),
],
);
}
}

View File

@@ -0,0 +1,359 @@
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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:fl_chart/fl_chart.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 ForecastReportPage extends StatefulWidget {
const ForecastReportPage({super.key});
@override
State<ForecastReportPage> createState() => _ForecastReportPageState();
}
class _ForecastReportPageState extends State<ForecastReportPage> {
DateTime _startDate = DateTime.now();
DateTime _endDate = DateTime.now().add(const Duration(days: 14));
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<ForecastBloc>()
..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<ForecastBloc, ForecastState>(
builder: (context, state) {
if (state is ForecastLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is ForecastError) {
return Center(child: Text(state.message));
}
if (state is ForecastLoaded) {
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: () => Modular.to.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),
),
),
],
),
],
),
],
),
),
// 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: _ForecastSummaryCard(
label: 'Projected Spend',
value: NumberFormat.currency(symbol: r'$')
.format(report.projectedSpend),
icon: UiIcons.dollar,
color: UiColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: _ForecastSummaryCard(
label: '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(
'Spending Forecast',
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(
'DAILY PROJECTIONS',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
if (report.chartData.isEmpty)
const Center(child: Text('No projections available'))
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),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
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) {
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 _ForecastChart extends StatelessWidget {
final List<ForecastPoint> points;
const _ForecastChart({required this.points});
@override
Widget build(BuildContext context) {
if (points.isEmpty) return const SizedBox();
return LineChart(
LineChartData(
gridData: const FlGridData(show: false),
titlesData: FlTitlesData(
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)),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: points
.asMap()
.entries
.map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost))
.toList(),
isCurved: true,
color: UiColors.primary,
barWidth: 4,
isStrokeCapRound: true,
dotData: const FlDotData(show: false),
belowBarData: BarAreaData(
show: true,
color: UiColors.primary.withOpacity(0.1),
),
),
],
),
);
}
}
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('$workers workers needed', style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
],
),
Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)),
],
),
);
}
}

View File

@@ -0,0 +1,220 @@
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';
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';
class NoShowReportPage extends StatefulWidget {
const NoShowReportPage({super.key});
@override
State<NoShowReportPage> createState() => _NoShowReportPageState();
}
class _NoShowReportPageState extends State<NoShowReportPage> {
DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
DateTime _endDate = DateTime.now();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<NoShowBloc>()
..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<NoShowBloc, NoShowState>(
builder: (context, state) {
if (state is NoShowLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is NoShowError) {
return Center(child: Text(state.message));
}
if (state is NoShowLoaded) {
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.error, UiColors.tagError],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Modular.to.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.no_show_report.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
),
Text(
context.t.client_reports.no_show_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(
children: [
// Summary
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.06), blurRadius: 10)],
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_SummaryItem(
label: 'Total No-Shows',
value: report.totalNoShows.toString(),
color: UiColors.error,
),
_SummaryItem(
label: 'No-Show Rate',
value: '${report.noShowRate.toStringAsFixed(1)}%',
color: UiColors.textWarning,
),
],
),
),
const SizedBox(height: 24),
// Flagged Workers
Align(
alignment: Alignment.centerLeft,
child: Text(
context.t.client_reports.no_show_report.workers_list_title,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: UiColors.textSecondary, letterSpacing: 1.2),
),
),
const SizedBox(height: 16),
if (report.flaggedWorkers.isEmpty)
const Padding(
padding: EdgeInsets.all(40.0),
child: Text('No workers flagged for no-shows'),
)
else
...report.flaggedWorkers.map((worker) => _WorkerListItem(worker: worker)),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _SummaryItem extends StatelessWidget {
final String label;
final String value;
final Color color;
const _SummaryItem({required this.label, required this.value, required this.color});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)),
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
],
);
}
}
class _WorkerListItem extends StatelessWidget {
final dynamic worker;
const _WorkerListItem({required this.worker});
@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: [
Row(
children: [
Container(
width: 40,
height: 40,
decoration: const BoxDecoration(color: UiColors.bgSecondary, shape: BoxShape.circle),
child: const Icon(UiIcons.user, color: UiColors.textSecondary),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)),
Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)),
],
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)),
const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,223 @@
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.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: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';
class PerformanceReportPage extends StatefulWidget {
const PerformanceReportPage({super.key});
@override
State<PerformanceReportPage> createState() => _PerformanceReportPageState();
}
class _PerformanceReportPageState extends State<PerformanceReportPage> {
DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
DateTime _endDate = DateTime.now();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<PerformanceBloc>()
..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<PerformanceBloc, PerformanceState>(
builder: (context, state) {
if (state is PerformanceLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is PerformanceError) {
return Center(child: Text(state.message));
}
if (state is PerformanceLoaded) {
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.buttonPrimaryHover],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
children: [
GestureDetector(
onTap: () => Modular.to.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.performance_report.title,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
),
Text(
context.t.client_reports.performance_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(
children: [
// Main Stats
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.5,
children: [
_StatTile(
label: 'Fill Rate',
value: '${report.fillRate.toStringAsFixed(1)}%',
color: UiColors.primary,
icon: UiIcons.users,
),
_StatTile(
label: 'Completion',
value: '${report.completionRate.toStringAsFixed(1)}%',
color: UiColors.success,
icon: UiIcons.checkCircle,
),
_StatTile(
label: 'On-Time',
value: '${report.onTimeRate.toStringAsFixed(1)}%',
color: UiColors.textWarning,
icon: UiIcons.clock,
),
_StatTile(
label: 'Avg Fill Time',
value: '${report.avgFillTimeHours.toStringAsFixed(1)}h',
color: UiColors.primary,
icon: UiIcons.trendingUp,
),
],
),
const SizedBox(height: 24),
// KPI List
Container(
padding: const EdgeInsets.all(20),
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: [
const Text(
'Key Performance Indicators',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
const SizedBox(height: 20),
...report.keyPerformanceIndicators.map((kpi) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(kpi.label, style: const TextStyle(color: UiColors.textSecondary)),
Row(
children: [
Text(kpi.value, style: const TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(width: 8),
Icon(
kpi.trend >= 0 ? UiIcons.chevronUp : UiIcons.chevronDown,
size: 14,
color: kpi.trend >= 0 ? UiColors.success : UiColors.error,
),
],
),
],
),
)),
],
),
),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _StatTile extends StatelessWidget {
final String label;
final String value;
final Color color;
final IconData icon;
const _StatTile({required this.label, required this.value, required this.color, required this.icon});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 5)],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(icon, color: color, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,696 @@
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.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: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 ReportsPage extends StatefulWidget {
const ReportsPage({super.key});
@override
State<ReportsPage> createState() => _ReportsPageState();
}
class _ReportsPageState extends State<ReportsPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
late ReportsSummaryBloc _summaryBloc;
// Date ranges per tab: Today, Week, Month, Quarter
final List<(DateTime, DateTime)> _dateRanges = [
(
DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day),
DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day,
23, 59, 59),
),
(
DateTime.now().subtract(const Duration(days: 7)),
DateTime.now(),
),
(
DateTime(DateTime.now().year, DateTime.now().month, 1),
DateTime.now(),
),
(
DateTime(DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1,
1),
DateTime.now(),
),
];
@override
void initState() {
super.initState();
_tabController = TabController(length: 4, vsync: this);
_summaryBloc = Modular.get<ReportsSummaryBloc>();
_loadSummary(0);
_tabController.addListener(() {
if (!_tabController.indexIsChanging) {
_loadSummary(_tabController.index);
}
});
}
void _loadSummary(int tabIndex) {
final range = _dateRanges[tabIndex];
_summaryBloc.add(LoadReportsSummary(
startDate: range.$1,
endDate: range.$2,
));
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _summaryBloc,
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: 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.buttonPrimaryHover,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
children: [
Row(
children: [
GestureDetector(
onTap: () =>
Modular.to.navigate('/client-main/home'),
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),
Text(
context.t.client_reports.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: UiColors.white,
),
),
],
),
const SizedBox(height: 24),
// Tabs
Container(
height: 44,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: UiColors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: TabBar(
controller: _tabController,
indicator: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
labelColor: UiColors.primary,
unselectedLabelColor: UiColors.white,
labelStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
indicatorSize: TabBarIndicatorSize.tab,
dividerColor: Colors.transparent,
tabs: [
Tab(text: context.t.client_reports.tabs.today),
Tab(text: context.t.client_reports.tabs.week),
Tab(text: context.t.client_reports.tabs.month),
Tab(text: context.t.client_reports.tabs.quarter),
],
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Key Metrics — driven by BLoC
BlocBuilder<ReportsSummaryBloc, ReportsSummaryState>(
builder: (context, state) {
if (state is ReportsSummaryLoading ||
state is ReportsSummaryInitial) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 32),
child: Center(child: CircularProgressIndicator()),
);
}
if (state is ReportsSummaryError) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.tagError,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(UiIcons.warning,
color: UiColors.error, size: 16),
const SizedBox(width: 8),
Expanded(
child: Text(
state.message,
style: const TextStyle(
color: UiColors.error, fontSize: 12),
),
),
],
),
),
);
}
final summary = (state as ReportsSummaryLoaded).summary;
final currencyFmt =
NumberFormat.currency(symbol: '\$', decimalDigits: 0);
return GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.2,
children: [
_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),
badgeText: context
.t.client_reports.metrics.total_hrs.badge,
badgeColor: UiColors.tagRefunded,
badgeTextColor: UiColors.primary,
iconColor: UiColors.primary,
),
_MetricCard(
icon: UiIcons.trendingUp,
label: context
.t.client_reports.metrics.ot_hours.label,
value: summary.otHours.toStringAsFixed(0),
badgeText: context
.t.client_reports.metrics.ot_hours.badge,
badgeColor: UiColors.tagValue,
badgeTextColor: UiColors.textSecondary,
iconColor: UiColors.textWarning,
),
_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),
badgeText: context
.t.client_reports.metrics.total_spend.badge,
badgeColor: UiColors.tagSuccess,
badgeTextColor: UiColors.textSuccess,
iconColor: UiColors.success,
),
_MetricCard(
icon: UiIcons.trendingUp,
label: context
.t.client_reports.metrics.fill_rate.label,
value:
'${summary.fillRate.toStringAsFixed(0)}%',
badgeText: context
.t.client_reports.metrics.fill_rate.badge,
badgeColor: UiColors.tagInProgress,
badgeTextColor: UiColors.textLink,
iconColor: UiColors.iconActive,
),
_MetricCard(
icon: UiIcons.clock,
label: context.t.client_reports.metrics
.avg_fill_time.label,
value:
'${summary.avgFillTimeHours.toStringAsFixed(1)} hrs',
badgeText: context.t.client_reports.metrics
.avg_fill_time.badge,
badgeColor: UiColors.tagInProgress,
badgeTextColor: UiColors.textLink,
iconColor: UiColors.iconActive,
),
_MetricCard(
icon: UiIcons.warning,
label: context
.t.client_reports.metrics.no_show_rate.label,
value:
'${summary.noShowRate.toStringAsFixed(1)}%',
badgeText: context
.t.client_reports.metrics.no_show_rate.badge,
badgeColor: summary.noShowRate < 5
? UiColors.tagSuccess
: UiColors.tagError,
badgeTextColor: summary.noShowRate < 5
? UiColors.textSuccess
: UiColors.error,
iconColor: UiColors.destructive,
),
],
);
},
),
const SizedBox(height: 24),
// Quick Reports
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
context.t.client_reports.quick_reports.title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
),
TextButton.icon(
onPressed: () {},
icon: const Icon(UiIcons.download, size: 16),
label: Text(
context.t.client_reports.quick_reports.export_all),
style: TextButton.styleFrom(
foregroundColor: UiColors.textLink,
padding: EdgeInsets.zero,
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
GridView.count(
crossAxisCount: 2,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 1.3,
children: [
_ReportCard(
icon: UiIcons.calendar,
name: context
.t.client_reports.quick_reports.cards.daily_ops,
iconBgColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
route: './daily-ops',
),
_ReportCard(
icon: UiIcons.dollar,
name: context
.t.client_reports.quick_reports.cards.spend,
iconBgColor: UiColors.tagSuccess,
iconColor: UiColors.success,
route: './spend',
),
_ReportCard(
icon: UiIcons.users,
name: context
.t.client_reports.quick_reports.cards.coverage,
iconBgColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
route: './coverage',
),
_ReportCard(
icon: UiIcons.warning,
name: context
.t.client_reports.quick_reports.cards.no_show,
iconBgColor: UiColors.tagError,
iconColor: UiColors.destructive,
route: './no-show',
),
_ReportCard(
icon: UiIcons.trendingUp,
name: context
.t.client_reports.quick_reports.cards.forecast,
iconBgColor: UiColors.tagPending,
iconColor: UiColors.textWarning,
route: './forecast',
),
_ReportCard(
icon: UiIcons.chart,
name: context
.t.client_reports.quick_reports.cards.performance,
iconBgColor: UiColors.tagInProgress,
iconColor: UiColors.primary,
route: './performance',
),
],
),
const SizedBox(height: 24),
// AI Insights
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.tagInProgress.withOpacity(0.3),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 2,
offset: const Offset(0, 1),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'💡 ${context.t.client_reports.ai_insights.title}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 12),
_InsightRow(
children: [
TextSpan(
text: context.t.client_reports.ai_insights
.insight_1.prefix),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_1.highlight,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_1.suffix,
),
],
),
_InsightRow(
children: [
TextSpan(
text: context.t.client_reports.ai_insights
.insight_2.prefix),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_2.highlight,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_2.suffix,
),
],
),
_InsightRow(
children: [
TextSpan(
text: context.t.client_reports.ai_insights
.insight_3.prefix,
),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_3.highlight,
style: const TextStyle(
fontWeight: FontWeight.bold),
),
TextSpan(
text: context.t.client_reports.ai_insights
.insight_3.suffix),
],
),
],
),
),
const SizedBox(height: 100),
],
),
),
],
),
),
),
);
}
}
class _MetricCard extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final String badgeText;
final Color badgeColor;
final Color badgeTextColor;
final Color iconColor;
const _MetricCard({
required this.icon,
required this.label,
required this.value,
required this.badgeText,
required this.badgeColor,
required this.badgeTextColor,
required this.iconColor,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.06),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
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: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 4),
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: badgeColor,
borderRadius: BorderRadius.circular(10),
),
child: Text(
badgeText,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: badgeTextColor,
),
),
),
],
),
],
),
);
}
}
class _ReportCard extends StatelessWidget {
final IconData icon;
final String name;
final Color iconBgColor;
final Color iconColor;
final String route;
const _ReportCard({
required this.icon,
required this.name,
required this.iconBgColor,
required this.iconColor,
required this.route,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Modular.to.pushNamed(route),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 2,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: iconBgColor,
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, size: 20, color: iconColor),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: UiColors.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
const Icon(
UiIcons.download,
size: 12,
color: UiColors.textSecondary,
),
const SizedBox(width: 4),
Text(
context.t.client_reports.quick_reports.two_click_export,
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
),
],
),
],
),
],
),
),
);
}
}
class _InsightRow extends StatelessWidget {
final List<InlineSpan> children;
const _InsightRow({required this.children});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'',
style: TextStyle(color: UiColors.textSecondary, fontSize: 14),
),
Expanded(
child: Text.rich(
TextSpan(
style: const TextStyle(
fontSize: 14,
color: UiColors.textSecondary,
height: 1.4,
),
children: children,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,674 @@
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:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:fl_chart/fl_chart.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 SpendReportPage extends StatefulWidget {
const SpendReportPage({super.key});
@override
State<SpendReportPage> createState() => _SpendReportPageState();
}
class _SpendReportPageState extends State<SpendReportPage> {
DateTime _startDate = DateTime.now().subtract(const Duration(days: 6));
DateTime _endDate = DateTime.now();
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => Modular.get<SpendBloc>()
..add(LoadSpendReport(startDate: _startDate, endDate: _endDate)),
child: Scaffold(
backgroundColor: UiColors.bgMenu,
body: BlocBuilder<SpendBloc, SpendState>(
builder: (context, state) {
if (state is SpendLoading) {
return const Center(child: CircularProgressIndicator());
}
if (state is SpendError) {
return Center(child: Text(state.message));
}
if (state is SpendLoaded) {
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.success, UiColors.tagSuccess],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
GestureDetector(
onTap: () => Modular.to.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.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),
),
);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(
UiIcons.download,
size: 14,
color: UiColors.success,
),
const SizedBox(width: 6),
Text(
context.t.client_reports.quick_reports
.export_all
.split(' ')
.first,
style: const TextStyle(
color: UiColors.success,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
// 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: _SpendSummaryCard(
label: context.t.client_reports.spend_report
.summary.total_spend,
value: NumberFormat.currency(symbol: r'$')
.format(report.totalSpend),
change: '', // Can be calculated if needed
period: context.t.client_reports
.spend_report.summary.this_week,
color: UiColors.textSuccess,
icon: UiIcons.dollar,
),
),
const SizedBox(width: 12),
Expanded(
child: _SpendSummaryCard(
label: context.t.client_reports.spend_report
.summary.avg_daily,
value: NumberFormat.currency(symbol: r'$')
.format(report.averageCost),
change: '',
period: context.t.client_reports
.spend_report.summary.per_day,
color: UiColors.primary,
icon: UiIcons.chart,
),
),
],
),
const SizedBox(height: 24),
// Chart Section
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,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
context.t.client_reports.spend_report
.chart_title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius:
BorderRadius.circular(8),
),
child: Row(
children: [
Text(
context.t.client_reports.tabs.week,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
),
),
const Icon(
UiIcons.chevronDown,
size: 10,
color: UiColors.textSecondary,
),
],
),
),
],
),
const SizedBox(height: 24),
Expanded(
child: _SpendBarChart(
chartData: report.chartData),
),
],
),
),
const SizedBox(height: 24),
// Status Distribution
Row(
children: [
Expanded(
child: _StatusMiniCard(
label: 'Paid',
value: report.paidInvoices.toString(),
color: UiColors.success,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatusMiniCard(
label: 'Pending',
value: report.pendingInvoices.toString(),
color: UiColors.textWarning,
),
),
const SizedBox(width: 8),
Expanded(
child: _StatusMiniCard(
label: 'Overdue',
value: report.overdueInvoices.toString(),
color: UiColors.error,
),
),
],
),
const SizedBox(height: 32),
Text(
'RECENT INVOICES',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: UiColors.textSecondary,
letterSpacing: 1.2,
),
),
const SizedBox(height: 16),
// Invoice List
if (report.invoices.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: Center(
child:
Text('No invoices found for this period'),
),
)
else
...report.invoices.map((inv) => _InvoiceListItem(
invoice: inv.invoiceNumber,
vendor: inv.vendorName,
date: DateFormat('MMM dd, yyyy')
.format(inv.issueDate),
amount: NumberFormat.currency(symbol: r'$')
.format(inv.amount),
status: inv.status,
statusColor: inv.status == 'PAID'
? UiColors.success
: inv.status == 'PENDING'
? UiColors.textWarning
: UiColors.error,
)),
const SizedBox(height: 100),
],
),
),
),
],
),
);
}
return const SizedBox.shrink();
},
),
),
);
}
}
class _SpendBarChart extends StatelessWidget {
final List<dynamic> chartData;
const _SpendBarChart({required this.chartData});
@override
Widget build(BuildContext context) {
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: (chartData.fold<double>(
0, (prev, element) => element.amount > prev ? element.amount : prev) *
1.2)
.ceilToDouble(),
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
tooltipPadding: const EdgeInsets.all(8),
getTooltipItem: (group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'\$${rod.toY.round()}',
const TextStyle(
color: UiColors.white,
fontWeight: FontWeight.bold,
),
);
},
),
),
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
if (value.toInt() >= chartData.length) return const SizedBox();
final date = chartData[value.toInt()].date;
return SideTitleWidget(
axisSide: meta.axisSide,
space: 4,
child: Text(
DateFormat('E').format(date),
style: const TextStyle(
color: UiColors.textSecondary,
fontSize: 10,
),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: const FlGridData(show: false),
borderData: FlBorderData(show: false),
barGroups: List.generate(
chartData.length,
(index) => BarChartGroupData(
x: index,
barRods: [
BarChartRodData(
toY: chartData[index].amount,
color: UiColors.success,
width: 16,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(4),
),
),
],
),
),
),
);
}
}
class _SpendSummaryCard extends StatelessWidget {
final String label;
final String value;
final String change;
final String period;
final Color color;
final IconData icon;
const _SpendSummaryCard({
required this.label,
required this.value,
required this.change,
required this.period,
required this.color,
required this.icon,
});
@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,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: color),
),
if (change.isNotEmpty)
Text(
change,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: change.startsWith('+')
? UiColors.error
: UiColors.success,
),
),
],
),
const SizedBox(height: 12),
Text(
label,
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
period,
style: const TextStyle(
fontSize: 10,
color: UiColors.textDescription,
),
),
],
),
);
}
}
class _StatusMiniCard extends StatelessWidget {
final String label;
final String value;
final Color color;
const _StatusMiniCard({
required this.label,
required this.value,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.1)),
),
child: Column(
children: [
Text(
value,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontSize: 10,
color: UiColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
}
class _InvoiceListItem extends StatelessWidget {
final String invoice;
final String vendor;
final String date;
final String amount;
final String status;
final Color statusColor;
const _InvoiceListItem({
required this.invoice,
required this.vendor,
required this.date,
required this.amount,
required this.status,
required this.statusColor,
});
@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(16),
boxShadow: [
BoxShadow(
color: UiColors.black.withOpacity(0.02),
blurRadius: 10,
),
],
),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(UiIcons.file, size: 20, color: statusColor),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
invoice,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
vendor,
style: const TextStyle(
fontSize: 12,
color: UiColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
date,
style: const TextStyle(
fontSize: 11,
color: UiColors.textDescription,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
amount,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
color: UiColors.textPrimary,
),
),
const SizedBox(height: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
status.toUpperCase(),
style: TextStyle(
color: statusColor,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,46 @@
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/daily_ops/daily_ops_bloc.dart';
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart';
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
import 'package:client_reports/src/presentation/blocs/no_show/no_show_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:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
class ReportsModule extends Module {
@override
List<Module> get imports => [DataConnectModule()];
@override
void binds(Injector i) {
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);
i.add<ReportsSummaryBloc>(ReportsSummaryBloc.new);
}
@override
void routes(RouteManager r) {
r.child('/', child: (_) => const ReportsPage());
r.child('/daily-ops', child: (_) => const DailyOpsReportPage());
r.child('/spend', child: (_) => const SpendReportPage());
r.child('/forecast', child: (_) => const ForecastReportPage());
r.child('/performance', child: (_) => const PerformanceReportPage());
r.child('/no-show', child: (_) => const NoShowReportPage());
r.child('/coverage', child: (_) => const CoverageReportPage());
}
}

View File

@@ -0,0 +1,39 @@
name: client_reports
description: Workforce reports and analytics for client application
version: 0.0.1
publish_to: none
resolution: workspace
environment:
sdk: ^3.6.0
dependencies:
flutter:
sdk: flutter
# Dependencies needed for the prototype
# lucide_icons removed, used via design_system
fl_chart: ^0.66.0
# Internal packages
design_system:
path: ../../../design_system
krow_domain:
path: ../../../domain
krow_core:
path: ../../../core
core_localization:
path: ../../../core_localization
krow_data_connect:
path: ../../../data_connect
# External packages
flutter_modular: ^6.3.4
flutter_bloc: ^8.1.6
equatable: ^2.0.7
intl: ^0.20.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0

View File

@@ -215,14 +215,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
staffRecord = staffResponse.data.staffs.first;
}
final String email = user?.email ?? '';
return _setSession(firebaseUser.uid, user, staffRecord);
}
@override
Future<void> restoreSession() async {
final User? firebaseUser = await _service.auth.authStateChanges().first;
if (firebaseUser == null) {
return;
}
// Reuse the same logic as verifyOtp to fetch user/staff and set session
// We can't reuse verifyOtp directly because it requires verificationId/smsCode
// So we fetch the data manually here.
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
await _service.run(
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
requiresAuthentication: false,
);
final GetUserByIdUser? user = response.data.user;
if (user == null) {
// User authenticated in Firebase but not in our DB?
// Should likely sign out or handle gracefully.
await _service.auth.signOut();
return;
}
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run(
() => _service.connector.getStaffByUserId(userId: firebaseUser.uid).execute(),
requiresAuthentication: false,
);
final GetStaffByUserIdStaffs? staffRecord =
staffResponse.data.staffs.firstOrNull;
_setSession(firebaseUser.uid, user, staffRecord);
}
domain.User _setSession(
String uid,
GetUserByIdUser? user,
GetStaffByUserIdStaffs? staffRecord,
) {
//TO-DO: create(registration) user and staff account
//TO-DO: save user data locally
final domain.User domainUser = domain.User(
id: firebaseUser.uid,
email: email,
phone: firebaseUser.phoneNumber,
id: uid,
email: user?.email ?? '',
phone: user?.phone, // Use user.phone locally if needed, but domain.User expects it
role: user?.role.stringValue ?? 'USER',
);
final domain.Staff? domainStaff = staffRecord == null

View File

@@ -20,4 +20,7 @@ abstract interface class AuthRepositoryInterface {
/// Signs out the current user.
Future<void> signOut();
/// Restores the user session if a user is already signed in.
Future<void> restoreSession();
}

View File

@@ -28,7 +28,6 @@ class StaffAuthenticationModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
@@ -53,6 +52,11 @@ class StaffAuthenticationModule extends Module {
);
}
@override
void exportedBinds(Injector i) {
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
}
@override
void routes(RouteManager r) {
r.child(StaffPaths.root, child: (_) => const IntroPage());

View File

@@ -3,3 +3,4 @@ export 'src/presentation/pages/get_started_page.dart';
export 'src/presentation/pages/phone_verification_page.dart';
export 'src/presentation/pages/profile_setup_page.dart';
export 'src/staff_authentication_module.dart';
export 'src/domain/repositories/auth_repository_interface.dart';