From 215ddcbc87790e230c0ca1b7672268cb6ed83cbe Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 16:09:59 +0530 Subject: [PATCH] reports page ui --- .../pages/coverage_report_page.dart | 573 +++++++++--------- .../pages/daily_ops_report_page.dart | 111 ++-- .../pages/no_show_report_page.dart | 465 +++++++++++--- .../pages/performance_report_page.dart | 460 +++++++++++--- .../dataconnect/connector/user/mutations.gql | 4 + .../dataconnect/connector/user/queries.gql | 3 + backend/dataconnect/schema/user.gql | 1 + 7 files changed, 1085 insertions(+), 532 deletions(-) diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart index 1491bb83..06031d10 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -38,10 +38,19 @@ class _CoverageReportPageState extends State { 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 { 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 { 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 { } } -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), + valueColor: AlwaysStoppedAnimation(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), - ), - ), - Icon( - percentage >= 1.0 ? UiIcons.checkCircle : UiIcons.trendingUp, - color: color, - size: size * 0.4, - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart index 1f0a2182..5e6d0d75 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -16,7 +16,35 @@ class DailyOpsReportPage extends StatefulWidget { } class _DailyOpsReportPageState extends State { - final DateTime _selectedDate = DateTime.now(); + DateTime _selectedDate = DateTime.now(); + + Future _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().add(LoadDailyOpsReport(date: picked)); + } + } + } @override Widget build(BuildContext context) { @@ -161,46 +189,49 @@ class _DailyOpsReportPageState extends State { 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), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart index a67392cb..9a735022 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -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 { 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 { 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 { } } -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, + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart index afca3373..cba7597a 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -37,10 +37,90 @@ class _PerformanceReportPageState extends State { 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 { ), ), 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 { } } -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(kpi.barColor), + minHeight: 5, + ), + ), ], ), ); diff --git a/backend/dataconnect/connector/user/mutations.gql b/backend/dataconnect/connector/user/mutations.gql index 05e233b6..f29b62d9 100644 --- a/backend/dataconnect/connector/user/mutations.gql +++ b/backend/dataconnect/connector/user/mutations.gql @@ -1,6 +1,7 @@ mutation CreateUser( $id: String!, # Firebase UID $email: String, + $phone: String, $fullName: String, $role: UserBaseRole!, $userRole: String, @@ -10,6 +11,7 @@ mutation CreateUser( data: { id: $id email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole @@ -21,6 +23,7 @@ mutation CreateUser( mutation UpdateUser( $id: String!, $email: String, + $phone: String, $fullName: String, $role: UserBaseRole, $userRole: String, @@ -30,6 +33,7 @@ mutation UpdateUser( id: $id, data: { email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole diff --git a/backend/dataconnect/connector/user/queries.gql b/backend/dataconnect/connector/user/queries.gql index 044abebf..760d633f 100644 --- a/backend/dataconnect/connector/user/queries.gql +++ b/backend/dataconnect/connector/user/queries.gql @@ -2,6 +2,7 @@ query listUsers @auth(level: USER) { users { id email + phone fullName role userRole @@ -17,6 +18,7 @@ query getUserById( user(id: $id) { id email + phone fullName role userRole @@ -40,6 +42,7 @@ query filterUsers( ) { id email + phone fullName role userRole diff --git a/backend/dataconnect/schema/user.gql b/backend/dataconnect/schema/user.gql index 4cb4ca24..4d932493 100644 --- a/backend/dataconnect/schema/user.gql +++ b/backend/dataconnect/schema/user.gql @@ -6,6 +6,7 @@ enum UserBaseRole { type User @table(name: "users") { id: String! # user_id / uid de Firebase email: String + phone: String fullName: String role: UserBaseRole! userRole: String