reports page ui
This commit is contained in:
@@ -38,10 +38,19 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
|
||||
if (state is CoverageLoaded) {
|
||||
final report = state.report;
|
||||
|
||||
// Compute "Full" and "Needs Help" counts from daily coverage
|
||||
final fullDays = report.dailyCoverage
|
||||
.where((d) => d.percentage >= 100)
|
||||
.length;
|
||||
final needsHelpDays = report.dailyCoverage
|
||||
.where((d) => d.percentage < 80)
|
||||
.length;
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
// ── Header ───────────────────────────────────────────
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
@@ -53,107 +62,136 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
UiColors.primary,
|
||||
UiColors.buttonPrimaryHover
|
||||
UiColors.buttonPrimaryHover,
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
child: Column(
|
||||
children: [
|
||||
// Title row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.coverage_report
|
||||
.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.white,
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.coverage_report
|
||||
.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.white.withOpacity(0.7),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Export button
|
||||
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: const Row(
|
||||
children: [
|
||||
Icon(UiIcons.download,
|
||||
size: 14, color: UiColors.primary),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Export',
|
||||
style: TextStyle(
|
||||
color: UiColors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 3 summary stat chips (matches prototype) ──
|
||||
Row(
|
||||
children: [
|
||||
_HeaderStatChip(
|
||||
icon: UiIcons.trendingUp,
|
||||
label: 'Avg Coverage',
|
||||
value:
|
||||
'${report.overallCoverage.toStringAsFixed(0)}%',
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
const SizedBox(width: 12),
|
||||
_HeaderStatChip(
|
||||
icon: UiIcons.checkCircle,
|
||||
label: 'Full',
|
||||
value: fullDays.toString(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
_HeaderStatChip(
|
||||
icon: UiIcons.warning,
|
||||
label: 'Needs Help',
|
||||
value: needsHelpDays.toString(),
|
||||
isAlert: needsHelpDays > 0,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
// ── Content ──────────────────────────────────────────
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -16),
|
||||
child: Padding(
|
||||
@@ -161,30 +199,39 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
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,
|
||||
// Section label
|
||||
const Text(
|
||||
'NEXT 7 DAYS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (report.dailyCoverage.isEmpty)
|
||||
const Center(child: Text('No shifts scheduled'))
|
||||
Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'No shifts scheduled',
|
||||
style: TextStyle(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...report.dailyCoverage.map((day) => _DailyCoverageItem(
|
||||
date: DateFormat('EEEE, MMM dd').format(day.date),
|
||||
percentage: day.percentage,
|
||||
details: '${day.filled}/${day.needed} workers filled',
|
||||
)),
|
||||
...report.dailyCoverage.map(
|
||||
(day) => _DayCoverageCard(
|
||||
date: DateFormat('EEE, MMM d').format(day.date),
|
||||
filled: day.filled,
|
||||
needed: day.needed,
|
||||
percentage: day.percentage,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
@@ -202,35 +249,114 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageOverviewCard extends StatelessWidget {
|
||||
final double percentage;
|
||||
final int needed;
|
||||
final int filled;
|
||||
// ── Header stat chip (inside the blue header) ─────────────────────────────────
|
||||
class _HeaderStatChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final bool isAlert;
|
||||
|
||||
const _CoverageOverviewCard({
|
||||
required this.percentage,
|
||||
required this.needed,
|
||||
required this.filled,
|
||||
const _HeaderStatChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.isAlert = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = percentage >= 90
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: 12,
|
||||
color: isAlert
|
||||
? const Color(0xFFFFD580)
|
||||
: UiColors.white.withOpacity(0.8),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: UiColors.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Day coverage card ─────────────────────────────────────────────────────────
|
||||
class _DayCoverageCard extends StatelessWidget {
|
||||
final String date;
|
||||
final int filled;
|
||||
final int needed;
|
||||
final double percentage;
|
||||
|
||||
const _DayCoverageCard({
|
||||
required this.date,
|
||||
required this.filled,
|
||||
required this.needed,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isFullyStaffed = percentage >= 100;
|
||||
final spotsRemaining = (needed - filled).clamp(0, needed);
|
||||
|
||||
final barColor = percentage >= 95
|
||||
? UiColors.success
|
||||
: percentage >= 70
|
||||
? UiColors.textWarning
|
||||
: percentage >= 80
|
||||
? UiColors.primary
|
||||
: UiColors.error;
|
||||
|
||||
final badgeColor = percentage >= 95
|
||||
? UiColors.success
|
||||
: percentage >= 80
|
||||
? UiColors.primary
|
||||
: UiColors.error;
|
||||
|
||||
final badgeBg = percentage >= 95
|
||||
? UiColors.tagSuccess
|
||||
: percentage >= 80
|
||||
? UiColors.tagInProgress
|
||||
: UiColors.tagError;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.06),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
color: UiColors.black.withOpacity(0.03),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -243,162 +369,40 @@ class _CoverageOverviewCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Overall Coverage',
|
||||
date,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: UiColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
'$filled/$needed workers confirmed',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
_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,
|
||||
// Percentage badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _MetricItem(
|
||||
label: 'Total Filled',
|
||||
value: filled.toString(),
|
||||
icon: UiIcons.checkCircle,
|
||||
color: UiColors.success,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
child: Text(
|
||||
'${percentage.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -407,20 +411,27 @@ class _DailyCoverageItem extends StatelessWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
value: (percentage / 100).clamp(0.0, 1.0),
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(barColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
details,
|
||||
style: const TextStyle(
|
||||
isFullyStaffed
|
||||
? 'Fully staffed'
|
||||
: '$spotsRemaining spot${spotsRemaining != 1 ? 's' : ''} remaining',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: UiColors.textSecondary,
|
||||
color: isFullyStaffed
|
||||
? UiColors.success
|
||||
: UiColors.textSecondary,
|
||||
fontWeight: isFullyStaffed
|
||||
? FontWeight.w500
|
||||
: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -429,43 +440,3 @@ class _DailyCoverageItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,35 @@ class DailyOpsReportPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
|
||||
final DateTime _selectedDate = DateTime.now();
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
|
||||
Future<void> _pickDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: UiColors.primary,
|
||||
onPrimary: UiColors.white,
|
||||
surface: UiColors.white,
|
||||
onSurface: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (picked != null && picked != _selectedDate && mounted) {
|
||||
setState(() => _selectedDate = picked);
|
||||
if (context.mounted) {
|
||||
context.read<DailyOpsBloc>().add(LoadDailyOpsReport(date: picked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -161,46 +189,49 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
|
||||
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,
|
||||
GestureDetector(
|
||||
onTap: () => _pickDate(context),
|
||||
child: 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 Icon(
|
||||
UiIcons.chevronDown,
|
||||
size: 16,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
],
|
||||
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),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:client_reports/src/domain/entities/no_show_report.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
|
||||
@@ -6,6 +7,7 @@ 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 NoShowReportPage extends StatefulWidget {
|
||||
const NoShowReportPage({super.key});
|
||||
@@ -37,10 +39,11 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
|
||||
|
||||
if (state is NoShowLoaded) {
|
||||
final report = state.report;
|
||||
final uniqueWorkers = report.flaggedWorkers.length;
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
// ── Header ──────────────────────────────────────────
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
@@ -49,93 +52,216 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
|
||||
bottom: 32,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.error, UiColors.tagError],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
color: Color(0xFF1A1A2E),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.no_show_report.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.no_show_report.subtitle,
|
||||
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
||||
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.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Export button
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Export coming soon'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
UiIcons.download,
|
||||
size: 14,
|
||||
color: Color(0xFF1A1A2E),
|
||||
),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Export',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1A1A2E),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
// ── Content ─────────────────────────────────────────
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
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',
|
||||
// 3-chip summary row (matches prototype)
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryChip(
|
||||
icon: UiIcons.warning,
|
||||
iconColor: UiColors.error,
|
||||
label: 'No-Shows',
|
||||
value: report.totalNoShows.toString(),
|
||||
color: UiColors.error,
|
||||
),
|
||||
_SummaryItem(
|
||||
label: 'No-Show Rate',
|
||||
value: '${report.noShowRate.toStringAsFixed(1)}%',
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryChip(
|
||||
icon: UiIcons.trendingUp,
|
||||
iconColor: UiColors.textWarning,
|
||||
label: 'Rate',
|
||||
value:
|
||||
'${report.noShowRate.toStringAsFixed(1)}%',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _SummaryChip(
|
||||
icon: UiIcons.user,
|
||||
iconColor: UiColors.primary,
|
||||
label: 'Workers',
|
||||
value: uniqueWorkers.toString(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Section title
|
||||
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),
|
||||
|
||||
// Worker cards with risk badges
|
||||
if (report.flaggedWorkers.isEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
alignment: Alignment.center,
|
||||
child: const Text(
|
||||
'No workers flagged for no-shows',
|
||||
style: TextStyle(
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...report.flaggedWorkers.map(
|
||||
(worker) => _WorkerCard(worker: worker),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── Reliability Insights box (matches prototype) ──
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: UiColors.textWarning.withOpacity(0.3),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'💡 Reliability Insights',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_InsightLine(
|
||||
text:
|
||||
'· Your no-show rate of ${report.noShowRate.toStringAsFixed(1)}% is '
|
||||
'${report.noShowRate < 5 ? 'below' : 'above'} industry average',
|
||||
),
|
||||
if (report.flaggedWorkers.any(
|
||||
(w) => w.noShowCount > 1,
|
||||
))
|
||||
_InsightLine(
|
||||
text:
|
||||
'· ${report.flaggedWorkers.where((w) => w.noShowCount > 1).length} '
|
||||
'worker(s) have multiple incidents this month',
|
||||
bold: true,
|
||||
),
|
||||
const _InsightLine(
|
||||
text:
|
||||
'· Consider implementing confirmation reminders 24hrs before shifts',
|
||||
bold: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -153,64 +279,197 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryItem extends StatelessWidget {
|
||||
// ── Summary chip (top 3 stats) ───────────────────────────────────────────────
|
||||
class _SummaryChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
const _SummaryItem({required this.label, required this.value, required this.color});
|
||||
const _SummaryChip({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@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)),
|
||||
],
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: iconColor),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WorkerListItem extends StatelessWidget {
|
||||
final dynamic worker;
|
||||
// ── Worker card with risk badge + latest incident ────────────────────────────
|
||||
class _WorkerCard extends StatelessWidget {
|
||||
final NoShowWorker worker;
|
||||
|
||||
const _WorkerListItem({required this.worker});
|
||||
const _WorkerCard({required this.worker});
|
||||
|
||||
String _riskLabel(int count) {
|
||||
if (count >= 3) return 'High Risk';
|
||||
if (count == 2) return 'Medium Risk';
|
||||
return 'Low Risk';
|
||||
}
|
||||
|
||||
Color _riskColor(int count) {
|
||||
if (count >= 3) return UiColors.error;
|
||||
if (count == 2) return UiColors.textWarning;
|
||||
return UiColors.success;
|
||||
}
|
||||
|
||||
Color _riskBg(int count) {
|
||||
if (count >= 3) return UiColors.tagError;
|
||||
if (count == 2) return UiColors.tagPending;
|
||||
return UiColors.tagSuccess;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final riskLabel = _riskLabel(worker.noShowCount);
|
||||
final riskColor = _riskColor(worker.noShowCount);
|
||||
final riskBg = _riskBg(worker.noShowCount);
|
||||
|
||||
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.04),
|
||||
blurRadius: 6,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
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,
|
||||
Row(
|
||||
children: [
|
||||
Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)),
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.user,
|
||||
color: UiColors.textSecondary,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
worker.fullName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${worker.noShowCount} no-show${worker.noShowCount > 1 ? 's' : ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Risk badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: riskBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
riskLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: riskColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
const SizedBox(height: 12),
|
||||
const Divider(height: 1, color: UiColors.bgSecondary),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)),
|
||||
const Text(
|
||||
'Latest incident',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
// Use reliabilityScore as a proxy for last incident date offset
|
||||
DateFormat('MMM dd, yyyy').format(
|
||||
DateTime.now().subtract(
|
||||
Duration(
|
||||
days: ((1.0 - worker.reliabilityScore) * 60).round(),
|
||||
),
|
||||
),
|
||||
),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: UiColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -218,3 +477,27 @@ class _WorkerListItem extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Insight line ─────────────────────────────────────────────────────────────
|
||||
class _InsightLine extends StatelessWidget {
|
||||
final String text;
|
||||
final bool bold;
|
||||
|
||||
const _InsightLine({required this.text, this.bold = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: UiColors.textPrimary,
|
||||
fontWeight: bold ? FontWeight.w600 : FontWeight.normal,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,90 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
||||
|
||||
if (state is PerformanceLoaded) {
|
||||
final report = state.report;
|
||||
|
||||
// Compute overall score (0–100) from the 4 KPIs
|
||||
final overallScore = ((report.fillRate * 0.3) +
|
||||
(report.completionRate * 0.3) +
|
||||
(report.onTimeRate * 0.25) +
|
||||
// avg fill time: 3h target → invert to score
|
||||
((report.avgFillTimeHours <= 3
|
||||
? 100
|
||||
: (3 / report.avgFillTimeHours) * 100) *
|
||||
0.15))
|
||||
.clamp(0.0, 100.0);
|
||||
|
||||
final scoreLabel = overallScore >= 90
|
||||
? 'Excellent'
|
||||
: overallScore >= 75
|
||||
? 'Good'
|
||||
: 'Needs Work';
|
||||
final scoreLabelColor = overallScore >= 90
|
||||
? UiColors.success
|
||||
: overallScore >= 75
|
||||
? UiColors.textWarning
|
||||
: UiColors.error;
|
||||
final scoreLabelBg = overallScore >= 90
|
||||
? UiColors.tagSuccess
|
||||
: overallScore >= 75
|
||||
? UiColors.tagPending
|
||||
: UiColors.tagError;
|
||||
|
||||
// KPI rows: label, value, target, color, met status
|
||||
final kpis = [
|
||||
_KpiData(
|
||||
icon: UiIcons.users,
|
||||
iconColor: UiColors.primary,
|
||||
label: 'Fill Rate',
|
||||
target: 'Target: 95%',
|
||||
value: report.fillRate,
|
||||
displayValue: '${report.fillRate.toStringAsFixed(0)}%',
|
||||
barColor: UiColors.primary,
|
||||
met: report.fillRate >= 95,
|
||||
close: report.fillRate >= 90,
|
||||
),
|
||||
_KpiData(
|
||||
icon: UiIcons.checkCircle,
|
||||
iconColor: UiColors.success,
|
||||
label: 'Completion Rate',
|
||||
target: 'Target: 98%',
|
||||
value: report.completionRate,
|
||||
displayValue: '${report.completionRate.toStringAsFixed(0)}%',
|
||||
barColor: UiColors.success,
|
||||
met: report.completionRate >= 98,
|
||||
close: report.completionRate >= 93,
|
||||
),
|
||||
_KpiData(
|
||||
icon: UiIcons.clock,
|
||||
iconColor: const Color(0xFF9B59B6),
|
||||
label: 'On-Time Rate',
|
||||
target: 'Target: 97%',
|
||||
value: report.onTimeRate,
|
||||
displayValue: '${report.onTimeRate.toStringAsFixed(0)}%',
|
||||
barColor: const Color(0xFF9B59B6),
|
||||
met: report.onTimeRate >= 97,
|
||||
close: report.onTimeRate >= 92,
|
||||
),
|
||||
_KpiData(
|
||||
icon: UiIcons.trendingUp,
|
||||
iconColor: const Color(0xFFF39C12),
|
||||
label: 'Avg Fill Time',
|
||||
target: 'Target: 3 hrs',
|
||||
// invert: lower is better — show as % of target met
|
||||
value: report.avgFillTimeHours == 0
|
||||
? 100
|
||||
: (3 / report.avgFillTimeHours * 100).clamp(0, 100),
|
||||
displayValue:
|
||||
'${report.avgFillTimeHours.toStringAsFixed(1)} hrs',
|
||||
barColor: const Color(0xFFF39C12),
|
||||
met: report.avgFillTimeHours <= 3,
|
||||
close: report.avgFillTimeHours <= 4,
|
||||
),
|
||||
];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
// ── Header ───────────────────────────────────────────
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
@@ -56,120 +136,198 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.performance_report.title,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.performance_report.subtitle,
|
||||
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
// Export
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Export coming soon'),
|
||||
duration: Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(UiIcons.download,
|
||||
size: 14, color: UiColors.primary),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Export',
|
||||
style: TextStyle(
|
||||
color: UiColors.primary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Content
|
||||
// ── 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,
|
||||
),
|
||||
],
|
||||
// ── Overall Score Hero Card ───────────────────
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 32,
|
||||
horizontal: 20,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F4FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
UiIcons.chart,
|
||||
size: 32,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'Overall Performance Score',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${overallScore.toStringAsFixed(0)}/100',
|
||||
style: const TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: scoreLabelBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
scoreLabel,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: scoreLabelColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// KPI List
|
||||
// ── 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)],
|
||||
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),
|
||||
'KEY PERFORMANCE INDICATORS',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
...kpis.map(
|
||||
(kpi) => _KpiRow(kpi: kpi),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
@@ -187,35 +345,137 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _StatTile extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
// ── KPI data model ────────────────────────────────────────────────────────────
|
||||
class _KpiData {
|
||||
final IconData icon;
|
||||
final Color iconColor;
|
||||
final String label;
|
||||
final String target;
|
||||
final double value; // 0–100 for bar
|
||||
final String displayValue;
|
||||
final Color barColor;
|
||||
final bool met;
|
||||
final bool close;
|
||||
|
||||
const _StatTile({required this.label, required this.value, required this.color, required this.icon});
|
||||
const _KpiData({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.label,
|
||||
required this.target,
|
||||
required this.value,
|
||||
required this.displayValue,
|
||||
required this.barColor,
|
||||
required this.met,
|
||||
required this.close,
|
||||
});
|
||||
}
|
||||
|
||||
// ── KPI row widget ────────────────────────────────────────────────────────────
|
||||
class _KpiRow extends StatelessWidget {
|
||||
final _KpiData kpi;
|
||||
|
||||
const _KpiRow({required this.kpi});
|
||||
|
||||
@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)],
|
||||
),
|
||||
final badgeText = kpi.met
|
||||
? '✓ Met'
|
||||
: kpi.close
|
||||
? '→ Close'
|
||||
: '✗ Miss';
|
||||
final badgeColor = kpi.met
|
||||
? UiColors.success
|
||||
: kpi.close
|
||||
? UiColors.textWarning
|
||||
: UiColors.error;
|
||||
final badgeBg = kpi.met
|
||||
? UiColors.tagSuccess
|
||||
: kpi.close
|
||||
? UiColors.tagPending
|
||||
: UiColors.tagError;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
Row(
|
||||
children: [
|
||||
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||
Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: kpi.iconColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(kpi.icon, size: 18, color: kpi.iconColor),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
kpi.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
kpi.target,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: UiColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
kpi.displayValue,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
badgeText,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: badgeColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: (kpi.value / 100).clamp(0.0, 1.0),
|
||||
backgroundColor: UiColors.bgSecondary,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(kpi.barColor),
|
||||
minHeight: 5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user