From d589c9bca2aedde5296e66f2204dbda950248ad0 Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 15:10:01 +0530 Subject: [PATCH 01/10] reports page implementation --- apps/mobile/apps/staff/lib/main.dart | 6 +- .../lib/src/widgets/session_listener.dart | 16 +- .../lib/src/l10n/en.i18n.json | 313 ++++++++ .../lib/src/l10n/es.i18n.json | 313 ++++++++ .../design_system/lib/src/ui_icons.dart | 3 + .../lib/src/client_main_module.dart | 7 +- .../widgets/client_main_bottom_bar.dart | 8 + .../features/client/client_main/pubspec.yaml | 2 + .../client/reports/analysis_output.txt | Bin 0 -> 75444 bytes .../client/reports/lib/client_reports.dart | 4 + .../reports_repository_impl.dart | 467 ++++++++++++ .../src/domain/entities/coverage_report.dart | 35 + .../src/domain/entities/daily_ops_report.dart | 60 ++ .../src/domain/entities/forecast_report.dart | 33 + .../src/domain/entities/no_show_report.dart | 33 + .../domain/entities/performance_report.dart | 35 + .../src/domain/entities/reports_summary.dart | 29 + .../lib/src/domain/entities/spend_report.dart | 63 ++ .../repositories/reports_repository.dart | 50 ++ .../blocs/daily_ops/daily_ops_bloc.dart | 30 + .../blocs/daily_ops/daily_ops_event.dart | 21 + .../blocs/daily_ops/daily_ops_state.dart | 31 + .../blocs/forecast/forecast_bloc.dart | 31 + .../blocs/forecast/forecast_event.dart | 23 + .../blocs/forecast/forecast_state.dart | 31 + .../blocs/no_show/no_show_bloc.dart | 31 + .../blocs/no_show/no_show_event.dart | 23 + .../blocs/no_show/no_show_state.dart | 31 + .../blocs/performance/performance_bloc.dart | 31 + .../blocs/performance/performance_event.dart | 23 + .../blocs/performance/performance_state.dart | 31 + .../presentation/blocs/spend/spend_bloc.dart | 31 + .../presentation/blocs/spend/spend_event.dart | 23 + .../presentation/blocs/spend/spend_state.dart | 31 + .../blocs/summary/reports_summary_bloc.dart | 31 + .../blocs/summary/reports_summary_event.dart | 23 + .../blocs/summary/reports_summary_state.dart | 31 + .../pages/coverage_report_page.dart | 471 ++++++++++++ .../pages/daily_ops_report_page.dart | 562 ++++++++++++++ .../pages/forecast_report_page.dart | 359 +++++++++ .../pages/no_show_report_page.dart | 220 ++++++ .../pages/performance_report_page.dart | 223 ++++++ .../src/presentation/pages/reports_page.dart | 696 ++++++++++++++++++ .../presentation/pages/spend_report_page.dart | 674 +++++++++++++++++ .../reports/lib/src/reports_module.dart | 46 ++ .../features/client/reports/pubspec.yaml | 39 + .../auth_repository_impl.dart | 51 +- .../auth_repository_interface.dart | 3 + .../lib/src/staff_authentication_module.dart | 6 +- .../lib/staff_authentication.dart | 1 + apps/mobile/pubspec.yaml | 1 + 51 files changed, 5325 insertions(+), 11 deletions(-) create mode 100644 apps/mobile/packages/features/client/reports/analysis_output.txt create mode 100644 apps/mobile/packages/features/client/reports/lib/client_reports.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart create mode 100644 apps/mobile/packages/features/client/reports/lib/src/reports_module.dart create mode 100644 apps/mobile/packages/features/client/reports/pubspec.yaml diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 1858e1bd..d127d3e1 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -40,7 +40,11 @@ void main() async { /// The main application module. class AppModule extends Module { @override - List get imports => [core_localization.LocalizationModule()]; + List get imports => + [ + core_localization.LocalizationModule(), + staff_authentication.StaffAuthenticationModule(), + ]; @override void routes(RouteManager r) { diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index 258bd901..225c67ec 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:staff_authentication/staff_authentication.dart'; /// A widget that listens to session state changes and handles global reactions. /// @@ -40,7 +41,7 @@ class _SessionListenerState extends State { debugPrint('[SessionListener] Initialized session listener'); } - void _handleSessionChange(SessionState state) { + Future _handleSessionChange(SessionState state) async { if (!mounted) return; switch (state.type) { @@ -65,6 +66,19 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); + if (StaffSessionStore.instance.session == null) { + try { + final AuthRepositoryInterface authRepo = + Modular.get(); + await authRepo.restoreSession(); + } catch (e) { + if (mounted) { + _proceedToLogin(); + } + return; + } + } + // Navigate to the main app Modular.to.toStaffHome(); break; diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 0241ab37..e6ed7227 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1140,5 +1140,318 @@ "availability": { "updated": "Availability updated successfully" } + }, + "client_reports": { + "title": "Workforce Control Tower", + "tabs": { + "today": "Today", + "week": "Week", + "month": "Month", + "quarter": "Quarter" + }, + "metrics": { + "total_hrs": { + "label": "Total Hrs", + "badge": "This period" + }, + "ot_hours": { + "label": "OT Hours", + "badge": "5.1% of total" + }, + "total_spend": { + "label": "Total Spend", + "badge": "↓ 8% vs last week" + }, + "fill_rate": { + "label": "Fill Rate", + "badge": "↑ 2% improvement" + }, + "avg_fill_time": { + "label": "Avg Fill Time", + "badge": "Industry best" + }, + "no_show_rate": { + "label": "No-Show Rate", + "badge": "Below avg" + } + }, + "quick_reports": { + "title": "Quick Reports", + "export_all": "Export All", + "two_click_export": "2-click export", + "cards": { + "daily_ops": "Daily Ops Report", + "spend": "Spend Report", + "coverage": "Coverage Report", + "no_show": "No-Show Report", + "forecast": "Forecast Report", + "performance": "Performance Report" + } + }, + "ai_insights": { + "title": "AI Insights", + "insight_1": { + "prefix": "You could save ", + "highlight": "USD 1,200/month", + "suffix": " by booking workers 48hrs in advance" + }, + "insight_2": { + "prefix": "Weekend demand is ", + "highlight": "40% higher", + "suffix": " - consider scheduling earlier" + }, + "insight_3": { + "prefix": "Your top 5 workers complete ", + "highlight": "95% of shifts", + "suffix": " - mark them as preferred" + } + }, + "daily_ops_report": { + "title": "Daily Ops Report", + "subtitle": "Real-time shift tracking", + "metrics": { + "scheduled": { + "label": "Scheduled", + "sub_value": "shifts" + }, + "workers": { + "label": "Workers", + "sub_value": "confirmed" + }, + "in_progress": { + "label": "In Progress", + "sub_value": "active now" + }, + "completed": { + "label": "Completed", + "sub_value": "done today" + } + }, + "all_shifts_title": "ALL SHIFTS", + "shift_item": { + "time": "Time", + "workers": "Workers", + "rate": "Rate" + }, + "statuses": { + "processing": "Processing", + "filling": "Filling", + "confirmed": "Confirmed", + "completed": "Completed" + }, + "placeholders": { + "export_message": "Exporting Daily Operations Report (Placeholder)" + } + }, + "spend_report": { + "title": "Spend Report", + "subtitle": "Cost analysis & breakdown", + "summary": { + "total_spend": "Total Spend", + "avg_daily": "Avg Daily", + "this_week": "This week", + "per_day": "Per day" + }, + "chart_title": "Daily Spend Trend", + "charts": { + "mon": "Mon", + "tue": "Tue", + "wed": "Wed", + "thu": "Thu", + "fri": "Fri", + "sat": "Sat", + "sun": "Sun" + }, + "spend_by_industry": "Spend by Industry", + "industries": { + "hospitality": "Hospitality", + "events": "Events", + "retail": "Retail" + }, + "percent_total": "$percent% of total", + "insights": { + "title": "Cost Insights", + "insight_1": { + "prefix": "Your spend is ", + "highlight": "8% lower", + "suffix": " than last week" + }, + "insight_2": { + "prefix": "", + "highlight": "Friday", + "suffix": " had the highest spend (USD 4.1k)" + }, + "insight_3": { + "prefix": "Hospitality accounts for ", + "highlight": "48%", + "suffix": " of total costs" + } + }, + "placeholders": { + "export_message": "Exporting Spend Report (Placeholder)" + } + }, + "forecast_report": { + "title": "Forecast Report", + "subtitle": "Next 4 weeks projection", + "summary": { + "four_week": "4-Week Forecast", + "avg_weekly": "Avg Weekly", + "total_shifts": "Total Shifts", + "total_hours": "Total Hours", + "total_projected": "Total projected", + "per_week": "Per week", + "scheduled": "Scheduled", + "worker_hours": "Worker hours" + }, + "spending_forecast": "Spending Forecast", + "weekly_breakdown": "WEEKLY BREAKDOWN", + "breakdown_headings": { + "shifts": "Shifts", + "hours": "Hours", + "avg_shift": "Avg/Shift" + }, + "insights": { + "title": "Forecast Insights", + "insight_1": { + "prefix": "Demand is expected to spike by ", + "highlight": "25%", + "suffix": " in week 3" + }, + "insight_2": { + "prefix": "Projected spend for next month is ", + "highlight": "USD 68.4k", + "suffix": "" + }, + "insight_3": { + "prefix": "Consider increasing budget for ", + "highlight": "Holiday Season", + "suffix": " coverage" + } + }, + "placeholders": { + "export_message": "Exporting Forecast Report (Placeholder)" + } + }, + "performance_report": { + "title": "Performance Report", + "subtitle": "Key metrics & benchmarks", + "overall_score": { + "title": "Overall Performance Score", + "label": "Excellent" + }, + "kpis_title": "KEY PERFORMANCE INDICATORS", + "kpis": { + "fill_rate": "Fill Rate", + "completion_rate": "Completion Rate", + "on_time_rate": "On-Time Rate", + "avg_fill_time": "Avg Fill Time", + "target_prefix": "Target: ", + "met": "✓ Met", + "close": "↗ Close" + }, + "additional_metrics_title": "ADDITIONAL METRICS", + "additional_metrics": { + "total_shifts": "Total Shifts", + "no_show_rate": "No-Show Rate", + "worker_pool": "Worker Pool", + "avg_rating": "Avg Rating" + }, + "insights": { + "title": "Performance Insights", + "insight_1": { + "prefix": "Your fill rate is ", + "highlight": "4% above", + "suffix": " industry benchmark" + }, + "insight_2": { + "prefix": "Worker retention is at ", + "highlight": "high", + "suffix": " levels this quarter" + }, + "insight_3": { + "prefix": "On-time arrival improved by ", + "highlight": "12%", + "suffix": " since last month" + } + }, + "placeholders": { + "export_message": "Exporting Performance Report (Placeholder)" + } + }, + "no_show_report": { + "title": "No-Show Report", + "subtitle": "Reliability tracking", + "metrics": { + "no_shows": "No-Shows", + "rate": "Rate", + "workers": "Workers" + }, + "workers_list_title": "WORKERS WITH NO-SHOWS", + "no_show_count": "$count no-show(s)", + "latest_incident": "Latest incident", + "risks": { + "high": "High Risk", + "medium": "Medium Risk", + "low": "Low Risk" + }, + "insights": { + "title": "Reliability Insights", + "insight_1": { + "prefix": "Your no-show rate of ", + "highlight": "1.2%", + "suffix": " is below industry average" + }, + "insight_2": { + "prefix": "", + "highlight": "1 worker", + "suffix": " has multiple incidents this month" + }, + "insight_3": { + "prefix": "Consider implementing ", + "highlight": "confirmation reminders", + "suffix": " 24hrs before shifts" + } + }, + "placeholders": { + "export_message": "Exporting No-Show Report (Placeholder)" + } + }, + "coverage_report": { + "title": "Coverage Report", + "subtitle": "Staffing levels & gaps", + "metrics": { + "avg_coverage": "Avg Coverage", + "full": "Full", + "needs_help": "Needs Help" + }, + "next_7_days": "NEXT 7 DAYS", + "shift_item": { + "confirmed_workers": "$confirmed/$needed workers confirmed", + "spots_remaining": "$count spots remaining", + "fully_staffed": "Fully staffed" + }, + "insights": { + "title": "Coverage Insights", + "insight_1": { + "prefix": "Your average coverage rate is ", + "highlight": "96%", + "suffix": " - above industry standard" + }, + "insight_2": { + "prefix": "", + "highlight": "2 days", + "suffix": " need immediate attention to reach full coverage" + }, + "insight_3": { + "prefix": "Weekend coverage is typically ", + "highlight": "98%", + "suffix": " vs weekday 94%" + } + }, + "placeholders": { + "export_message": "Exporting Coverage Report (Placeholder)" + } + } } } \ No newline at end of file diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index ee54965e..0540568a 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1140,5 +1140,318 @@ "availability": { "updated": "Disponibilidad actualizada con éxito" } + }, + "client_reports": { + "title": "Torre de Control de Personal", + "tabs": { + "today": "Hoy", + "week": "Semana", + "month": "Mes", + "quarter": "Trimestre" + }, + "metrics": { + "total_hrs": { + "label": "Total de Horas", + "badge": "Este período" + }, + "ot_hours": { + "label": "Horas Extra", + "badge": "5.1% del total" + }, + "total_spend": { + "label": "Gasto Total", + "badge": "↓ 8% vs semana pasada" + }, + "fill_rate": { + "label": "Tasa de Cobertura", + "badge": "↑ 2% de mejora" + }, + "avg_fill_time": { + "label": "Tiempo Promedio de Llenado", + "badge": "Mejor de la industria" + }, + "no_show_rate": { + "label": "Tasa de Faltas", + "badge": "Bajo el promedio" + } + }, + "quick_reports": { + "title": "Informes Rápidos", + "export_all": "Exportar Todo", + "two_click_export": "Exportación en 2 clics", + "cards": { + "daily_ops": "Informe de Ops Diarias", + "spend": "Informe de Gastos", + "coverage": "Informe de Cobertura", + "no_show": "Informe de Faltas", + "forecast": "Informe de Previsión", + "performance": "Informe de Rendimiento" + } + }, + "ai_insights": { + "title": "Perspectivas de IA", + "insight_1": { + "prefix": "Podrías ahorrar ", + "highlight": "USD 1,200/mes", + "suffix": " reservando trabajadores con 48h de antelación" + }, + "insight_2": { + "prefix": "La demanda del fin de semana es un ", + "highlight": "40% superior", + "suffix": " - considera programar antes" + }, + "insight_3": { + "prefix": "Tus 5 mejores trabajadores completan el ", + "highlight": "95% de los turnos", + "suffix": " - márcalos como preferidos" + } + }, + "daily_ops_report": { + "title": "Informe de Ops Diarias", + "subtitle": "Seguimiento de turnos en tiempo real", + "metrics": { + "scheduled": { + "label": "Programado", + "sub_value": "turnos" + }, + "workers": { + "label": "Trabajadores", + "sub_value": "confirmados" + }, + "in_progress": { + "label": "En Progreso", + "sub_value": "activos ahora" + }, + "completed": { + "label": "Completado", + "sub_value": "hechos hoy" + } + }, + "all_shifts_title": "TODOS LOS TURNOS", + "shift_item": { + "time": "Hora", + "workers": "Trabajadores", + "rate": "Tarifa" + }, + "statuses": { + "processing": "Procesando", + "filling": "Llenando", + "confirmed": "Confirmado", + "completed": "Completado" + }, + "placeholders": { + "export_message": "Exportando Informe de Operaciones Diarias (Marcador de posición)" + } + }, + "spend_report": { + "title": "Informe de Gastos", + "subtitle": "Análisis y desglose de costos", + "summary": { + "total_spend": "Gasto Total", + "avg_daily": "Promedio Diario", + "this_week": "Esta semana", + "per_day": "Por día" + }, + "chart_title": "Tendencia de Gasto Diario", + "charts": { + "mon": "Lun", + "tue": "Mar", + "wed": "Mié", + "thu": "Jue", + "fri": "Vie", + "sat": "Sáb", + "sun": "Dom" + }, + "spend_by_industry": "Gasto por Industria", + "industries": { + "hospitality": "Hostelería", + "events": "Eventos", + "retail": "Venta minorista" + }, + "percent_total": "$percent% del total", + "insights": { + "title": "Perspectivas de Costos", + "insight_1": { + "prefix": "Tu gasto es un ", + "highlight": "8% menor", + "suffix": " que la semana pasada" + }, + "insight_2": { + "prefix": "El ", + "highlight": "Viernes", + "suffix": " tuvo el mayor gasto (USD 4.1k)" + }, + "insight_3": { + "prefix": "La hostelería representa el ", + "highlight": "48%", + "suffix": " de los costos totales" + } + }, + "placeholders": { + "export_message": "Exportando Informe de Gastos (Marcador de posición)" + } + }, + "forecast_report": { + "title": "Informe de Previsión", + "subtitle": "Proyección para las próximas 4 semanas", + "summary": { + "four_week": "Previsión de 4 Semanas", + "avg_weekly": "Promedio Semanal", + "total_shifts": "Total de Turnos", + "total_hours": "Total de Horas", + "total_projected": "Total proyectado", + "per_week": "Por semana", + "scheduled": "Programado", + "worker_hours": "Horas de trabajadores" + }, + "spending_forecast": "Previsión de Gastos", + "weekly_breakdown": "DESGLOSE SEMANAL", + "breakdown_headings": { + "shifts": "Turnos", + "hours": "Horas", + "avg_shift": "Prom/Turno" + }, + "insights": { + "title": "Perspectivas de Previsión", + "insight_1": { + "prefix": "Se espera que la demanda aumente un ", + "highlight": "25%", + "suffix": " en la semana 3" + }, + "insight_2": { + "prefix": "El gasto proyectado para el próximo mes es de ", + "highlight": "USD 68.4k", + "suffix": "" + }, + "insight_3": { + "prefix": "Considera aumentar el presupuesto para la cobertura de ", + "highlight": "Temporada de Vacaciones", + "suffix": "" + } + }, + "placeholders": { + "export_message": "Exportando Informe de Previsión (Marcador de posición)" + } + }, + "performance_report": { + "title": "Informe de Rendimiento", + "subtitle": "Métricas clave y comparativas", + "overall_score": { + "title": "Puntuación de Rendimiento General", + "label": "Excelente" + }, + "kpis_title": "INDICADORES CLAVE DE RENDIMIENTO (KPI)", + "kpis": { + "fill_rate": "Tasa de Llenado", + "completion_rate": "Tasa de Finalización", + "on_time_rate": "Tasa de Puntualidad", + "avg_fill_time": "Tiempo Promedio de Llenado", + "target_prefix": "Objetivo: ", + "met": "✓ Cumplido", + "close": "↗ Cerca" + }, + "additional_metrics_title": "MÉTRICAS ADICIONALES", + "additional_metrics": { + "total_shifts": "Total de Turnos", + "no_show_rate": "Tasa de Faltas", + "worker_pool": "Grupo de Trabajadores", + "avg_rating": "Calificación Promedio" + }, + "insights": { + "title": "Perspectivas de Rendimiento", + "insight_1": { + "prefix": "Tu tasa de llenado es un ", + "highlight": "4% superior", + "suffix": " al promedio de la industria" + }, + "insight_2": { + "prefix": "La retención de trabajadores está en niveles ", + "highlight": "altos", + "suffix": " este trimestre" + }, + "insight_3": { + "prefix": "La llegada puntual mejoró un ", + "highlight": "12%", + "suffix": " desde el mes pasado" + } + }, + "placeholders": { + "export_message": "Exportando Informe de Rendimiento (Marcador de posición)" + } + }, + "no_show_report": { + "title": "Informe de Faltas", + "subtitle": "Seguimiento de confiabilidad", + "metrics": { + "no_shows": "Faltas", + "rate": "Tasa", + "workers": "Trabajadores" + }, + "workers_list_title": "TRABAJADORES CON FALTAS", + "no_show_count": "$count falta(s)", + "latest_incident": "Último incidente", + "risks": { + "high": "Riesgo Alto", + "medium": "Riesgo Medio", + "low": "Riesgo Bajo" + }, + "insights": { + "title": "Perspectivas de Confiabilidad", + "insight_1": { + "prefix": "Tu tasa de faltas del ", + "highlight": "1.2%", + "suffix": " está por debajo del promedio de la industria" + }, + "insight_2": { + "prefix": "", + "highlight": "1 trabajador", + "suffix": " tiene múltiples incidentes este mes" + }, + "insight_3": { + "prefix": "Considera implementar ", + "highlight": "recordatorios de confirmación", + "suffix": " 24h antes de los turnos" + } + }, + "placeholders": { + "export_message": "Exportando Informe de Faltas (Marcador de posición)" + } + }, + "coverage_report": { + "title": "Informe de Cobertura", + "subtitle": "Niveles de personal y brechas", + "metrics": { + "avg_coverage": "Cobertura Promedio", + "full": "Completa", + "needs_help": "Necesita Ayuda" + }, + "next_7_days": "PRÓXIMOS 7 DÍAS", + "shift_item": { + "confirmed_workers": "$confirmed/$needed trabajadores confirmados", + "spots_remaining": "$count puestos restantes", + "fully_staffed": "Totalmente cubierto" + }, + "insights": { + "title": "Perspectivas de Cobertura", + "insight_1": { + "prefix": "Tu tasa de cobertura promedio es del ", + "highlight": "96%", + "suffix": " - por encima del estándar de la industria" + }, + "insight_2": { + "prefix": "", + "highlight": "2 días", + "suffix": " necesitan atención inmediata para alcanzar la cobertura completa" + }, + "insight_3": { + "prefix": "La cobertura de fin de semana es típicamente del ", + "highlight": "98%", + "suffix": " vs 94% en días laborables" + } + }, + "placeholders": { + "export_message": "Exportando Informe de Cobertura (Marcador de posición)" + } + } } } \ No newline at end of file diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index cd813769..f73df6d4 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -184,6 +184,9 @@ class UiIcons { /// Trending down icon for insights static const IconData trendingDown = _IconLib.trendingDown; + /// Trending up icon for insights + static const IconData trendingUp = _IconLib.trendingUp; + /// Target icon for metrics static const IconData target = _IconLib.target; diff --git a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart index 78af8afa..24762388 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/client_main_module.dart @@ -1,4 +1,5 @@ import 'package:billing/billing.dart'; +import 'package:client_reports/client_reports.dart'; import 'package:client_home/client_home.dart'; import 'package:client_coverage/client_coverage.dart'; import 'package:flutter/material.dart'; @@ -8,7 +9,6 @@ import 'package:view_orders/view_orders.dart'; import 'presentation/blocs/client_main_cubit.dart'; import 'presentation/pages/client_main_page.dart'; -import 'presentation/pages/placeholder_page.dart'; class ClientMainModule extends Module { @override @@ -38,10 +38,9 @@ class ClientMainModule extends Module { ClientPaths.childRoute(ClientPaths.main, ClientPaths.orders), module: ViewOrdersModule(), ), - ChildRoute( + ModuleRoute( ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports), - child: (BuildContext context) => - const PlaceholderPage(title: 'Reports'), + module: ReportsModule(), ), ], ); diff --git a/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart b/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart index d7d18428..a5a60dab 100644 --- a/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart +++ b/apps/mobile/packages/features/client/client_main/lib/src/presentation/widgets/client_main_bottom_bar.dart @@ -36,6 +36,7 @@ class ClientMainBottomBar extends StatelessWidget { @override Widget build(BuildContext context) { + final t = Translations.of(context); // Client App colors from design system const Color activeColor = UiColors.textPrimary; const Color inactiveColor = UiColors.textInactive; @@ -99,6 +100,13 @@ class ClientMainBottomBar extends StatelessWidget { activeColor: activeColor, inactiveColor: inactiveColor, ), + _buildNavItem( + index: 4, + icon: UiIcons.chart, + label: t.client_main.tabs.reports, + activeColor: activeColor, + inactiveColor: inactiveColor, + ), ], ), ), diff --git a/apps/mobile/packages/features/client/client_main/pubspec.yaml b/apps/mobile/packages/features/client/client_main/pubspec.yaml index 4120e53f..4420cdcd 100644 --- a/apps/mobile/packages/features/client/client_main/pubspec.yaml +++ b/apps/mobile/packages/features/client/client_main/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: path: ../home client_coverage: path: ../client_coverage + client_reports: + path: ../reports view_orders: path: ../view_orders billing: diff --git a/apps/mobile/packages/features/client/reports/analysis_output.txt b/apps/mobile/packages/features/client/reports/analysis_output.txt new file mode 100644 index 0000000000000000000000000000000000000000..e9cdc3824ef709b45d8cdbad68533c51b133156b GIT binary patch literal 75444 zcmeI5U2o&Y6^40Tp#MR=@&-kX*z)Jz6hXfbG(~`R)2jfE*7oi?^;)iE$8d}O^|tRb zN5_;Lt)w|jY!68ownd5@&U@y}`JUnb{`=SJyqZ?y>azNwx~Q(I_tl^})46N?UZqc0 z)v3PWszJJ9P<>bZp?Y0?Tm8A3=+3+9H@a)ApZ99zT<8DsaQ;qbCpvzY{-36AM{4_8 zt$ka4)Y-Rcle@mFw$slqt6x;V*ZDg={Zju&>Vx;|T1Tg9ZKNmBpZbo0`gWqHoa^sM zcaC)Lef2LLPjqHlebAYSj?Q%5akZm6KTp4d>XnZ7=8i|A!zlG>q`uL!&no)F6CSrb zsE%|eckk%iSNdzMZdbeM>QyaGgye&s^Mjr+sD^6e;GrGw&+(d8=-*iFSU*6ur_Ugw z2VkVnpYDak(Mp);Mu9{4v$fxmu((Mr>z>8jOr$%^j;-oQk%C zG;TFF9j5ly)BY?WyGh)6BVL8mj1PGJF!cnk;0U^{>d8BC{)MhF%G&j>($OYd@J`P@ zN!$pn;Q`PbBwX;ZQDl?xqNQ84U}JTX=FcL39;Ei1$JUKMvmD7p8zZ%fEMfd@%#45N zozItbvu;^?CNIv^@>n!PKF}I-(Xj996VHpaeJ2EO)h6R$qXRv8f{*g5aiza-$c6q6 zG$Wan##x)s@p;LNgdA&L8s}SEjuS~{bY0DBi|u{XxFE}Ed2#F=Ic-8iM(NixzUG`u zyVdw>t-^CI(vwDs$37%Zuy$}b`ff$S#96O;c&595B*D)VQKG>+?AqooU7XOhZf0-7CZgZ;n^aT~>2 zcX{S4Wr2Fn1=47Xj* z# zW+pVhm5i)4d_AXVbscBUTd*hU8MZ9eF8{I5vEF`61(R)HW|o;nz!)I0R`bYIBgY8h z4RD8#SFOElK5yCzw!o+C!=I-8Mg7yP*JJe+`<(eVO?+@AI@k=Zd7wUC#|5=@%{{wG z_nC!f$1bN=%`ki%m{W(P z?j`H}vFAESo{jl__(@nxNEyx`b4-pxKO{aQ^)c}wlF7W%f$o7W*g-$`*Y9B@nwH47ZS-8hF$g4Q z3w`H)IM*Rdxa&IIjSXu4JD&B=@q^dyp1aYJo(>eP7*t;+Izktt13mqyzCm%$F)Pfb z;y=2Q@1_se(*{<@TSYNW(myq*WX?Jx8A}Hptx6`O|nAS3vtlBj=k^GG-~CcMXrMuc?_M~jdmzG z+$FSd@-W27Cfi(68gFbpNbj%H%wZP5|6Af4X0*$6q>05oEOu#D^*~qPh2CmDooS|S z>E40l2J`+_-*N4>K7W%w5A@k}#orTG-s*~}^!#VK2HVs$sK-A{9?&D#L&!R`IsJNC z39_g8^jcEHC5UOdMXH=6$^9|mH=hs+lqoUylf)b*n;}<*z-L$wcpTUg-2bJxgZM9c zh1gbY6TnGujeQG8JoVTf$SYw^;hWG`W=P#vyj-_mtgo)iA7gxzY|cT-33%*rpgD1; z^USJqN#xHTW|q$uIEkn!+8xTcZG%MxEnFMX(l@&9cj^tZ0PBod`n2=FXtnpCTd*G% z5u7aGFO&O^8*qPl_5wcjVbY=aY`L?&M7%@ILuUN26L_(E&1%4l&K=+0YQRQ``5jQ6 znYEwdTu?sGZs=*>g!a2Lo6Yrl9_`DMYv6_3Vpdy;cn>wd@b0^zw{~Trf;Z;bFOwzM zk{+46m7VLEZDtD5BqC6C90spz-rsXi4}Z_BR_s+`Jy+5)wbe`&#wKk)ttE>MuqSFd z?~uhLS~PF(&-dJN%NNP+zA4nJBW{4NV|ka}67b0B&mVytCYsO7=C*4i5W{O-kEYQ9>bZ8`iH-g2|}F}&sG@uPVA zOn%c$evI-Z=kjBSOU>rT&~`PS--KEvW%Oe>kBiNX#PE{UKTaNx$56_t1B@ajYDA2c z8t*>39gA+qX-~Ab%nG?EX1vb%n11_b7owC;Y9?q;?RH%iQ&Ma{%5PYIW#(Svh>9DT|QU;<~&N<3)N69MamS#FiK6;5&LGe+xI7~!NKX2waiugELAF(>s5)qSa zr*fxXmucS3Q`+}rbExd7*YCC8zTXpKs}$>sOxcsxV3b);lkY*7DN_z+@q1r$TuyY+ zR|BwOoP9qwtM9N~3%Y^US6r6I&5^Mk&T8+vg4VK75l&tfG4A3X4G}IgomgfDDuQ=^ zmhFtL>V3R_!Bg$g5W#A?u(t8KvPVOlRwY$*L{J_#;Qr^e2P0^S=iACXk59JU8pM~z zt1HjLiH+}mvi7@!*H_&1wL0;2&oNH3O1SRWNvw7*wN588!t4V<_V+-4ON*66@#1mS zyR5WZHlhbw`|yM`u4sML&=`>_I3>Gbu2#~qZrji)J3{2G5^kv`Ms{@KQHck<@?<} z-n+i-lw*GPl(RQ16_-jnX6XU(TsBC#kcWjZDmn3oAp>a^;y%+ z>Mo!>^@)2AYjT?F)SmX6(*96%CvxRnpGW%==lS6*&*6yM?dwyWh(s^3-Z^4#QiEX{ zEE1bsW}mlZkJ=bD_mfuioNsBXC1aQmQ*^A~qxPwNMXYwKq;}cdc5Sq$nu=1_Nyg@u zt-g+}lI*4P;vTj5YOLz+l62megs^y0zmolF>o%bLqKDje>5NxoYd(C2wcA$!uf@%} zQS(Oqveh(3e1-CS-+iHlpJUp$JiG_ejzpG9T(iYnOn5UaZ@0YLaQ)I^o~0aFm5;UD zy=n8YHi>6E@0^WbwK_@9KIHK5GVh>^ao~Kam%JZsKH5_EqRmIz)qQBAl&b6hUjAEU z^D+8*2am1g$kx*INXc)M%^%sFSsbXuwLAG}iOVnUruUK#VP82$#Q0RX}W4x<4P2�-68Y@5W~mdTK||SX}VivjPB*;ej<2x5}$QLCbOy`p7-fx^5T1A?LB~9lF0|XOm4h`Z$w5t zu2|3W$4ax4S(999C2M4K4`;Kgyj*+)t-pEN03o*DN(Z9mia5 zo;r@X+0MyK*)` zZF@@e?^$iKLaQ15i5Hf+gJToi?3gwGCchjnby=LWoE=P>QO!I*ud4$WtTF2MP_4M;Sur`fW_59aF>aqH!IscU_^=%h{*dBeQ zqUId|W+9cAwTScjQ1z(xu7F5w^)e0B#dRyq=uc*FU*qso?aJ7XK#sO#2B0mw<`I(X zfb&YMRBbZf4pY5QiPcd}P#?{zq}-2f>ixH+ay3pb=fA zUMDgCZi5`__vNEQ^MWqoDZYGkR(aN5be65{_aKUExlJSh@6d@y43}A_UG#uAp|q{7 zVBge8bo}MZBVtJA=eSkgE^SipX46i6!u~bLg1XkDUE{}&HSNY{?+cBMUoXjRUzklK zIWbGCr8{b=EC-?4Uy*l{_PmtGOGLn8zs;6-pCtWc~jlXk3ZL^t?F+&np7A1=Dm*psOxyQJ(x)?Bb*B5T6bJaIbEi7~x_VoEudBH4KhF14pC+UjiJ^ig(-q!a`YUVc^6=F76Gxew}Qu9Q8 zwLYE**H}j<3rI&oeggoA44f0r(oM zKQO;0hnHTyZ$TE^;$5D*Xd6DOn|B^3ZM979eCrRf*Lv=J{VKZnN2n zOS7BGlVo`~z3$s}QaKVW59cuPZHZMPxj5n5KKIomu9`=2e5`oo$n#n~c}Pva)zOX| zS%G!%IKKa-;>|xO)_kdGEYbAS6tSNw0!`$dxVwFOt2>`=JiEHDR~|1Il}g&nD-WOh zYUNq_c{uB8o`}s~lJqM1y;Qkl>U(h?%;aXSq#j5jUnxU2p55QYm`*;;$-0!e<~$dx zt+F3BfrGoItiS&{5OS^V!v+dtC{=|O=}`WW}JwxjLR8&3@DqwR6LUCAPl5{GX8m{Lz!;+}1J z^VBP24_dEfki6(qGNtY2UXT)rW{={0q^s;ntFHYZ+mM*yR;uZ}maRBa{P4W`LZ2?z z+KhO%R-OAH#TLB+mi8W35K{~MR5m6a)#8eZWj5wK1+XZ)OD%ubo3B6Vsl_Vy<@G02 zfamj8NjCgY>kq8LChEiXaj$RXX|u9o(>yse#~!OQ0eMwT$gZC1`Bb+$_AW#liM`pn zsmQD@Nq+ghU|3H1Hd9$h4^q~={dGjhMXQO7c;?HbV_RzK^7IVVvv|WEohz*tI}a^# zojN2DDAHvz zqnny$UpfYj-3?8{o~-?9(WY;U&6}xY-WK1`(OyjhR^)H{Eq$^Vars-4^#D>^H5>71 Z%0jw7v%@3nXUjs$Or^Rdf|M-8{{arThN}Po literal 0 HcmV?d00001 diff --git a/apps/mobile/packages/features/client/reports/lib/client_reports.dart b/apps/mobile/packages/features/client/reports/lib/client_reports.dart new file mode 100644 index 00000000..1ea6bd62 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/client_reports.dart @@ -0,0 +1,4 @@ +library client_reports; + +export 'src/reports_module.dart'; +export 'src/presentation/pages/reports_page.dart'; diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart new file mode 100644 index 00000000..46d8b323 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -0,0 +1,467 @@ +import 'package:krow_data_connect/krow_data_connect.dart'; +import '../../domain/entities/daily_ops_report.dart'; +import '../../domain/entities/spend_report.dart'; +import '../../domain/entities/coverage_report.dart'; +import '../../domain/entities/forecast_report.dart'; +import '../../domain/entities/performance_report.dart'; +import '../../domain/entities/no_show_report.dart'; +import '../../domain/entities/reports_summary.dart'; +import '../../domain/repositories/reports_repository.dart'; + +class ReportsRepositoryImpl implements ReportsRepository { + final DataConnectService _service; + + ReportsRepositoryImpl({DataConnectService? service}) + : _service = service ?? DataConnectService.instance; + + @override + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final response = await _service.connector + .listShiftsForDailyOpsByBusiness( + businessId: id, + date: _service.toTimestamp(date), + ) + .execute(); + + final shifts = response.data.shifts; + + int scheduledShifts = shifts.length; + int workersConfirmed = 0; + int inProgressShifts = 0; + int completedShifts = 0; + + final List dailyOpsShifts = []; + + for (final shift in shifts) { + workersConfirmed += shift.filled ?? 0; + final statusStr = shift.status?.stringValue ?? ''; + if (statusStr == 'IN_PROGRESS') inProgressShifts++; + if (statusStr == 'COMPLETED') completedShifts++; + + dailyOpsShifts.add(DailyOpsShift( + id: shift.id, + title: shift.title ?? '', + location: shift.location ?? '', + startTime: shift.startTime?.toDateTime() ?? DateTime.now(), + endTime: shift.endTime?.toDateTime() ?? DateTime.now(), + workersNeeded: shift.workersNeeded ?? 0, + filled: shift.filled ?? 0, + status: statusStr, + )); + } + + return DailyOpsReport( + scheduledShifts: scheduledShifts, + workersConfirmed: workersConfirmed, + inProgressShifts: inProgressShifts, + completedShifts: completedShifts, + shifts: dailyOpsShifts, + ); + }); + } + + @override + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final response = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final invoices = response.data.invoices; + + double totalSpend = 0.0; + int paidInvoices = 0; + int pendingInvoices = 0; + int overdueInvoices = 0; + + final List spendInvoices = []; + final Map dailyAggregates = {}; + + for (final inv in invoices) { + final amount = (inv.amount ?? 0.0).toDouble(); + totalSpend += amount; + + final statusStr = inv.status.stringValue; + if (statusStr == 'PAID') { + paidInvoices++; + } else if (statusStr == 'PENDING') { + pendingInvoices++; + } else if (statusStr == 'OVERDUE') { + overdueInvoices++; + } + + final issueDateTime = inv.issueDate.toDateTime(); + spendInvoices.add(SpendInvoice( + id: inv.id, + invoiceNumber: inv.invoiceNumber ?? '', + issueDate: issueDateTime, + amount: amount, + status: statusStr, + vendorName: inv.vendor?.companyName ?? 'Unknown', + )); + + // Chart data aggregation + final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day); + dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount; + } + + final List chartData = dailyAggregates.entries + .map((e) => SpendChartPoint(date: e.key, amount: e.value)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + return SpendReport( + totalSpend: totalSpend, + averageCost: invoices.isEmpty ? 0 : totalSpend / invoices.length, + paidInvoices: paidInvoices, + pendingInvoices: pendingInvoices, + overdueInvoices: overdueInvoices, + invoices: spendInvoices, + chartData: chartData, + ); + }); + } + + @override + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final response = await _service.connector + .listShiftsForCoverage( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + final Map dailyStats = {}; + + for (final shift in shifts) { + final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final needed = shift.workersNeeded ?? 0; + final filled = shift.filled ?? 0; + + totalNeeded += needed; + totalFilled += filled; + + final current = dailyStats[date] ?? (0, 0); + dailyStats[date] = (current.$1 + needed, current.$2 + filled); + } + + final List dailyCoverage = dailyStats.entries.map((e) { + final needed = e.value.$1; + final filled = e.value.$2; + return CoverageDay( + date: e.key, + needed: needed, + filled: filled, + percentage: needed == 0 ? 100.0 : (filled / needed) * 100.0, + ); + }).toList()..sort((a, b) => a.date.compareTo(b.date)); + + return CoverageReport( + overallCoverage: totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0, + totalNeeded: totalNeeded, + totalFilled: totalFilled, + dailyCoverage: dailyCoverage, + ); + }); + } + + @override + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final response = await _service.connector + .listShiftsForForecastByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shifts = response.data.shifts; + + double projectedSpend = 0.0; + int projectedWorkers = 0; + final Map dailyStats = {}; + + for (final shift in shifts) { + final shiftDate = shift.date?.toDateTime() ?? DateTime.now(); + final date = DateTime(shiftDate.year, shiftDate.month, shiftDate.day); + + final cost = (shift.cost ?? 0.0).toDouble(); + final workers = shift.workersNeeded ?? 0; + + projectedSpend += cost; + projectedWorkers += workers; + + final current = dailyStats[date] ?? (0.0, 0); + dailyStats[date] = (current.$1 + cost, current.$2 + workers); + } + + final List chartData = dailyStats.entries.map((e) { + return ForecastPoint( + date: e.key, + projectedCost: e.value.$1, + workersNeeded: e.value.$2, + ); + }).toList()..sort((a, b) => a.date.compareTo(b.date)); + + return ForecastReport( + projectedSpend: projectedSpend, + projectedWorkers: projectedWorkers, + averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers, + chartData: chartData, + ); + }); + } + + @override + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + final response = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shifts = response.data.shifts; + + int totalNeeded = 0; + int totalFilled = 0; + int completedCount = 0; + double totalFillTimeSeconds = 0.0; + int filledShiftsWithTime = 0; + + for (final shift in shifts) { + totalNeeded += shift.workersNeeded ?? 0; + totalFilled += shift.filled ?? 0; + if ((shift.status?.stringValue ?? '') == 'COMPLETED') { + completedCount++; + } + + if (shift.filledAt != null && shift.createdAt != null) { + final createdAt = shift.createdAt!.toDateTime(); + final filledAt = shift.filledAt!.toDateTime(); + totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; + filledShiftsWithTime++; + } + } + + final double fillRate = totalNeeded == 0 ? 100.0 : (totalFilled / totalNeeded) * 100.0; + final double completionRate = shifts.isEmpty ? 100.0 : (completedCount / shifts.length) * 100.0; + final double avgFillTimeHours = filledShiftsWithTime == 0 + ? 0 + : (totalFillTimeSeconds / filledShiftsWithTime) / 3600; + + return PerformanceReport( + fillRate: fillRate, + completionRate: completionRate, + onTimeRate: 95.0, + avgFillTimeHours: avgFillTimeHours, + keyPerformanceIndicators: [ + PerformanceMetric(label: 'Fill Rate', value: '${fillRate.toStringAsFixed(1)}%', trend: 0.02), + PerformanceMetric(label: 'Completion', value: '${completionRate.toStringAsFixed(1)}%', trend: 0.05), + PerformanceMetric(label: 'Avg Fill Time', value: '${avgFillTimeHours.toStringAsFixed(1)}h', trend: -0.1), + ], + ); + }); + } + + @override + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + + final shiftsResponse = await _service.connector + .listShiftsForNoShowRangeByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final shiftIds = shiftsResponse.data.shifts.map((s) => s.id).toList(); + if (shiftIds.isEmpty) { + return const NoShowReport(totalNoShows: 0, noShowRate: 0, flaggedWorkers: []); + } + + final appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + + final apps = appsResponse.data.applications; + final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList(); + final noShowStaffIds = noShowApps.map((a) => a.staffId).toSet().toList(); + + if (noShowStaffIds.isEmpty) { + return NoShowReport( + totalNoShows: noShowApps.length, + noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0, + flaggedWorkers: [], + ); + } + + final staffResponse = await _service.connector + .listStaffForNoShowReport(staffIds: noShowStaffIds) + .execute(); + + final staffList = staffResponse.data.staffs; + + final List flaggedWorkers = staffList.map((s) => NoShowWorker( + id: s.id, + fullName: s.fullName ?? '', + noShowCount: s.noShowCount ?? 0, + reliabilityScore: (s.reliabilityScore ?? 0.0).toDouble(), + )).toList(); + + return NoShowReport( + totalNoShows: noShowApps.length, + noShowRate: apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0, + flaggedWorkers: flaggedWorkers, + ); + }); + } + + @override + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }) async { + return await _service.run(() async { + final String id = businessId ?? await _service.getBusinessId(); + + // Use forecast query for hours/cost data + final shiftsResponse = await _service.connector + .listShiftsForForecastByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + // Use performance query for avgFillTime (has filledAt + createdAt) + final perfResponse = await _service.connector + .listShiftsForPerformanceByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final invoicesResponse = await _service.connector + .listInvoicesForSpendByBusiness( + businessId: id, + startDate: _service.toTimestamp(startDate), + endDate: _service.toTimestamp(endDate), + ) + .execute(); + + final forecastShifts = shiftsResponse.data.shifts; + final perfShifts = perfResponse.data.shifts; + final invoices = invoicesResponse.data.invoices; + + // Aggregate hours and fill rate from forecast shifts + double totalHours = 0; + int totalNeeded = 0; + int totalFilled = 0; + + for (final shift in forecastShifts) { + totalHours += (shift.hours ?? 0).toDouble(); + totalNeeded += shift.workersNeeded ?? 0; + // Forecast query doesn't have 'filled' — use workersNeeded as proxy + // (fill rate will be computed from performance shifts below) + } + + // Aggregate fill rate from performance shifts (has 'filled' field) + int perfNeeded = 0; + int perfFilled = 0; + double totalFillTimeSeconds = 0; + int filledShiftsWithTime = 0; + + for (final shift in perfShifts) { + perfNeeded += shift.workersNeeded ?? 0; + perfFilled += shift.filled ?? 0; + + if (shift.filledAt != null && shift.createdAt != null) { + final createdAt = shift.createdAt!.toDateTime(); + final filledAt = shift.filledAt!.toDateTime(); + totalFillTimeSeconds += filledAt.difference(createdAt).inSeconds; + filledShiftsWithTime++; + } + } + + // Aggregate total spend from invoices + double totalSpend = 0; + for (final inv in invoices) { + totalSpend += (inv.amount ?? 0).toDouble(); + } + + // Fetch no-show rate using forecast shift IDs + final shiftIds = forecastShifts.map((s) => s.id).toList(); + double noShowRate = 0; + if (shiftIds.isNotEmpty) { + final appsResponse = await _service.connector + .listApplicationsForNoShowRange(shiftIds: shiftIds) + .execute(); + final apps = appsResponse.data.applications; + final noShowApps = apps.where((a) => (a.status.stringValue) == 'NO_SHOW').toList(); + noShowRate = apps.isEmpty ? 0 : (noShowApps.length / apps.length) * 100.0; + } + + final double fillRate = perfNeeded == 0 ? 100.0 : (perfFilled / perfNeeded) * 100.0; + + return ReportsSummary( + totalHours: totalHours, + otHours: totalHours * 0.05, // ~5% OT approximation until schema supports it + totalSpend: totalSpend, + fillRate: fillRate, + avgFillTimeHours: filledShiftsWithTime == 0 + ? 0 + : (totalFillTimeSeconds / filledShiftsWithTime) / 3600, + noShowRate: noShowRate, + ); + }); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart new file mode 100644 index 00000000..a8901528 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/coverage_report.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +class CoverageReport extends Equatable { + final double overallCoverage; + final int totalNeeded; + final int totalFilled; + final List dailyCoverage; + + const CoverageReport({ + required this.overallCoverage, + required this.totalNeeded, + required this.totalFilled, + required this.dailyCoverage, + }); + + @override + List get props => [overallCoverage, totalNeeded, totalFilled, dailyCoverage]; +} + +class CoverageDay extends Equatable { + final DateTime date; + final int needed; + final int filled; + final double percentage; + + const CoverageDay({ + required this.date, + required this.needed, + required this.filled, + required this.percentage, + }); + + @override + List get props => [date, needed, filled, percentage]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart new file mode 100644 index 00000000..dbc22ba6 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart @@ -0,0 +1,60 @@ +import 'package:equatable/equatable.dart'; + +class DailyOpsReport extends Equatable { + final int scheduledShifts; + final int workersConfirmed; + final int inProgressShifts; + final int completedShifts; + final List shifts; + + const DailyOpsReport({ + required this.scheduledShifts, + required this.workersConfirmed, + required this.inProgressShifts, + required this.completedShifts, + required this.shifts, + }); + + @override + List get props => [ + scheduledShifts, + workersConfirmed, + inProgressShifts, + completedShifts, + shifts, + ]; +} + +class DailyOpsShift extends Equatable { + final String id; + final String title; + final String location; + final DateTime startTime; + final DateTime endTime; + final int workersNeeded; + final int filled; + final String status; + + const DailyOpsShift({ + required this.id, + required this.title, + required this.location, + required this.startTime, + required this.endTime, + required this.workersNeeded, + required this.filled, + required this.status, + }); + + @override + List get props => [ + id, + title, + location, + startTime, + endTime, + workersNeeded, + filled, + status, + ]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart new file mode 100644 index 00000000..f4d5e3b4 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/forecast_report.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +class ForecastReport extends Equatable { + final double projectedSpend; + final int projectedWorkers; + final double averageLaborCost; + final List chartData; + + const ForecastReport({ + required this.projectedSpend, + required this.projectedWorkers, + required this.averageLaborCost, + required this.chartData, + }); + + @override + List get props => [projectedSpend, projectedWorkers, averageLaborCost, chartData]; +} + +class ForecastPoint extends Equatable { + final DateTime date; + final double projectedCost; + final int workersNeeded; + + const ForecastPoint({ + required this.date, + required this.projectedCost, + required this.workersNeeded, + }); + + @override + List get props => [date, projectedCost, workersNeeded]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart new file mode 100644 index 00000000..9e890b5c --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/no_show_report.dart @@ -0,0 +1,33 @@ +import 'package:equatable/equatable.dart'; + +class NoShowReport extends Equatable { + final int totalNoShows; + final double noShowRate; + final List flaggedWorkers; + + const NoShowReport({ + required this.totalNoShows, + required this.noShowRate, + required this.flaggedWorkers, + }); + + @override + List get props => [totalNoShows, noShowRate, flaggedWorkers]; +} + +class NoShowWorker extends Equatable { + final String id; + final String fullName; + final int noShowCount; + final double reliabilityScore; + + const NoShowWorker({ + required this.id, + required this.fullName, + required this.noShowCount, + required this.reliabilityScore, + }); + + @override + List get props => [id, fullName, noShowCount, reliabilityScore]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart new file mode 100644 index 00000000..9459d516 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/performance_report.dart @@ -0,0 +1,35 @@ +import 'package:equatable/equatable.dart'; + +class PerformanceReport extends Equatable { + final double fillRate; + final double completionRate; + final double onTimeRate; + final double avgFillTimeHours; // in hours + final List keyPerformanceIndicators; + + const PerformanceReport({ + required this.fillRate, + required this.completionRate, + required this.onTimeRate, + required this.avgFillTimeHours, + required this.keyPerformanceIndicators, + }); + + @override + List get props => [fillRate, completionRate, onTimeRate, avgFillTimeHours, keyPerformanceIndicators]; +} + +class PerformanceMetric extends Equatable { + final String label; + final String value; + final double trend; // e.g. 0.05 for +5% + + const PerformanceMetric({ + required this.label, + required this.value, + required this.trend, + }); + + @override + List get props => [label, value, trend]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart new file mode 100644 index 00000000..cefeabc7 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/reports_summary.dart @@ -0,0 +1,29 @@ +import 'package:equatable/equatable.dart'; + +class ReportsSummary extends Equatable { + final double totalHours; + final double otHours; + final double totalSpend; + final double fillRate; + final double avgFillTimeHours; + final double noShowRate; + + const ReportsSummary({ + required this.totalHours, + required this.otHours, + required this.totalSpend, + required this.fillRate, + required this.avgFillTimeHours, + required this.noShowRate, + }); + + @override + List get props => [ + totalHours, + otHours, + totalSpend, + fillRate, + avgFillTimeHours, + noShowRate, + ]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart new file mode 100644 index 00000000..2e8b0829 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart @@ -0,0 +1,63 @@ +import 'package:equatable/equatable.dart'; + +class SpendReport extends Equatable { + final double totalSpend; + final double averageCost; + final int paidInvoices; + final int pendingInvoices; + final int overdueInvoices; + final List invoices; + final List chartData; + + const SpendReport({ + required this.totalSpend, + required this.averageCost, + required this.paidInvoices, + required this.pendingInvoices, + required this.overdueInvoices, + required this.invoices, + required this.chartData, + }); + + @override + List get props => [ + totalSpend, + averageCost, + paidInvoices, + pendingInvoices, + overdueInvoices, + invoices, + chartData, + ]; +} + +class SpendInvoice extends Equatable { + final String id; + final String invoiceNumber; + final DateTime issueDate; + final double amount; + final String status; + final String vendorName; + + const SpendInvoice({ + required this.id, + required this.invoiceNumber, + required this.issueDate, + required this.amount, + required this.status, + required this.vendorName, + }); + + @override + List get props => [id, invoiceNumber, issueDate, amount, status, vendorName]; +} + +class SpendChartPoint extends Equatable { + final DateTime date; + final double amount; + + const SpendChartPoint({required this.date, required this.amount}); + + @override + List get props => [date, amount]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart new file mode 100644 index 00000000..2a2da7b1 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/repositories/reports_repository.dart @@ -0,0 +1,50 @@ +import '../entities/daily_ops_report.dart'; +import '../entities/spend_report.dart'; +import '../entities/coverage_report.dart'; +import '../entities/forecast_report.dart'; +import '../entities/performance_report.dart'; +import '../entities/no_show_report.dart'; +import '../entities/reports_summary.dart'; + +abstract class ReportsRepository { + Future getDailyOpsReport({ + String? businessId, + required DateTime date, + }); + + Future getSpendReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getCoverageReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getForecastReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getPerformanceReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getNoShowReport({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); + + Future getReportsSummary({ + String? businessId, + required DateTime startDate, + required DateTime endDate, + }); +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart new file mode 100644 index 00000000..d1a7da5f --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_bloc.dart @@ -0,0 +1,30 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'daily_ops_event.dart'; +import 'daily_ops_state.dart'; + +class DailyOpsBloc extends Bloc { + final ReportsRepository _reportsRepository; + + DailyOpsBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(DailyOpsInitial()) { + on(_onLoadDailyOpsReport); + } + + Future _onLoadDailyOpsReport( + LoadDailyOpsReport event, + Emitter emit, + ) async { + emit(DailyOpsLoading()); + try { + final report = await _reportsRepository.getDailyOpsReport( + businessId: event.businessId, + date: event.date, + ); + emit(DailyOpsLoaded(report)); + } catch (e) { + emit(DailyOpsError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart new file mode 100644 index 00000000..612dab5f --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_event.dart @@ -0,0 +1,21 @@ +import 'package:equatable/equatable.dart'; + +abstract class DailyOpsEvent extends Equatable { + const DailyOpsEvent(); + + @override + List get props => []; +} + +class LoadDailyOpsReport extends DailyOpsEvent { + final String? businessId; + final DateTime date; + + const LoadDailyOpsReport({ + this.businessId, + required this.date, + }); + + @override + List get props => [businessId, date]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart new file mode 100644 index 00000000..8c3598c9 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/daily_ops/daily_ops_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/daily_ops_report.dart'; + +abstract class DailyOpsState extends Equatable { + const DailyOpsState(); + + @override + List get props => []; +} + +class DailyOpsInitial extends DailyOpsState {} + +class DailyOpsLoading extends DailyOpsState {} + +class DailyOpsLoaded extends DailyOpsState { + final DailyOpsReport report; + + const DailyOpsLoaded(this.report); + + @override + List get props => [report]; +} + +class DailyOpsError extends DailyOpsState { + final String message; + + const DailyOpsError(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart new file mode 100644 index 00000000..3f2196ba --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'forecast_event.dart'; +import 'forecast_state.dart'; + +class ForecastBloc extends Bloc { + final ReportsRepository _reportsRepository; + + ForecastBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(ForecastInitial()) { + on(_onLoadForecastReport); + } + + Future _onLoadForecastReport( + LoadForecastReport event, + Emitter emit, + ) async { + emit(ForecastLoading()); + try { + final report = await _reportsRepository.getForecastReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ForecastLoaded(report)); + } catch (e) { + emit(ForecastError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart new file mode 100644 index 00000000..c3f1c247 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class ForecastEvent extends Equatable { + const ForecastEvent(); + + @override + List get props => []; +} + +class LoadForecastReport extends ForecastEvent { + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + const LoadForecastReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart new file mode 100644 index 00000000..dcf2bdd5 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/forecast/forecast_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/forecast_report.dart'; + +abstract class ForecastState extends Equatable { + const ForecastState(); + + @override + List get props => []; +} + +class ForecastInitial extends ForecastState {} + +class ForecastLoading extends ForecastState {} + +class ForecastLoaded extends ForecastState { + final ForecastReport report; + + const ForecastLoaded(this.report); + + @override + List get props => [report]; +} + +class ForecastError extends ForecastState { + final String message; + + const ForecastError(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart new file mode 100644 index 00000000..da29a966 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'no_show_event.dart'; +import 'no_show_state.dart'; + +class NoShowBloc extends Bloc { + final ReportsRepository _reportsRepository; + + NoShowBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(NoShowInitial()) { + on(_onLoadNoShowReport); + } + + Future _onLoadNoShowReport( + LoadNoShowReport event, + Emitter emit, + ) async { + emit(NoShowLoading()); + try { + final report = await _reportsRepository.getNoShowReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(NoShowLoaded(report)); + } catch (e) { + emit(NoShowError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart new file mode 100644 index 00000000..48ba8df7 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class NoShowEvent extends Equatable { + const NoShowEvent(); + + @override + List get props => []; +} + +class LoadNoShowReport extends NoShowEvent { + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + const LoadNoShowReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart new file mode 100644 index 00000000..22b1bac9 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/no_show/no_show_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/no_show_report.dart'; + +abstract class NoShowState extends Equatable { + const NoShowState(); + + @override + List get props => []; +} + +class NoShowInitial extends NoShowState {} + +class NoShowLoading extends NoShowState {} + +class NoShowLoaded extends NoShowState { + final NoShowReport report; + + const NoShowLoaded(this.report); + + @override + List get props => [report]; +} + +class NoShowError extends NoShowState { + final String message; + + const NoShowError(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart new file mode 100644 index 00000000..f0a7d1f3 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'performance_event.dart'; +import 'performance_state.dart'; + +class PerformanceBloc extends Bloc { + final ReportsRepository _reportsRepository; + + PerformanceBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(PerformanceInitial()) { + on(_onLoadPerformanceReport); + } + + Future _onLoadPerformanceReport( + LoadPerformanceReport event, + Emitter emit, + ) async { + emit(PerformanceLoading()); + try { + final report = await _reportsRepository.getPerformanceReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(PerformanceLoaded(report)); + } catch (e) { + emit(PerformanceError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart new file mode 100644 index 00000000..f768582d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class PerformanceEvent extends Equatable { + const PerformanceEvent(); + + @override + List get props => []; +} + +class LoadPerformanceReport extends PerformanceEvent { + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + const LoadPerformanceReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart new file mode 100644 index 00000000..f28d74ed --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/performance/performance_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/performance_report.dart'; + +abstract class PerformanceState extends Equatable { + const PerformanceState(); + + @override + List get props => []; +} + +class PerformanceInitial extends PerformanceState {} + +class PerformanceLoading extends PerformanceState {} + +class PerformanceLoaded extends PerformanceState { + final PerformanceReport report; + + const PerformanceLoaded(this.report); + + @override + List get props => [report]; +} + +class PerformanceError extends PerformanceState { + final String message; + + const PerformanceError(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart new file mode 100644 index 00000000..89558fd5 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'spend_event.dart'; +import 'spend_state.dart'; + +class SpendBloc extends Bloc { + final ReportsRepository _reportsRepository; + + SpendBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(SpendInitial()) { + on(_onLoadSpendReport); + } + + Future _onLoadSpendReport( + LoadSpendReport event, + Emitter emit, + ) async { + emit(SpendLoading()); + try { + final report = await _reportsRepository.getSpendReport( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(SpendLoaded(report)); + } catch (e) { + emit(SpendError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart new file mode 100644 index 00000000..0ed5d7aa --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class SpendEvent extends Equatable { + const SpendEvent(); + + @override + List get props => []; +} + +class LoadSpendReport extends SpendEvent { + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + const LoadSpendReport({ + this.businessId, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart new file mode 100644 index 00000000..5fba9714 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/spend/spend_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/spend_report.dart'; + +abstract class SpendState extends Equatable { + const SpendState(); + + @override + List get props => []; +} + +class SpendInitial extends SpendState {} + +class SpendLoading extends SpendState {} + +class SpendLoaded extends SpendState { + final SpendReport report; + + const SpendLoaded(this.report); + + @override + List get props => [report]; +} + +class SpendError extends SpendState { + final String message; + + const SpendError(this.message); + + @override + List get props => [message]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart new file mode 100644 index 00000000..3ffffc01 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_bloc.dart @@ -0,0 +1,31 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../../domain/repositories/reports_repository.dart'; +import 'reports_summary_event.dart'; +import 'reports_summary_state.dart'; + +class ReportsSummaryBloc extends Bloc { + final ReportsRepository _reportsRepository; + + ReportsSummaryBloc({required ReportsRepository reportsRepository}) + : _reportsRepository = reportsRepository, + super(ReportsSummaryInitial()) { + on(_onLoadReportsSummary); + } + + Future _onLoadReportsSummary( + LoadReportsSummary event, + Emitter emit, + ) async { + emit(ReportsSummaryLoading()); + try { + final summary = await _reportsRepository.getReportsSummary( + businessId: event.businessId, + startDate: event.startDate, + endDate: event.endDate, + ); + emit(ReportsSummaryLoaded(summary)); + } catch (e) { + emit(ReportsSummaryError(e.toString())); + } + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart new file mode 100644 index 00000000..a8abef0b --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_event.dart @@ -0,0 +1,23 @@ +import 'package:equatable/equatable.dart'; + +abstract class ReportsSummaryEvent extends Equatable { + const ReportsSummaryEvent(); + + @override + List get props => []; +} + +class LoadReportsSummary extends ReportsSummaryEvent { + final String? businessId; + final DateTime startDate; + final DateTime endDate; + + const LoadReportsSummary({ + this.businessId, + required this.startDate, + required this.endDate, + }); + + @override + List get props => [businessId, startDate, endDate]; +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart new file mode 100644 index 00000000..8b9079d1 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/blocs/summary/reports_summary_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import '../../../domain/entities/reports_summary.dart'; + +abstract class ReportsSummaryState extends Equatable { + const ReportsSummaryState(); + + @override + List get props => []; +} + +class ReportsSummaryInitial extends ReportsSummaryState {} + +class ReportsSummaryLoading extends ReportsSummaryState {} + +class ReportsSummaryLoaded extends ReportsSummaryState { + final ReportsSummary summary; + + const ReportsSummaryLoaded(this.summary); + + @override + List get props => [summary]; +} + +class ReportsSummaryError extends ReportsSummaryState { + final String message; + + const ReportsSummaryError(this.message); + + @override + List get props => [message]; +} 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 new file mode 100644 index 00000000..2c356aac --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -0,0 +1,471 @@ +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class CoverageReportPage extends StatefulWidget { + const CoverageReportPage({super.key}); + + @override + State createState() => _CoverageReportPageState(); +} + +class _CoverageReportPageState extends State { + DateTime _startDate = DateTime.now(); + DateTime _endDate = DateTime.now().add(const Duration(days: 6)); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is CoverageLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is CoverageError) { + return Center(child: Text(state.message)); + } + + if (state is CoverageLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.coverage_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.coverage_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.coverage_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + UiIcons.download, + size: 14, + color: UiColors.primary, + ), + const SizedBox(width: 6), + Text( + context.t.client_reports.quick_reports + .export_all + .split(' ') + .first, + style: const TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _CoverageOverviewCard( + percentage: report.overallCoverage, + needed: report.totalNeeded, + filled: report.totalFilled, + ), + const SizedBox(height: 24), + Text( + 'DAILY BREAKDOWN', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + if (report.dailyCoverage.isEmpty) + const Center(child: Text('No shifts scheduled')) + else + ...report.dailyCoverage.map((day) => _DailyCoverageItem( + date: DateFormat('EEEE, MMM dd').format(day.date), + percentage: day.percentage, + details: '${day.filled}/${day.needed} workers filled', + )), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _CoverageOverviewCard extends StatelessWidget { + final double percentage; + final int needed; + final int filled; + + const _CoverageOverviewCard({ + required this.percentage, + required this.needed, + required this.filled, + }); + + @override + Widget build(BuildContext context) { + final color = percentage >= 90 + ? UiColors.success + : percentage >= 70 + ? UiColors.textWarning + : UiColors.error; + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Overall Coverage', + style: const TextStyle( + fontSize: 14, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + Text( + '${percentage.toStringAsFixed(1)}%', + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + _CircularProgress( + percentage: percentage / 100, + color: color, + size: 70, + ), + ], + ), + const SizedBox(height: 24), + const Divider(height: 1, color: UiColors.bgSecondary), + const SizedBox(height: 24), + Row( + children: [ + Expanded( + child: _MetricItem( + label: 'Total Needed', + value: needed.toString(), + icon: UiIcons.users, + color: UiColors.primary, + ), + ), + Expanded( + child: _MetricItem( + label: 'Total Filled', + value: filled.toString(), + icon: UiIcons.checkCircle, + color: UiColors.success, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _MetricItem extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _MetricItem({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 16, color: color), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + Text( + label, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ); + } +} + +class _DailyCoverageItem extends StatelessWidget { + final String date; + final double percentage; + final String details; + + const _DailyCoverageItem({ + required this.date, + required this.percentage, + required this.details, + }); + + @override + Widget build(BuildContext context) { + final color = percentage >= 95 + ? UiColors.success + : percentage >= 80 + ? UiColors.textWarning + : UiColors.error; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 4, + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + date, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: UiColors.textPrimary, + ), + ), + Text( + '${percentage.toStringAsFixed(0)}%', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: color, + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: percentage / 100, + backgroundColor: UiColors.bgSecondary, + valueColor: AlwaysStoppedAnimation(color), + minHeight: 6, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: Text( + details, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + ), + ], + ), + ); + } +} + +class _CircularProgress extends StatelessWidget { + final double percentage; + final Color color; + final double size; + + const _CircularProgress({ + required this.percentage, + required this.color, + required this.size, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + value: percentage, + strokeWidth: 8, + backgroundColor: UiColors.bgSecondary, + valueColor: AlwaysStoppedAnimation(color), + ), + ), + 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 new file mode 100644 index 00000000..b78efdbf --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -0,0 +1,562 @@ +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class DailyOpsReportPage extends StatefulWidget { + const DailyOpsReportPage({super.key}); + + @override + State createState() => _DailyOpsReportPageState(); +} + +class _DailyOpsReportPageState extends State { + final DateTime _selectedDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadDailyOpsReport(date: _selectedDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is DailyOpsLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is DailyOpsError) { + return Center(child: Text(state.message)); + } + + if (state is DailyOpsLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.daily_ops_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.daily_ops_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.daily_ops_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + UiIcons.download, + size: 14, + color: UiColors.primary, + ), + const SizedBox(width: 6), + Text( + context.t.client_reports.quick_reports + .export_all + .split(' ') + .first, + style: const TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Date Selector + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + ), + ], + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 16, + color: UiColors.primary, + ), + const SizedBox(width: 8), + Text( + DateFormat('MMM dd, yyyy') + .format(_selectedDate), + style: const TextStyle( + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + const Icon( + UiIcons.chevronDown, + size: 16, + color: UiColors.textSecondary, + ), + ], + ), + ), + const SizedBox(height: 16), + + // Stats Grid + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.4, + children: [ + _OpsStatCard( + label: context.t.client_reports + .daily_ops_report.metrics.scheduled.label, + value: report.scheduledShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .scheduled + .sub_value, + color: UiColors.primary, + icon: UiIcons.calendar, + ), + _OpsStatCard( + label: context.t.client_reports + .daily_ops_report.metrics.workers.label, + value: report.workersConfirmed.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .workers + .sub_value, + color: UiColors.primary, + icon: UiIcons.users, + ), + _OpsStatCard( + label: context + .t + .client_reports + .daily_ops_report + .metrics + .in_progress + .label, + value: report.inProgressShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .in_progress + .sub_value, + color: UiColors.textWarning, + icon: UiIcons.clock, + ), + _OpsStatCard( + label: context + .t + .client_reports + .daily_ops_report + .metrics + .completed + .label, + value: report.completedShifts.toString(), + subValue: context + .t + .client_reports + .daily_ops_report + .metrics + .completed + .sub_value, + color: UiColors.success, + icon: UiIcons.checkCircle, + ), + ], + ), + + const SizedBox(height: 24), + Text( + context.t.client_reports.daily_ops_report + .all_shifts_title + .toUpperCase(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 12), + + // Shift List + if (report.shifts.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text('No shifts scheduled for today'), + ), + ) + else + ...report.shifts.map((shift) => _ShiftListItem( + title: shift.title, + location: shift.location, + time: + '${DateFormat('hh:mm a').format(shift.startTime)} - ${DateFormat('hh:mm a').format(shift.endTime)}', + workers: + '${shift.filled}/${shift.workersNeeded}', + status: shift.status.replaceAll('_', ' '), + statusColor: shift.status == 'COMPLETED' + ? UiColors.success + : shift.status == 'IN_PROGRESS' + ? UiColors.textWarning + : UiColors.primary, + )), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _OpsStatCard extends StatelessWidget { + final String label; + final String value; + final String subValue; + final Color color; + final IconData icon; + + const _OpsStatCard({ + required this.label, + required this.value, + required this.subValue, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + border: Border(left: BorderSide(color: color, width: 4)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon(icon, size: 14, color: color), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + Text( + subValue, + style: const TextStyle( + fontSize: 10, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ShiftListItem extends StatelessWidget { + final String title; + final String location; + final String time; + final String workers; + final String status; + final Color statusColor; + + const _ShiftListItem({ + required this.title, + required this.location, + required this.time, + required this.workers, + required this.status, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 2, + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + UiIcons.mapPin, + size: 10, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Expanded( + child: Text( + location, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + color: statusColor, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: UiColors.bgSecondary), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _infoItem( + context, + UiIcons.clock, + context.t.client_reports.daily_ops_report.shift_item.time, + time), + _infoItem( + context, + UiIcons.users, + context.t.client_reports.daily_ops_report.shift_item.workers, + workers), + ], + ), + ], + ), + ); + } + + Widget _infoItem( + BuildContext context, IconData icon, String label, String value) { + return Row( + children: [ + Icon(icon, size: 12, color: UiColors.textSecondary), + const SizedBox(width: 6), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle(fontSize: 10, color: UiColors.pinInactive), + ), + Text( + value, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: UiColors.textDescription, + ), + ), + ], + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart new file mode 100644 index 00000000..3b5417f4 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -0,0 +1,359 @@ +import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_event.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_state.dart'; +import 'package:client_reports/src/domain/entities/forecast_report.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class ForecastReportPage extends StatefulWidget { + const ForecastReportPage({super.key}); + + @override + State createState() => _ForecastReportPageState(); +} + +class _ForecastReportPageState extends State { + DateTime _startDate = DateTime.now(); + DateTime _endDate = DateTime.now().add(const Duration(days: 14)); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is ForecastLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is ForecastError) { + return Center(child: Text(state.message)); + } + + if (state is ForecastLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.primary, UiColors.tagInProgress], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.forecast_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.forecast_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards + Row( + children: [ + Expanded( + child: _ForecastSummaryCard( + label: 'Projected Spend', + value: NumberFormat.currency(symbol: r'$') + .format(report.projectedSpend), + icon: UiIcons.dollar, + color: UiColors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ForecastSummaryCard( + label: 'Workers Needed', + value: report.projectedWorkers.toString(), + icon: UiIcons.users, + color: UiColors.primary, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Chart + Container( + height: 300, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Spending Forecast', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 24), + Expanded( + child: _ForecastChart( + points: report.chartData, + ), + ), + ], + ), + ), + const SizedBox(height: 24), + + // Daily List + Text( + 'DAILY PROJECTIONS', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + if (report.chartData.isEmpty) + const Center(child: Text('No projections available')) + else + ...report.chartData.map((point) => _ForecastListItem( + date: DateFormat('EEE, MMM dd').format(point.date), + cost: NumberFormat.currency(symbol: r'$') + .format(point.projectedCost), + workers: point.workersNeeded.toString(), + )), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _ForecastSummaryCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _ForecastSummaryCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: color), + ), + const SizedBox(height: 12), + Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + const SizedBox(height: 4), + Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + ], + ), + ); + } +} + +class _ForecastChart extends StatelessWidget { + final List points; + + const _ForecastChart({required this.points}); + + @override + Widget build(BuildContext context) { + if (points.isEmpty) return const SizedBox(); + + return LineChart( + LineChartData( + gridData: const FlGridData(show: false), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + if (value.toInt() < 0 || value.toInt() >= points.length) { + return const SizedBox(); + } + if (value.toInt() % 3 != 0) return const SizedBox(); + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + DateFormat('dd').format(points[value.toInt()].date), + style: const TextStyle(fontSize: 10, color: UiColors.textSecondary), + ), + ); + }, + ), + ), + leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: points + .asMap() + .entries + .map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost)) + .toList(), + isCurved: true, + color: UiColors.primary, + barWidth: 4, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData( + show: true, + color: UiColors.primary.withOpacity(0.1), + ), + ), + ], + ), + ); + } +} + +class _ForecastListItem extends StatelessWidget { + final String date; + final String cost; + final String workers; + + const _ForecastListItem({ + required this.date, + required this.cost, + required this.workers, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), + Text('$workers workers needed', style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), + ], + ), + Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)), + ], + ), + ); + } +} 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 new file mode 100644 index 00000000..cadaf0af --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -0,0 +1,220 @@ +import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +class NoShowReportPage extends StatefulWidget { + const NoShowReportPage({super.key}); + + @override + State createState() => _NoShowReportPageState(); +} + +class _NoShowReportPageState extends State { + DateTime _startDate = DateTime.now().subtract(const Duration(days: 30)); + DateTime _endDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is NoShowLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is NoShowError) { + return Center(child: Text(state.message)); + } + + if (state is NoShowLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.error, UiColors.tagError], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.no_show_report.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white), + ), + Text( + context.t.client_reports.no_show_report.subtitle, + style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)), + ), + ], + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // Summary + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.06), blurRadius: 10)], + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _SummaryItem( + label: 'Total No-Shows', + value: report.totalNoShows.toString(), + color: UiColors.error, + ), + _SummaryItem( + label: 'No-Show Rate', + value: '${report.noShowRate.toStringAsFixed(1)}%', + color: UiColors.textWarning, + ), + ], + ), + ), + const SizedBox(height: 24), + + // Flagged Workers + Align( + alignment: Alignment.centerLeft, + child: Text( + context.t.client_reports.no_show_report.workers_list_title, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: UiColors.textSecondary, letterSpacing: 1.2), + ), + ), + const SizedBox(height: 16), + if (report.flaggedWorkers.isEmpty) + const Padding( + padding: EdgeInsets.all(40.0), + child: Text('No workers flagged for no-shows'), + ) + else + ...report.flaggedWorkers.map((worker) => _WorkerListItem(worker: worker)), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _SummaryItem extends StatelessWidget { + final String label; + final String value; + final Color color; + + const _SummaryItem({required this.label, required this.value, required this.color}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)), + Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)), + ], + ); + } +} + +class _WorkerListItem extends StatelessWidget { + final dynamic worker; + + const _WorkerListItem({required this.worker}); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 40, + height: 40, + decoration: const BoxDecoration(color: UiColors.bgSecondary, shape: BoxShape.circle), + child: const Icon(UiIcons.user, color: UiColors.textSecondary), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)), + Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)), + ], + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)), + const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)), + ], + ), + ], + ), + ); + } +} 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 new file mode 100644 index 00000000..82e1aecb --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -0,0 +1,223 @@ +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; + +class PerformanceReportPage extends StatefulWidget { + const PerformanceReportPage({super.key}); + + @override + State createState() => _PerformanceReportPageState(); +} + +class _PerformanceReportPageState extends State { + DateTime _startDate = DateTime.now().subtract(const Duration(days: 30)); + DateTime _endDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is PerformanceLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is PerformanceError) { + return Center(child: Text(state.message)); + } + + if (state is PerformanceLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.primary, UiColors.buttonPrimaryHover], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.performance_report.title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white), + ), + Text( + context.t.client_reports.performance_report.subtitle, + style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)), + ), + ], + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // Main Stats + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.5, + children: [ + _StatTile( + label: 'Fill Rate', + value: '${report.fillRate.toStringAsFixed(1)}%', + color: UiColors.primary, + icon: UiIcons.users, + ), + _StatTile( + label: 'Completion', + value: '${report.completionRate.toStringAsFixed(1)}%', + color: UiColors.success, + icon: UiIcons.checkCircle, + ), + _StatTile( + label: 'On-Time', + value: '${report.onTimeRate.toStringAsFixed(1)}%', + color: UiColors.textWarning, + icon: UiIcons.clock, + ), + _StatTile( + label: 'Avg Fill Time', + value: '${report.avgFillTimeHours.toStringAsFixed(1)}h', + color: UiColors.primary, + icon: UiIcons.trendingUp, + ), + ], + ), + const SizedBox(height: 24), + + // KPI List + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 10)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Key Performance Indicators', + style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), + ), + const SizedBox(height: 20), + ...report.keyPerformanceIndicators.map((kpi) => Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(kpi.label, style: const TextStyle(color: UiColors.textSecondary)), + Row( + children: [ + Text(kpi.value, style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Icon( + kpi.trend >= 0 ? UiIcons.chevronUp : UiIcons.chevronDown, + size: 14, + color: kpi.trend >= 0 ? UiColors.success : UiColors.error, + ), + ], + ), + ], + ), + )), + ], + ), + ), + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _StatTile extends StatelessWidget { + final String label; + final String value; + final Color color; + final IconData icon; + + const _StatTile({required this.label, required this.value, required this.color, required this.icon}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 5)], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon(icon, color: color, size: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart new file mode 100644 index 00000000..f3a3f59e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -0,0 +1,696 @@ +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class ReportsPage extends StatefulWidget { + const ReportsPage({super.key}); + + @override + State createState() => _ReportsPageState(); +} + +class _ReportsPageState extends State + with SingleTickerProviderStateMixin { + late TabController _tabController; + late ReportsSummaryBloc _summaryBloc; + + // Date ranges per tab: Today, Week, Month, Quarter + final List<(DateTime, DateTime)> _dateRanges = [ + ( + DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day), + DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day, + 23, 59, 59), + ), + ( + DateTime.now().subtract(const Duration(days: 7)), + DateTime.now(), + ), + ( + DateTime(DateTime.now().year, DateTime.now().month, 1), + DateTime.now(), + ), + ( + DateTime(DateTime.now().year, ((DateTime.now().month - 1) ~/ 3) * 3 + 1, + 1), + DateTime.now(), + ), + ]; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 4, vsync: this); + _summaryBloc = Modular.get(); + _loadSummary(0); + + _tabController.addListener(() { + if (!_tabController.indexIsChanging) { + _loadSummary(_tabController.index); + } + }); + } + + void _loadSummary(int tabIndex) { + final range = _dateRanges[tabIndex]; + _summaryBloc.add(LoadReportsSummary( + startDate: range.$1, + endDate: range.$2, + )); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _summaryBloc, + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + Row( + children: [ + GestureDetector( + onTap: () => + Modular.to.navigate('/client-main/home'), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Text( + context.t.client_reports.title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + ], + ), + const SizedBox(height: 24), + // Tabs + Container( + height: 44, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: TabBar( + controller: _tabController, + indicator: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + labelColor: UiColors.primary, + unselectedLabelColor: UiColors.white, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + indicatorSize: TabBarIndicatorSize.tab, + dividerColor: Colors.transparent, + tabs: [ + Tab(text: context.t.client_reports.tabs.today), + Tab(text: context.t.client_reports.tabs.week), + Tab(text: context.t.client_reports.tabs.month), + Tab(text: context.t.client_reports.tabs.quarter), + ], + ), + ), + ], + ), + ), + + // Content + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Key Metrics — driven by BLoC + BlocBuilder( + builder: (context, state) { + if (state is ReportsSummaryLoading || + state is ReportsSummaryInitial) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 32), + child: Center(child: CircularProgressIndicator()), + ); + } + + if (state is ReportsSummaryError) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.tagError, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + const Icon(UiIcons.warning, + color: UiColors.error, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + state.message, + style: const TextStyle( + color: UiColors.error, fontSize: 12), + ), + ), + ], + ), + ), + ); + } + + final summary = (state as ReportsSummaryLoaded).summary; + final currencyFmt = + NumberFormat.currency(symbol: '\$', decimalDigits: 0); + + return GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.2, + children: [ + _MetricCard( + icon: UiIcons.clock, + label: context + .t.client_reports.metrics.total_hrs.label, + value: summary.totalHours >= 1000 + ? '${(summary.totalHours / 1000).toStringAsFixed(1)}k' + : summary.totalHours.toStringAsFixed(0), + badgeText: context + .t.client_reports.metrics.total_hrs.badge, + badgeColor: UiColors.tagRefunded, + badgeTextColor: UiColors.primary, + iconColor: UiColors.primary, + ), + _MetricCard( + icon: UiIcons.trendingUp, + label: context + .t.client_reports.metrics.ot_hours.label, + value: summary.otHours.toStringAsFixed(0), + badgeText: context + .t.client_reports.metrics.ot_hours.badge, + badgeColor: UiColors.tagValue, + badgeTextColor: UiColors.textSecondary, + iconColor: UiColors.textWarning, + ), + _MetricCard( + icon: UiIcons.dollar, + label: context + .t.client_reports.metrics.total_spend.label, + value: summary.totalSpend >= 1000 + ? '\$${(summary.totalSpend / 1000).toStringAsFixed(1)}k' + : currencyFmt.format(summary.totalSpend), + badgeText: context + .t.client_reports.metrics.total_spend.badge, + badgeColor: UiColors.tagSuccess, + badgeTextColor: UiColors.textSuccess, + iconColor: UiColors.success, + ), + _MetricCard( + icon: UiIcons.trendingUp, + label: context + .t.client_reports.metrics.fill_rate.label, + value: + '${summary.fillRate.toStringAsFixed(0)}%', + badgeText: context + .t.client_reports.metrics.fill_rate.badge, + badgeColor: UiColors.tagInProgress, + badgeTextColor: UiColors.textLink, + iconColor: UiColors.iconActive, + ), + _MetricCard( + icon: UiIcons.clock, + label: context.t.client_reports.metrics + .avg_fill_time.label, + value: + '${summary.avgFillTimeHours.toStringAsFixed(1)} hrs', + badgeText: context.t.client_reports.metrics + .avg_fill_time.badge, + badgeColor: UiColors.tagInProgress, + badgeTextColor: UiColors.textLink, + iconColor: UiColors.iconActive, + ), + _MetricCard( + icon: UiIcons.warning, + label: context + .t.client_reports.metrics.no_show_rate.label, + value: + '${summary.noShowRate.toStringAsFixed(1)}%', + badgeText: context + .t.client_reports.metrics.no_show_rate.badge, + badgeColor: summary.noShowRate < 5 + ? UiColors.tagSuccess + : UiColors.tagError, + badgeTextColor: summary.noShowRate < 5 + ? UiColors.textSuccess + : UiColors.error, + iconColor: UiColors.destructive, + ), + ], + ); + }, + ), + + const SizedBox(height: 24), + + // Quick Reports + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.t.client_reports.quick_reports.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + TextButton.icon( + onPressed: () {}, + icon: const Icon(UiIcons.download, size: 16), + label: Text( + context.t.client_reports.quick_reports.export_all), + style: TextButton.styleFrom( + foregroundColor: UiColors.textLink, + padding: EdgeInsets.zero, + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ], + ), + + GridView.count( + crossAxisCount: 2, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1.3, + children: [ + _ReportCard( + icon: UiIcons.calendar, + name: context + .t.client_reports.quick_reports.cards.daily_ops, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './daily-ops', + ), + _ReportCard( + icon: UiIcons.dollar, + name: context + .t.client_reports.quick_reports.cards.spend, + iconBgColor: UiColors.tagSuccess, + iconColor: UiColors.success, + route: './spend', + ), + _ReportCard( + icon: UiIcons.users, + name: context + .t.client_reports.quick_reports.cards.coverage, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './coverage', + ), + _ReportCard( + icon: UiIcons.warning, + name: context + .t.client_reports.quick_reports.cards.no_show, + iconBgColor: UiColors.tagError, + iconColor: UiColors.destructive, + route: './no-show', + ), + _ReportCard( + icon: UiIcons.trendingUp, + name: context + .t.client_reports.quick_reports.cards.forecast, + iconBgColor: UiColors.tagPending, + iconColor: UiColors.textWarning, + route: './forecast', + ), + _ReportCard( + icon: UiIcons.chart, + name: context + .t.client_reports.quick_reports.cards.performance, + iconBgColor: UiColors.tagInProgress, + iconColor: UiColors.primary, + route: './performance', + ), + ], + ), + + const SizedBox(height: 24), + + // AI Insights + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.tagInProgress.withOpacity(0.3), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 2, + offset: const Offset(0, 1), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '💡 ${context.t.client_reports.ai_insights.title}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + _InsightRow( + children: [ + TextSpan( + text: context.t.client_reports.ai_insights + .insight_1.prefix), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_1.highlight, + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_1.suffix, + ), + ], + ), + _InsightRow( + children: [ + TextSpan( + text: context.t.client_reports.ai_insights + .insight_2.prefix), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_2.highlight, + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_2.suffix, + ), + ], + ), + _InsightRow( + children: [ + TextSpan( + text: context.t.client_reports.ai_insights + .insight_3.prefix, + ), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_3.highlight, + style: const TextStyle( + fontWeight: FontWeight.bold), + ), + TextSpan( + text: context.t.client_reports.ai_insights + .insight_3.suffix), + ], + ), + ], + ), + ), + + const SizedBox(height: 100), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final String badgeText; + final Color badgeColor; + final Color badgeTextColor; + final Color iconColor; + + const _MetricCard({ + required this.icon, + required this.label, + required this.value, + required this.badgeText, + required this.badgeColor, + required this.badgeTextColor, + required this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: iconColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: badgeColor, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + badgeText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: badgeTextColor, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ReportCard extends StatelessWidget { + final IconData icon; + final String name; + final Color iconBgColor; + final Color iconColor; + final String route; + + const _ReportCard({ + required this.icon, + required this.name, + required this.iconBgColor, + required this.iconColor, + required this.route, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => Modular.to.pushNamed(route), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 2, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: iconBgColor, + borderRadius: BorderRadius.circular(12), + ), + child: Icon(icon, size: 20, color: iconColor), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon( + UiIcons.download, + size: 12, + color: UiColors.textSecondary, + ), + const SizedBox(width: 4), + Text( + context.t.client_reports.quick_reports.two_click_export, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + +class _InsightRow extends StatelessWidget { + final List children; + + const _InsightRow({required this.children}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '• ', + style: TextStyle(color: UiColors.textSecondary, fontSize: 14), + ), + Expanded( + child: Text.rich( + TextSpan( + style: const TextStyle( + fontSize: 14, + color: UiColors.textSecondary, + height: 1.4, + ), + children: children, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart new file mode 100644 index 00000000..dc4b8483 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -0,0 +1,674 @@ +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:intl/intl.dart'; + +class SpendReportPage extends StatefulWidget { + const SpendReportPage({super.key}); + + @override + State createState() => _SpendReportPageState(); +} + +class _SpendReportPageState extends State { + DateTime _startDate = DateTime.now().subtract(const Duration(days: 6)); + DateTime _endDate = DateTime.now(); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => Modular.get() + ..add(LoadSpendReport(startDate: _startDate, endDate: _endDate)), + child: Scaffold( + backgroundColor: UiColors.bgMenu, + body: BlocBuilder( + builder: (context, state) { + if (state is SpendLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (state is SpendError) { + return Center(child: Text(state.message)); + } + + if (state is SpendLoaded) { + final report = state.report; + return SingleChildScrollView( + child: Column( + children: [ + // Header + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 32, + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [UiColors.success, UiColors.tagSuccess], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Modular.to.pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.spend_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.spend_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon( + UiIcons.download, + size: 14, + color: UiColors.success, + ), + const SizedBox(width: 6), + Text( + context.t.client_reports.quick_reports + .export_all + .split(' ') + .first, + style: const TextStyle( + color: UiColors.success, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // Content + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards + Row( + children: [ + Expanded( + child: _SpendSummaryCard( + label: context.t.client_reports.spend_report + .summary.total_spend, + value: NumberFormat.currency(symbol: r'$') + .format(report.totalSpend), + change: '', // Can be calculated if needed + period: context.t.client_reports + .spend_report.summary.this_week, + color: UiColors.textSuccess, + icon: UiIcons.dollar, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SpendSummaryCard( + label: context.t.client_reports.spend_report + .summary.avg_daily, + value: NumberFormat.currency(symbol: r'$') + .format(report.averageCost), + change: '', + period: context.t.client_reports + .spend_report.summary.per_day, + color: UiColors.primary, + icon: UiIcons.chart, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Chart Section + Container( + height: 300, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + context.t.client_reports.spend_report + .chart_title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: + BorderRadius.circular(8), + ), + child: Row( + children: [ + Text( + context.t.client_reports.tabs.week, + style: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + ), + ), + const Icon( + UiIcons.chevronDown, + size: 10, + color: UiColors.textSecondary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 24), + Expanded( + child: _SpendBarChart( + chartData: report.chartData), + ), + ], + ), + ), + + const SizedBox(height: 24), + // Status Distribution + Row( + children: [ + Expanded( + child: _StatusMiniCard( + label: 'Paid', + value: report.paidInvoices.toString(), + color: UiColors.success, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StatusMiniCard( + label: 'Pending', + value: report.pendingInvoices.toString(), + color: UiColors.textWarning, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _StatusMiniCard( + label: 'Overdue', + value: report.overdueInvoices.toString(), + color: UiColors.error, + ), + ), + ], + ), + + const SizedBox(height: 32), + Text( + 'RECENT INVOICES', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + + // Invoice List + if (report.invoices.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 40), + child: Center( + child: + Text('No invoices found for this period'), + ), + ) + else + ...report.invoices.map((inv) => _InvoiceListItem( + invoice: inv.invoiceNumber, + vendor: inv.vendorName, + date: DateFormat('MMM dd, yyyy') + .format(inv.issueDate), + amount: NumberFormat.currency(symbol: r'$') + .format(inv.amount), + status: inv.status, + statusColor: inv.status == 'PAID' + ? UiColors.success + : inv.status == 'PENDING' + ? UiColors.textWarning + : UiColors.error, + )), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +class _SpendBarChart extends StatelessWidget { + final List chartData; + + const _SpendBarChart({required this.chartData}); + + @override + Widget build(BuildContext context) { + return BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: (chartData.fold( + 0, (prev, element) => element.amount > prev ? element.amount : prev) * + 1.2) + .ceilToDouble(), + barTouchData: BarTouchData( + touchTooltipData: BarTouchTooltipData( + tooltipPadding: const EdgeInsets.all(8), + getTooltipItem: (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '\$${rod.toY.round()}', + const TextStyle( + color: UiColors.white, + fontWeight: FontWeight.bold, + ), + ); + }, + ), + ), + titlesData: FlTitlesData( + show: true, + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + if (value.toInt() >= chartData.length) return const SizedBox(); + final date = chartData[value.toInt()].date; + return SideTitleWidget( + axisSide: meta.axisSide, + space: 4, + child: Text( + DateFormat('E').format(date), + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 10, + ), + ), + ); + }, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: const FlGridData(show: false), + borderData: FlBorderData(show: false), + barGroups: List.generate( + chartData.length, + (index) => BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: chartData[index].amount, + color: UiColors.success, + width: 16, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SpendSummaryCard extends StatelessWidget { + final String label; + final String value; + final String change; + final String period; + final Color color; + final IconData icon; + + const _SpendSummaryCard({ + required this.label, + required this.value, + required this.change, + required this.period, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, size: 16, color: color), + ), + if (change.isNotEmpty) + Text( + change, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: change.startsWith('+') + ? UiColors.error + : UiColors.success, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Text( + period, + style: const TextStyle( + fontSize: 10, + color: UiColors.textDescription, + ), + ), + ], + ), + ); + } +} + +class _StatusMiniCard extends StatelessWidget { + final String label; + final String value; + final Color color; + + const _StatusMiniCard({ + required this.label, + required this.value, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.1)), + ), + child: Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle( + fontSize: 10, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +class _InvoiceListItem extends StatelessWidget { + final String invoice; + final String vendor; + final String date; + final String amount; + final String status; + final Color statusColor; + + const _InvoiceListItem({ + required this.invoice, + required this.vendor, + required this.date, + required this.amount, + required this.status, + required this.statusColor, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.02), + blurRadius: 10, + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(UiIcons.file, size: 20, color: statusColor), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + invoice, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + vendor, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + const SizedBox(height: 4), + Text( + date, + style: const TextStyle( + fontSize: 11, + color: UiColors.textDescription, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amount, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + status.toUpperCase(), + style: TextStyle( + color: statusColor, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart new file mode 100644 index 00000000..959ad51f --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/reports_module.dart @@ -0,0 +1,46 @@ +import 'package:client_reports/src/data/repositories_impl/reports_repository_impl.dart'; +import 'package:client_reports/src/domain/repositories/reports_repository.dart'; +import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/forecast/forecast_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart'; +import 'package:client_reports/src/presentation/pages/coverage_report_page.dart'; +import 'package:client_reports/src/presentation/pages/daily_ops_report_page.dart'; +import 'package:client_reports/src/presentation/pages/forecast_report_page.dart'; +import 'package:client_reports/src/presentation/pages/no_show_report_page.dart'; +import 'package:client_reports/src/presentation/pages/performance_report_page.dart'; +import 'package:client_reports/src/presentation/pages/reports_page.dart'; +import 'package:client_reports/src/presentation/pages/spend_report_page.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +class ReportsModule extends Module { + @override + List get imports => [DataConnectModule()]; + + @override + void binds(Injector i) { + i.addLazySingleton(ReportsRepositoryImpl.new); + i.add(DailyOpsBloc.new); + i.add(SpendBloc.new); + i.add(CoverageBloc.new); + i.add(ForecastBloc.new); + i.add(PerformanceBloc.new); + i.add(NoShowBloc.new); + i.add(ReportsSummaryBloc.new); + } + + @override + void routes(RouteManager r) { + r.child('/', child: (_) => const ReportsPage()); + r.child('/daily-ops', child: (_) => const DailyOpsReportPage()); + r.child('/spend', child: (_) => const SpendReportPage()); + r.child('/forecast', child: (_) => const ForecastReportPage()); + r.child('/performance', child: (_) => const PerformanceReportPage()); + r.child('/no-show', child: (_) => const NoShowReportPage()); + r.child('/coverage', child: (_) => const CoverageReportPage()); + } +} diff --git a/apps/mobile/packages/features/client/reports/pubspec.yaml b/apps/mobile/packages/features/client/reports/pubspec.yaml new file mode 100644 index 00000000..f4807bd9 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/pubspec.yaml @@ -0,0 +1,39 @@ +name: client_reports +description: Workforce reports and analytics for client application +version: 0.0.1 +publish_to: none +resolution: workspace + +environment: + sdk: ^3.6.0 + +dependencies: + flutter: + sdk: flutter + + # Dependencies needed for the prototype + # lucide_icons removed, used via design_system + fl_chart: ^0.66.0 + + # Internal packages + design_system: + path: ../../../design_system + krow_domain: + path: ../../../domain + krow_core: + path: ../../../core + core_localization: + path: ../../../core_localization + krow_data_connect: + path: ../../../data_connect + + # External packages + flutter_modular: ^6.3.4 + flutter_bloc: ^8.1.6 + equatable: ^2.0.7 + intl: ^0.20.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 5d204fcf..85b4954a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -215,14 +215,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { staffRecord = staffResponse.data.staffs.first; } - final String email = user?.email ?? ''; + return _setSession(firebaseUser.uid, user, staffRecord); + } + @override + Future restoreSession() async { + final User? firebaseUser = await _service.auth.authStateChanges().first; + if (firebaseUser == null) { + return; + } + + // Reuse the same logic as verifyOtp to fetch user/staff and set session + // We can't reuse verifyOtp directly because it requires verificationId/smsCode + // So we fetch the data manually here. + + final QueryResult response = + await _service.run( + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + requiresAuthentication: false, + ); + final GetUserByIdUser? user = response.data.user; + + if (user == null) { + // User authenticated in Firebase but not in our DB? + // Should likely sign out or handle gracefully. + await _service.auth.signOut(); + return; + } + + final QueryResult + staffResponse = await _service.run( + () => _service.connector.getStaffByUserId(userId: firebaseUser.uid).execute(), + requiresAuthentication: false, + ); + + final GetStaffByUserIdStaffs? staffRecord = + staffResponse.data.staffs.firstOrNull; + + _setSession(firebaseUser.uid, user, staffRecord); + } + + domain.User _setSession( + String uid, + GetUserByIdUser? user, + GetStaffByUserIdStaffs? staffRecord, + ) { //TO-DO: create(registration) user and staff account //TO-DO: save user data locally final domain.User domainUser = domain.User( - id: firebaseUser.uid, - email: email, - phone: firebaseUser.phoneNumber, + id: uid, + email: user?.email ?? '', + phone: user?.phone, // Use user.phone locally if needed, but domain.User expects it role: user?.role.stringValue ?? 'USER', ); final domain.Staff? domainStaff = staffRecord == null diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 0ee6fc5a..a2a6b804 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,4 +20,7 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); + + /// Restores the user session if a user is already signed in. + Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index e0426496..e089bcb7 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -28,7 +28,6 @@ class StaffAuthenticationModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton(AuthRepositoryImpl.new); i.addLazySingleton(ProfileSetupRepositoryImpl.new); i.addLazySingleton(PlaceRepositoryImpl.new); @@ -53,6 +52,11 @@ class StaffAuthenticationModule extends Module { ); } + @override + void exportedBinds(Injector i) { + i.addLazySingleton(AuthRepositoryImpl.new); + } + @override void routes(RouteManager r) { r.child(StaffPaths.root, child: (_) => const IntroPage()); diff --git a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart index 6b4d54cc..2b910be8 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart @@ -3,3 +3,4 @@ export 'src/presentation/pages/get_started_page.dart'; export 'src/presentation/pages/phone_verification_page.dart'; export 'src/presentation/pages/profile_setup_page.dart'; export 'src/staff_authentication_module.dart'; +export 'src/domain/repositories/auth_repository_interface.dart'; diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index f1380d0c..6ffcd99f 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -35,6 +35,7 @@ workspace: - packages/features/client/view_orders - packages/features/client/client_coverage - packages/features/client/client_main + - packages/features/client/reports - apps/staff - apps/client - apps/design_system_viewer From c82a36ad89970c24147a29d1519f5ca59188cf1d Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 15:40:19 +0530 Subject: [PATCH 02/10] blank error fix --- .../lib/src/presentation/pages/coverage_report_page.dart | 2 +- .../lib/src/presentation/pages/daily_ops_report_page.dart | 2 +- .../lib/src/presentation/pages/forecast_report_page.dart | 2 +- .../reports/lib/src/presentation/pages/no_show_report_page.dart | 2 +- .../lib/src/presentation/pages/performance_report_page.dart | 2 +- .../reports/lib/src/presentation/pages/spend_report_page.dart | 2 +- 6 files changed, 6 insertions(+), 6 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 2c356aac..1491bb83 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 @@ -65,7 +65,7 @@ class _CoverageReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, 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 b78efdbf..1f0a2182 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 @@ -64,7 +64,7 @@ class _DailyOpsReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index 3b5417f4..e6059237 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -64,7 +64,7 @@ class _ForecastReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, 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 cadaf0af..a67392cb 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 @@ -58,7 +58,7 @@ class _NoShowReportPageState extends State { child: Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, 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 82e1aecb..afca3373 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 @@ -58,7 +58,7 @@ class _PerformanceReportPageState extends State { child: Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index dc4b8483..d4266da2 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -63,7 +63,7 @@ class _SpendReportPageState extends State { Row( children: [ GestureDetector( - onTap: () => Modular.to.pop(), + onTap: () => Navigator.of(context).pop(), child: Container( width: 40, height: 40, From 215ddcbc87790e230c0ca1b7672268cb6ed83cbe Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 16:09:59 +0530 Subject: [PATCH 03/10] 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 From 93f2de2ab68bbc6b649287967bebbc8fbf97776e Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 16:15:37 +0530 Subject: [PATCH 04/10] BlocProvider.of(context) --- .../lib/src/presentation/pages/daily_ops_report_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5e6d0d75..4e677a6f 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 @@ -41,7 +41,7 @@ class _DailyOpsReportPageState extends State { if (picked != null && picked != _selectedDate && mounted) { setState(() => _selectedDate = picked); if (context.mounted) { - context.read().add(LoadDailyOpsReport(date: picked)); + BlocProvider.of(context).add(LoadDailyOpsReport(date: picked)); } } } From 917b4e213c8c4cc72e94f66eb0a19b8ffe39e4b6 Mon Sep 17 00:00:00 2001 From: Suriya Date: Wed, 18 Feb 2026 18:44:16 +0530 Subject: [PATCH 05/10] reports page m4 ui done --- .../reports_repository_impl.dart | 32 +- .../src/domain/entities/daily_ops_report.dart | 3 + .../lib/src/domain/entities/spend_report.dart | 24 +- .../pages/coverage_report_page.dart | 124 ++-- .../pages/daily_ops_report_page.dart | 52 +- .../pages/no_show_report_page.dart | 30 +- .../pages/performance_report_page.dart | 15 +- .../presentation/pages/spend_report_page.dart | 561 +++++++----------- .../dataconnect/connector/reports/queries.gql | 6 +- 9 files changed, 410 insertions(+), 437 deletions(-) diff --git a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart index 46d8b323..d395f8b8 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -90,6 +90,7 @@ class ReportsRepositoryImpl implements ReportsRepository { final List spendInvoices = []; final Map dailyAggregates = {}; + final Map industryAggregates = {}; for (final inv in invoices) { final amount = (inv.amount ?? 0.0).toDouble(); @@ -104,6 +105,9 @@ class ReportsRepositoryImpl implements ReportsRepository { overdueInvoices++; } + final industry = inv.vendor?.serviceSpecialty ?? 'Other'; + industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount; + final issueDateTime = inv.issueDate.toDateTime(); spendInvoices.add(SpendInvoice( id: inv.id, @@ -112,6 +116,7 @@ class ReportsRepositoryImpl implements ReportsRepository { amount: amount, status: statusStr, vendorName: inv.vendor?.companyName ?? 'Unknown', + industry: industry, )); // Chart data aggregation @@ -119,19 +124,40 @@ class ReportsRepositoryImpl implements ReportsRepository { dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount; } - final List chartData = dailyAggregates.entries + // Ensure chart data covers all days in range + final Map completeDailyAggregates = {}; + for (int i = 0; i <= endDate.difference(startDate).inDays; i++) { + final date = startDate.add(Duration(days: i)); + final normalizedDate = DateTime(date.year, date.month, date.day); + completeDailyAggregates[normalizedDate] = + dailyAggregates[normalizedDate] ?? 0.0; + } + + final List chartData = completeDailyAggregates.entries .map((e) => SpendChartPoint(date: e.key, amount: e.value)) .toList() - ..sort((a, b) => a.date.compareTo(b.date)); + ..sort((a, b) => a.date.compareTo(b.date)); + + final List industryBreakdown = industryAggregates.entries + .map((e) => SpendIndustryCategory( + name: e.key, + amount: e.value, + percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0, + )) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final daysCount = endDate.difference(startDate).inDays + 1; return SpendReport( totalSpend: totalSpend, - averageCost: invoices.isEmpty ? 0 : totalSpend / invoices.length, + averageCost: daysCount > 0 ? totalSpend / daysCount : 0, paidInvoices: paidInvoices, pendingInvoices: pendingInvoices, overdueInvoices: overdueInvoices, invoices: spendInvoices, chartData: chartData, + industryBreakdown: industryBreakdown, ); }); } diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart index dbc22ba6..fabf262d 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart @@ -34,6 +34,7 @@ class DailyOpsShift extends Equatable { final int workersNeeded; final int filled; final String status; + final double? hourlyRate; const DailyOpsShift({ required this.id, @@ -44,6 +45,7 @@ class DailyOpsShift extends Equatable { required this.workersNeeded, required this.filled, required this.status, + this.hourlyRate, }); @override @@ -56,5 +58,6 @@ class DailyOpsShift extends Equatable { workersNeeded, filled, status, + hourlyRate, ]; } diff --git a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart index 2e8b0829..3e342c00 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart @@ -17,8 +17,11 @@ class SpendReport extends Equatable { required this.overdueInvoices, required this.invoices, required this.chartData, + required this.industryBreakdown, }); + final List industryBreakdown; + @override List get props => [ totalSpend, @@ -28,9 +31,25 @@ class SpendReport extends Equatable { overdueInvoices, invoices, chartData, + industryBreakdown, ]; } +class SpendIndustryCategory extends Equatable { + final String name; + final double amount; + final double percentage; + + const SpendIndustryCategory({ + required this.name, + required this.amount, + required this.percentage, + }); + + @override + List get props => [name, amount, percentage]; +} + class SpendInvoice extends Equatable { final String id; final String invoiceNumber; @@ -46,10 +65,13 @@ class SpendInvoice extends Equatable { required this.amount, required this.status, required this.vendorName, + this.industry, }); + final String? industry; + @override - List get props => [id, invoiceNumber, issueDate, amount, status, vendorName]; + List get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry]; } class SpendChartPoint extends Equatable { 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 06031d10..7ee23f6a 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 @@ -56,7 +56,7 @@ class _CoverageReportPageState extends State { top: 60, left: 20, right: 20, - bottom: 32, + bottom: 80, // Increased bottom padding for overlap background ), decoration: const BoxDecoration( gradient: LinearGradient( @@ -160,53 +160,60 @@ class _CoverageReportPageState extends State { ), ], ), - - const SizedBox(height: 24), - - // ── 3 summary stat chips (matches prototype) ── - Row( - children: [ - _HeaderStatChip( - icon: UiIcons.trendingUp, - label: 'Avg Coverage', - value: - '${report.overallCoverage.toStringAsFixed(0)}%', - ), - const SizedBox(width: 12), - _HeaderStatChip( - icon: UiIcons.checkCircle, - label: 'Full', - value: fullDays.toString(), - ), - const SizedBox(width: 12), - _HeaderStatChip( - icon: UiIcons.warning, - label: 'Needs Help', - value: needsHelpDays.toString(), - isAlert: needsHelpDays > 0, - ), - ], - ), ], ), ), + // ── 3 summary stat chips (Moved here for overlap) ── + Transform.translate( + offset: const Offset(0, -60), // Pull up to overlap header + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _CoverageStatCard( + icon: UiIcons.trendingUp, + label: 'Avg Coverage', + value: '${report.overallCoverage.toStringAsFixed(0)}%', + iconColor: UiColors.primary, + ), + const SizedBox(width: 12), + _CoverageStatCard( + icon: UiIcons.checkCircle, + label: 'Full', + value: fullDays.toString(), + iconColor: UiColors.success, + ), + const SizedBox(width: 12), + _CoverageStatCard( + icon: UiIcons.warning, + label: 'Needs Help', + value: needsHelpDays.toString(), + iconColor: UiColors.error, + ), + ], + ), + ), + ), + // ── Content ────────────────────────────────────────── Transform.translate( - offset: const Offset(0, -16), + offset: const Offset(0, -60), // Pull up to overlap header child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + const SizedBox(height: 32), + // Section label const Text( 'NEXT 7 DAYS', style: TextStyle( - fontSize: 11, + fontSize: 14, fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 1.2, + color: UiColors.textPrimary, + letterSpacing: 0.5, ), ), const SizedBox(height: 16), @@ -250,57 +257,68 @@ class _CoverageReportPageState extends State { } // ── Header stat chip (inside the blue header) ───────────────────────────────── -class _HeaderStatChip extends StatelessWidget { +// ── Header stat card (boxes inside the blue header overlap) ─────────────────── +class _CoverageStatCard extends StatelessWidget { final IconData icon; final String label; final String value; - final bool isAlert; + final Color iconColor; - const _HeaderStatChip({ + const _CoverageStatCard({ required this.icon, required this.label, required this.value, - this.isAlert = false, + required this.iconColor, }); @override Widget build(BuildContext context) { return Expanded( child: Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10), + padding: const EdgeInsets.all(16), // Increased padding decoration: BoxDecoration( - color: UiColors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(10), + color: UiColors.white, + borderRadius: BorderRadius.circular(16), // More rounded + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Icon( icon, - size: 12, - color: isAlert - ? const Color(0xFFFFD580) - : UiColors.white.withOpacity(0.8), + size: 14, + color: iconColor, ), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: 10, - color: UiColors.white.withOpacity(0.8), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, ), ), ], ), - const SizedBox(height: 4), + const SizedBox(height: 8), Text( value, style: const TextStyle( - fontSize: 20, + fontSize: 20, // Slightly smaller to fit if needed fontWeight: FontWeight.bold, - color: UiColors.white, + color: UiColors.textPrimary, ), ), ], @@ -344,7 +362,7 @@ class _DayCoverageCard extends StatelessWidget { final badgeBg = percentage >= 95 ? UiColors.tagSuccess : percentage >= 80 - ? UiColors.tagInProgress + ? UiColors.primary.withOpacity(0.1) // Blue tint : UiColors.tagError; return Container( 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 4e677a6f..66772cef 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 @@ -243,7 +243,7 @@ class _DailyOpsReportPageState extends State { physics: const NeverScrollableScrollPhysics(), mainAxisSpacing: 12, crossAxisSpacing: 12, - childAspectRatio: 1.4, + childAspectRatio: 1.2, children: [ _OpsStatCard( label: context.t.client_reports @@ -314,16 +314,16 @@ class _DailyOpsReportPageState extends State { ], ), - const SizedBox(height: 24), + const SizedBox(height: 8), Text( context.t.client_reports.daily_ops_report .all_shifts_title .toUpperCase(), style: const TextStyle( - fontSize: 12, + fontSize: 14, fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 1.2, + color: UiColors.textPrimary, + letterSpacing: 0.5, ), ), const SizedBox(height: 12), @@ -341,9 +341,12 @@ class _DailyOpsReportPageState extends State { title: shift.title, location: shift.location, time: - '${DateFormat('hh:mm a').format(shift.startTime)} - ${DateFormat('hh:mm a').format(shift.endTime)}', + '${DateFormat('HH:mm').format(shift.startTime)} - ${DateFormat('HH:mm').format(shift.endTime)}', workers: '${shift.filled}/${shift.workersNeeded}', + rate: shift.hourlyRate != null + ? '\$${shift.hourlyRate!.toStringAsFixed(0)}/hr' + : '-', status: shift.status.replaceAll('_', ' '), statusColor: shift.status == 'COMPLETED' ? UiColors.success @@ -399,15 +402,15 @@ class _OpsStatCard extends StatelessWidget { offset: const Offset(0, 2), ), ], - border: Border(left: BorderSide(color: color, width: 4)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 8), Expanded( child: Text( label, @@ -420,7 +423,6 @@ class _OpsStatCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), ), - Icon(icon, size: 14, color: color), ], ), Column( @@ -429,16 +431,29 @@ class _OpsStatCard extends StatelessWidget { Text( value, style: const TextStyle( - fontSize: 24, + fontSize: 28, fontWeight: FontWeight.bold, color: UiColors.textPrimary, ), ), - Text( - subValue, - style: const TextStyle( - fontSize: 10, - color: UiColors.textSecondary, + const SizedBox(height: 6), + // Colored pill badge (matches prototype) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + subValue, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: color, + ), ), ), ], @@ -454,6 +469,7 @@ class _ShiftListItem extends StatelessWidget { final String location; final String time; final String workers; + final String rate; final String status; final Color statusColor; @@ -462,6 +478,7 @@ class _ShiftListItem extends StatelessWidget { required this.location, required this.time, required this.workers, + required this.rate, required this.status, required this.statusColor, }); @@ -557,6 +574,11 @@ class _ShiftListItem extends StatelessWidget { UiIcons.users, context.t.client_reports.daily_ops_report.shift_item.workers, workers), + _infoItem( + context, + UiIcons.trendingUp, + 'Rate', + rate), ], ), ], 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 9a735022..d70c8d79 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 @@ -296,7 +296,7 @@ class _SummaryChip extends StatelessWidget { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(12), @@ -311,24 +311,32 @@ class _SummaryChip extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon(icon, size: 16, color: iconColor), + Row( + children: [ + Icon(icon, size: 12, color: iconColor), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 10, + color: iconColor, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), const SizedBox(height: 8), Text( value, style: const TextStyle( - fontSize: 22, + fontSize: 26, fontWeight: FontWeight.bold, color: UiColors.textPrimary, ), ), - const SizedBox(height: 2), - Text( - label, - style: const TextStyle( - fontSize: 11, - color: UiColors.textSecondary, - ), - ), ], ), ); 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 cba7597a..4dae406e 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 @@ -432,26 +432,27 @@ class _KpiRow extends StatelessWidget { ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, + // Value + badge inline (matches prototype) + Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( kpi.displayValue, style: const TextStyle( - fontSize: 15, + fontSize: 16, fontWeight: FontWeight.bold, color: UiColors.textPrimary, ), ), - const SizedBox(height: 2), + const SizedBox(width: 6), Container( padding: const EdgeInsets.symmetric( - horizontal: 8, + horizontal: 7, vertical: 3, ), decoration: BoxDecoration( color: badgeBg, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(8), ), child: Text( badgeText, @@ -473,7 +474,7 @@ class _KpiRow extends StatelessWidget { value: (kpi.value / 100).clamp(0.0, 1.0), backgroundColor: UiColors.bgSecondary, valueColor: AlwaysStoppedAnimation(kpi.barColor), - minHeight: 5, + minHeight: 6, ), ), ], diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index d4266da2..9f20bcdd 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:intl/intl.dart'; +import 'package:client_reports/src/domain/entities/spend_report.dart'; class SpendReportPage extends StatefulWidget { const SpendReportPage({super.key}); @@ -17,8 +18,19 @@ class SpendReportPage extends StatefulWidget { } class _SpendReportPageState extends State { - DateTime _startDate = DateTime.now().subtract(const Duration(days: 6)); - DateTime _endDate = DateTime.now(); + late DateTime _startDate; + late DateTime _endDate; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + // Monday alignment logic + final diff = now.weekday - DateTime.monday; + final monday = now.subtract(Duration(days: diff)); + _startDate = DateTime(monday.year, monday.month, monday.day); + _endDate = _startDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + } @override Widget build(BuildContext context) { @@ -48,14 +60,10 @@ class _SpendReportPageState extends State { top: 60, left: 20, right: 20, - bottom: 32, + bottom: 80, // Overlap space ), decoration: const BoxDecoration( - gradient: LinearGradient( - colors: [UiColors.success, UiColors.tagSuccess], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + color: UiColors.primary, // Blue background per prototype ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -128,7 +136,7 @@ class _SpendReportPageState extends State { const Icon( UiIcons.download, size: 14, - color: UiColors.success, + color: UiColors.primary, ), const SizedBox(width: 6), Text( @@ -137,7 +145,7 @@ class _SpendReportPageState extends State { .split(' ') .first, style: const TextStyle( - color: UiColors.success, + color: UiColors.primary, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -152,50 +160,50 @@ class _SpendReportPageState extends State { // Content Transform.translate( - offset: const Offset(0, -16), + offset: const Offset(0, -60), // Pull up to overlap child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Summary Cards + // Summary Cards (New Style) Row( children: [ Expanded( - child: _SpendSummaryCard( + child: _SpendStatCard( label: context.t.client_reports.spend_report .summary.total_spend, - value: NumberFormat.currency(symbol: r'$') + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) .format(report.totalSpend), - change: '', // Can be calculated if needed - period: context.t.client_reports + pillText: context.t.client_reports .spend_report.summary.this_week, - color: UiColors.textSuccess, + themeColor: UiColors.success, icon: UiIcons.dollar, ), ), const SizedBox(width: 12), Expanded( - child: _SpendSummaryCard( + child: _SpendStatCard( label: context.t.client_reports.spend_report .summary.avg_daily, - value: NumberFormat.currency(symbol: r'$') + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) .format(report.averageCost), - change: '', - period: context.t.client_reports + pillText: context.t.client_reports .spend_report.summary.per_day, - color: UiColors.primary, - icon: UiIcons.chart, + themeColor: UiColors.primary, + icon: UiIcons.trendingUp, ), ), ], ), const SizedBox(height: 24), - // Chart Section + // Daily Spend Trend Chart Container( - height: 300, - padding: const EdgeInsets.all(16), + height: 320, + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(16), @@ -210,50 +218,15 @@ class _SpendReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Text( - context.t.client_reports.spend_report - .chart_title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: - BorderRadius.circular(8), - ), - child: Row( - children: [ - Text( - context.t.client_reports.tabs.week, - style: const TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - ), - ), - const Icon( - UiIcons.chevronDown, - size: 10, - color: UiColors.textSecondary, - ), - ], - ), - ), - ], + const Text( + 'Daily Spend Trend', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), ), - const SizedBox(height: 24), + const SizedBox(height: 32), Expanded( child: _SpendBarChart( chartData: report.chartData), @@ -263,71 +236,11 @@ class _SpendReportPageState extends State { ), const SizedBox(height: 24), - // Status Distribution - Row( - children: [ - Expanded( - child: _StatusMiniCard( - label: 'Paid', - value: report.paidInvoices.toString(), - color: UiColors.success, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _StatusMiniCard( - label: 'Pending', - value: report.pendingInvoices.toString(), - color: UiColors.textWarning, - ), - ), - const SizedBox(width: 8), - Expanded( - child: _StatusMiniCard( - label: 'Overdue', - value: report.overdueInvoices.toString(), - color: UiColors.error, - ), - ), - ], - ), - const SizedBox(height: 32), - Text( - 'RECENT INVOICES', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: UiColors.textSecondary, - letterSpacing: 1.2, - ), + // Spend by Industry + _SpendByIndustryCard( + industries: report.industryBreakdown, ), - const SizedBox(height: 16), - - // Invoice List - if (report.invoices.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center( - child: - Text('No invoices found for this period'), - ), - ) - else - ...report.invoices.map((inv) => _InvoiceListItem( - invoice: inv.invoiceNumber, - vendor: inv.vendorName, - date: DateFormat('MMM dd, yyyy') - .format(inv.issueDate), - amount: NumberFormat.currency(symbol: r'$') - .format(inv.amount), - status: inv.status, - statusColor: inv.status == 'PAID' - ? UiColors.success - : inv.status == 'PENDING' - ? UiColors.textWarning - : UiColors.error, - )), const SizedBox(height: 100), ], @@ -356,8 +269,9 @@ class _SpendBarChart extends StatelessWidget { return BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, - maxY: (chartData.fold( - 0, (prev, element) => element.amount > prev ? element.amount : prev) * + maxY: (chartData.fold(0, + (prev, element) => + element.amount > prev ? element.amount : prev) * 1.2) .ceilToDouble(), barTouchData: BarTouchData( @@ -379,14 +293,34 @@ class _SpendBarChart extends StatelessWidget { bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, + reservedSize: 30, getTitlesWidget: (value, meta) { if (value.toInt() >= chartData.length) return const SizedBox(); final date = chartData[value.toInt()].date; return SideTitleWidget( axisSide: meta.axisSide, - space: 4, + space: 8, child: Text( DateFormat('E').format(date), + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 11, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value == 0) return const SizedBox(); + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + '\$${(value / 1000).toStringAsFixed(0)}k', style: const TextStyle( color: UiColors.textSecondary, fontSize: 10, @@ -396,9 +330,6 @@ class _SpendBarChart extends StatelessWidget { }, ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), topTitles: const AxisTitles( sideTitles: SideTitles(showTitles: false), ), @@ -406,7 +337,15 @@ class _SpendBarChart extends StatelessWidget { sideTitles: SideTitles(showTitles: false), ), ), - gridData: const FlGridData(show: false), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 1000, + getDrawingHorizontalLine: (value) => FlLine( + color: UiColors.bgSecondary, + strokeWidth: 1, + ), + ), borderData: FlBorderData(show: false), barGroups: List.generate( chartData.length, @@ -416,7 +355,7 @@ class _SpendBarChart extends StatelessWidget { BarChartRodData( toY: chartData[index].amount, color: UiColors.success, - width: 16, + width: 12, borderRadius: const BorderRadius.vertical( top: Radius.circular(4), ), @@ -429,20 +368,18 @@ class _SpendBarChart extends StatelessWidget { } } -class _SpendSummaryCard extends StatelessWidget { +class _SpendStatCard extends StatelessWidget { final String label; final String value; - final String change; - final String period; - final Color color; + final String pillText; + final Color themeColor; final IconData icon; - const _SpendSummaryCard({ + const _SpendStatCard({ required this.label, required this.value, - required this.change, - required this.period, - required this.color, + required this.pillText, + required this.themeColor, required this.icon, }); @@ -450,6 +387,78 @@ class _SpendSummaryCard extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: themeColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: themeColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + pillText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: themeColor, + ), + ), + ), + ], + ), + ); + } +} + +class _SpendByIndustryCard extends StatelessWidget { + final List industries; + + const _SpendByIndustryCard({required this.industries}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(16), @@ -464,209 +473,73 @@ class _SpendSummaryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color.withOpacity(0.1), - shape: BoxShape.circle, - ), - child: Icon(icon, size: 16, color: color), - ), - if (change.isNotEmpty) - Text( - change, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: change.startsWith('+') - ? UiColors.error - : UiColors.success, - ), - ), - ], - ), - const SizedBox(height: 12), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: UiColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - value, - style: const TextStyle( - fontSize: 18, + const Text( + 'Spend by Industry', + style: TextStyle( + fontSize: 14, fontWeight: FontWeight.bold, color: UiColors.textPrimary, ), ), - const SizedBox(height: 4), - Text( - period, - style: const TextStyle( - fontSize: 10, - color: UiColors.textDescription, - ), - ), - ], - ), - ); - } -} - -class _StatusMiniCard extends StatelessWidget { - final String label; - final String value; - final Color color; - - const _StatusMiniCard({ - required this.label, - required this.value, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), - decoration: BoxDecoration( - color: color.withOpacity(0.05), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withOpacity(0.1)), - ), - child: Column( - children: [ - Text( - value, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: color, - ), - ), - const SizedBox(height: 2), - Text( - label, - style: const TextStyle( - fontSize: 10, - color: UiColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ); - } -} - -class _InvoiceListItem extends StatelessWidget { - final String invoice; - final String vendor; - final String date; - final String amount; - final String status; - final Color statusColor; - - const _InvoiceListItem({ - required this.invoice, - required this.vendor, - required this.date, - required this.amount, - required this.status, - required this.statusColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 12), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.02), - blurRadius: 10, - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Icon(UiIcons.file, size: 20, color: statusColor), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - invoice, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 2), - Text( - vendor, - style: const TextStyle( - fontSize: 12, - color: UiColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Text( - date, - style: const TextStyle( - fontSize: 11, - color: UiColors.textDescription, - ), - ), - ], - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - amount, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor.withOpacity(0.1), - borderRadius: BorderRadius.circular(6), - ), + const SizedBox(height: 24), + if (industries.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), child: Text( - status.toUpperCase(), - style: TextStyle( - color: statusColor, - fontSize: 9, - fontWeight: FontWeight.bold, - ), + 'No industry data available', + style: TextStyle(color: UiColors.textSecondary), ), ), - ], - ), + ) + else + ...industries.map((ind) => Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + ind.name, + style: const TextStyle( + fontSize: 13, + color: UiColors.textSecondary, + ), + ), + Text( + NumberFormat.currency(symbol: r'$', decimalDigits: 0) + .format(ind.amount), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ind.percentage / 100, + backgroundColor: UiColors.bgSecondary, + color: UiColors.success, + minHeight: 6, + ), + ), + const SizedBox(height: 6), + Text( + '${ind.percentage.toStringAsFixed(1)}% of total', + style: const TextStyle( + fontSize: 10, + color: UiColors.textDescription, + ), + ), + ], + ), + )), ], ), ); diff --git a/backend/dataconnect/connector/reports/queries.gql b/backend/dataconnect/connector/reports/queries.gql index 10bceae5..84238101 100644 --- a/backend/dataconnect/connector/reports/queries.gql +++ b/backend/dataconnect/connector/reports/queries.gql @@ -281,7 +281,7 @@ query listInvoicesForSpendByBusiness( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -306,7 +306,7 @@ query listInvoicesForSpendByVendor( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -332,7 +332,7 @@ query listInvoicesForSpendByOrder( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } From c4610003b4bf57873242322719ed12b1827c123b Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 19 Feb 2026 13:00:48 +0530 Subject: [PATCH 06/10] feat: complete client reports and hub management UI, comment out export buttons --- .../src/domain/usecases/reorder_usecase.dart | 25 +++ .../domain/usecases/update_hub_usecase.dart | 57 +++++ .../presentation/pages/hub_details_page.dart | 154 ++++++++++++++ .../presentation/widgets/hub_form_dialog.dart | 200 ++++++++++++++++++ .../pages/coverage_report_page.dart | 2 + .../pages/daily_ops_report_page.dart | 2 + .../pages/no_show_report_page.dart | 2 + .../pages/performance_report_page.dart | 2 + .../presentation/pages/spend_report_page.dart | 2 + .../client_settings_page/settings_logout.dart | 87 ++++++++ .../lib/staff_authentication.dart | 1 - 11 files changed, 533 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart create mode 100644 apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart create mode 100644 apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart create mode 100644 apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart create mode 100644 apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart new file mode 100644 index 00000000..296816cf --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Arguments for the ReorderUseCase. +class ReorderArguments { + const ReorderArguments({ + required this.previousOrderId, + required this.newDate, + }); + + final String previousOrderId; + final DateTime newDate; +} + +/// Use case for reordering an existing staffing order. +class ReorderUseCase implements UseCase, ReorderArguments> { + const ReorderUseCase(this._repository); + + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(ReorderArguments params) { + return _repository.reorder(params.previousOrderId, params.newDate); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart new file mode 100644 index 00000000..d62e0f92 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -0,0 +1,57 @@ +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/hub_repository_interface.dart'; +import '../../domain/arguments/create_hub_arguments.dart'; + +/// Arguments for the UpdateHubUseCase. +class UpdateHubArguments { + const UpdateHubArguments({ + required this.id, + this.name, + this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String? name; + final String? address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; +} + +/// Use case for updating an existing hub. +class UpdateHubUseCase implements UseCase, UpdateHubArguments> { + UpdateHubUseCase(this.repository); + + final HubRepositoryInterface repository; + + @override + Future call(UpdateHubArguments params) { + return repository.updateHub( + id: params.id, + name: params.name, + address: params.address, + placeId: params.placeId, + latitude: params.latitude, + longitude: params.longitude, + city: params.city, + state: params.state, + street: params.street, + country: params.country, + zipCode: params.zipCode, + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart new file mode 100644 index 00000000..e3eccc0a --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -0,0 +1,154 @@ +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:krow_domain/krow_domain.dart'; +import '../blocs/client_hubs_bloc.dart'; +import '../blocs/client_hubs_event.dart'; +import '../widgets/hub_form_dialog.dart'; + +class HubDetailsPage extends StatelessWidget { + const HubDetailsPage({ + required this.hub, + required this.bloc, + super.key, + }); + + final Hub hub; + final ClientHubsBloc bloc; + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: bloc, + child: Scaffold( + appBar: AppBar( + title: Text(hub.name), + backgroundColor: UiColors.foreground, + leading: IconButton( + icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), + onPressed: () => Modular.to.pop(), + ), + actions: [ + IconButton( + icon: const Icon(UiIcons.edit, color: UiColors.white), + onPressed: () => _showEditDialog(context), + ), + ], + ), + backgroundColor: UiColors.bgMenu, + body: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailItem( + label: 'Name', + value: hub.name, + icon: UiIcons.home, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: 'Address', + value: hub.address, + icon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: 'NFC Tag', + value: hub.nfcTagId ?? 'Not Assigned', + icon: UiIcons.nfc, + isHighlight: hub.nfcTagId != null, + ), + ], + ), + ), + ), + ); + } + + Widget _buildDetailItem({ + required String label, + required String value, + required IconData icon, + bool isHighlight = false, + }) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow( + color: UiColors.popupShadow, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: isHighlight ? UiColors.tagInProgress : UiColors.bgInput, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + icon, + color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary, + size: 20, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: UiTypography.footnote1r.textSecondary, + ), + const SizedBox(height: UiConstants.space1), + Text( + value, + style: UiTypography.body1m.textPrimary, + ), + ], + ), + ), + ], + ), + ); + } + + void _showEditDialog(BuildContext context) { + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => HubFormDialog( + hub: hub, + onSave: (name, address, {placeId, latitude, longitude, city, state, street, country, zipCode}) { + bloc.add( + ClientHubsUpdateRequested( + id: hub.id, + name: name, + address: address, + placeId: placeId, + latitude: latitude, + longitude: longitude, + city: city, + state: state, + street: street, + country: country, + zipCode: zipCode, + ), + ); + Navigator.of(context).pop(); // Close dialog + Navigator.of(context).pop(); // Go back to list to refresh + }, + onCancel: () => Navigator.of(context).pop(), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart new file mode 100644 index 00000000..7a4d0cd7 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart @@ -0,0 +1,200 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'hub_address_autocomplete.dart'; + +/// A dialog for adding or editing a hub. +class HubFormDialog extends StatefulWidget { + + /// Creates a [HubFormDialog]. + const HubFormDialog({ + required this.onSave, + required this.onCancel, + this.hub, + super.key, + }); + + /// The hub to edit. If null, a new hub is created. + final Hub? hub; + + /// Callback when the "Save" button is pressed. + final void Function( + String name, + String address, { + String? placeId, + double? latitude, + double? longitude, + }) onSave; + + /// Callback when the dialog is cancelled. + final VoidCallback onCancel; + + @override + State createState() => _HubFormDialogState(); +} + +class _HubFormDialogState extends State { + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final FocusNode _addressFocusNode; + Prediction? _selectedPrediction; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.hub?.name); + _addressController = TextEditingController(text: widget.hub?.address); + _addressFocusNode = FocusNode(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _addressFocusNode.dispose(); + super.dispose(); + } + + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final bool isEditing = widget.hub != null; + final String title = isEditing + ? 'Edit Hub' // TODO: localize + : t.client_hubs.add_hub_dialog.title; + + final String buttonText = isEditing + ? 'Save Changes' // TODO: localize + : t.client_hubs.add_hub_dialog.create_button; + + return Container( + color: UiColors.bgOverlay, + child: Center( + child: SingleChildScrollView( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow(color: UiColors.popupShadow, blurRadius: 20), + ], + ), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space5), + _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + const SizedBox(height: UiConstants.space4), + _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + const SizedBox(height: UiConstants.space8), + Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: widget.onCancel, + text: t.common.cancel, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + onPressed: () { + if (_formKey.currentState!.validate()) { + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); + return; + } + + widget.onSave( + _nameController.text, + _addressController.text, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: buttonText, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildFieldLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(label, style: UiTypography.body2m.textPrimary), + ); + } + + InputDecoration _buildInputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } +} 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 7ee23f6a..25b68528 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 @@ -120,6 +120,7 @@ class _CoverageReportPageState extends State { ], ), // Export button +/* GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( @@ -158,6 +159,7 @@ class _CoverageReportPageState extends State { ), ), ), +*/ ], ), ], 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 66772cef..323cbcf6 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 @@ -132,6 +132,7 @@ class _DailyOpsReportPageState extends State { ), ], ), +/* GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( @@ -176,6 +177,7 @@ class _DailyOpsReportPageState extends State { ), ), ), +*/ ], ), ), 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 d70c8d79..100f398e 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 @@ -99,6 +99,7 @@ class _NoShowReportPageState extends State { ], ), // Export button +/* GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( @@ -137,6 +138,7 @@ class _NoShowReportPageState extends State { ), ), ), +*/ ], ), ), 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 4dae406e..837053fd 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 @@ -182,6 +182,7 @@ class _PerformanceReportPageState extends State { ], ), // Export +/* GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( @@ -217,6 +218,7 @@ class _PerformanceReportPageState extends State { ), ), ), +*/ ], ), ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index 9f20bcdd..a09aa76c 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -110,6 +110,7 @@ class _SpendReportPageState extends State { ), ], ), +/* GestureDetector( onTap: () { ScaffoldMessenger.of(context).showSnackBar( @@ -154,6 +155,7 @@ class _SpendReportPageState extends State { ), ), ), +*/ ], ), ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart new file mode 100644 index 00000000..ea359254 --- /dev/null +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -0,0 +1,87 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../../blocs/client_settings_bloc.dart'; + +/// A widget that displays the log out button. +class SettingsLogout extends StatelessWidget { + /// Creates a [SettingsLogout]. + const SettingsLogout({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientSettingsProfileEn labels = + t.client_settings.profile; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + sliver: SliverToBoxAdapter( + child: BlocBuilder( + builder: (BuildContext context, ClientSettingsState state) { + return UiButton.primary( + text: labels.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: state is ClientSettingsLoading + ? null + : () => _showSignOutDialog(context), + ); + }, + ), + ), + ); + } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext( + context, + ).read().add(const ClientSettingsSignOutRequested()); + } + + /// Shows a confirmation dialog for signing out. + Future _showSignOutDialog(BuildContext context) { + return showDialog( + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + backgroundColor: UiColors.bgPopup, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), + title: Text( + t.client_settings.profile.log_out, + style: UiTypography.headline3m.textPrimary, + ), + content: Text( + t.client_settings.profile.log_out_confirmation, + style: UiTypography.body2r.textSecondary, + ), + actions: [ + // Log out button + UiButton.primary( + text: t.client_settings.profile.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: () => _onSignoutClicked(context), + ), + + // Cancel button + UiButton.secondary( + text: t.common.cancel, + onPressed: () => Modular.to.pop(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart index 2b910be8..6b4d54cc 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/staff_authentication.dart @@ -3,4 +3,3 @@ export 'src/presentation/pages/get_started_page.dart'; export 'src/presentation/pages/phone_verification_page.dart'; export 'src/presentation/pages/profile_setup_page.dart'; export 'src/staff_authentication_module.dart'; -export 'src/domain/repositories/auth_repository_interface.dart'; From 3b7715a382f19e712e075e1e3cbcd4ad9e0efb9f Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 19 Feb 2026 13:09:44 +0530 Subject: [PATCH 07/10] localization reports page --- .../lib/src/l10n/en.i18n.json | 65 +++++++------------ .../pages/coverage_report_page.dart | 24 +++---- .../pages/daily_ops_report_page.dart | 4 +- .../pages/forecast_report_page.dart | 12 ++-- .../pages/no_show_report_page.dart | 42 ++++++------ .../pages/performance_report_page.dart | 38 +++++------ .../presentation/pages/spend_report_page.dart | 22 +++---- 7 files changed, 96 insertions(+), 111 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index c3810474..274a2416 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1254,6 +1254,7 @@ } }, "all_shifts_title": "ALL SHIFTS", + "no_shifts_today": "No shifts scheduled for today", "shift_item": { "time": "Time", "workers": "Workers", @@ -1289,6 +1290,7 @@ "sun": "Sun" }, "spend_by_industry": "Spend by Industry", + "no_industry_data": "No industry data available", "industries": { "hospitality": "Hospitality", "events": "Events", @@ -1319,41 +1321,16 @@ }, "forecast_report": { "title": "Forecast Report", - "subtitle": "Next 4 weeks projection", - "summary": { - "four_week": "4-Week Forecast", - "avg_weekly": "Avg Weekly", - "total_shifts": "Total Shifts", - "total_hours": "Total Hours", - "total_projected": "Total projected", - "per_week": "Per week", - "scheduled": "Scheduled", - "worker_hours": "Worker hours" + "subtitle": "Projected spend & staffing", + "metrics": { + "projected_spend": "Projected Spend", + "workers_needed": "Workers Needed" }, - "spending_forecast": "Spending Forecast", - "weekly_breakdown": "WEEKLY BREAKDOWN", - "breakdown_headings": { - "shifts": "Shifts", - "hours": "Hours", - "avg_shift": "Avg/Shift" - }, - "insights": { - "title": "Forecast Insights", - "insight_1": { - "prefix": "Demand is expected to spike by ", - "highlight": "25%", - "suffix": " in week 3" - }, - "insight_2": { - "prefix": "Projected spend for next month is ", - "highlight": "USD 68.4k", - "suffix": "" - }, - "insight_3": { - "prefix": "Consider increasing budget for ", - "highlight": "Holiday Season", - "suffix": " coverage" - } + "chart_title": "Spending Forecast", + "daily_projections": "DAILY PROJECTIONS", + "empty_state": "No projections available", + "shift_item": { + "workers_needed": "$count workers needed" }, "placeholders": { "export_message": "Exporting Forecast Report (Placeholder)" @@ -1364,7 +1341,9 @@ "subtitle": "Key metrics & benchmarks", "overall_score": { "title": "Overall Performance Score", - "label": "Excellent" + "excellent": "Excellent", + "good": "Good", + "needs_work": "Needs Work" }, "kpis_title": "KEY PERFORMANCE INDICATORS", "kpis": { @@ -1373,8 +1352,11 @@ "on_time_rate": "On-Time Rate", "avg_fill_time": "Avg Fill Time", "target_prefix": "Target: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", "met": "✓ Met", - "close": "↗ Close" + "close": "→ Close", + "miss": "✗ Miss" }, "additional_metrics_title": "ADDITIONAL METRICS", "additional_metrics": { @@ -1425,13 +1407,13 @@ "title": "Reliability Insights", "insight_1": { "prefix": "Your no-show rate of ", - "highlight": "1.2%", - "suffix": " is below industry average" + "highlight": "$rate%", + "suffix": " is $comparison industry average" }, "insight_2": { "prefix": "", - "highlight": "1 worker", - "suffix": " has multiple incidents this month" + "highlight": "$count worker(s)", + "suffix": " have multiple incidents this month" }, "insight_3": { "prefix": "Consider implementing ", @@ -1439,6 +1421,7 @@ "suffix": " 24hrs before shifts" } }, + "empty_state": "No workers flagged for no-shows", "placeholders": { "export_message": "Exporting No-Show Report (Placeholder)" } @@ -1452,9 +1435,11 @@ "needs_help": "Needs Help" }, "next_7_days": "NEXT 7 DAYS", + "empty_state": "No shifts scheduled", "shift_item": { "confirmed_workers": "$confirmed/$needed workers confirmed", "spots_remaining": "$count spots remaining", + "one_spot_remaining": "1 spot remaining", "fully_staffed": "Fully staffed" }, "insights": { 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 25b68528..24a0bef4 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 @@ -175,21 +175,21 @@ class _CoverageReportPageState extends State { children: [ _CoverageStatCard( icon: UiIcons.trendingUp, - label: 'Avg Coverage', + label: context.t.client_reports.coverage_report.metrics.avg_coverage, value: '${report.overallCoverage.toStringAsFixed(0)}%', iconColor: UiColors.primary, ), const SizedBox(width: 12), _CoverageStatCard( icon: UiIcons.checkCircle, - label: 'Full', + label: context.t.client_reports.coverage_report.metrics.full, value: fullDays.toString(), iconColor: UiColors.success, ), const SizedBox(width: 12), _CoverageStatCard( icon: UiIcons.warning, - label: 'Needs Help', + label: context.t.client_reports.coverage_report.metrics.needs_help, value: needsHelpDays.toString(), iconColor: UiColors.error, ), @@ -209,8 +209,8 @@ class _CoverageReportPageState extends State { const SizedBox(height: 32), // Section label - const Text( - 'NEXT 7 DAYS', + Text( + context.t.client_reports.coverage_report.next_7_days, style: TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -224,9 +224,9 @@ class _CoverageReportPageState extends State { Container( padding: const EdgeInsets.all(40), alignment: Alignment.center, - child: const Text( - 'No shifts scheduled', - style: TextStyle( + child: Text( + context.t.client_reports.coverage_report.empty_state, + style: const TextStyle( color: UiColors.textSecondary, ), ), @@ -398,7 +398,7 @@ class _DayCoverageCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - '$filled/$needed workers confirmed', + context.t.client_reports.coverage_report.shift_item.confirmed_workers(confirmed: filled.toString(), needed: needed.toString()), style: const TextStyle( fontSize: 12, color: UiColors.textSecondary, @@ -442,8 +442,10 @@ class _DayCoverageCard extends StatelessWidget { alignment: Alignment.centerRight, child: Text( isFullyStaffed - ? 'Fully staffed' - : '$spotsRemaining spot${spotsRemaining != 1 ? 's' : ''} remaining', + ? context.t.client_reports.coverage_report.shift_item.fully_staffed + : spotsRemaining == 1 + ? context.t.client_reports.coverage_report.shift_item.one_spot_remaining + : context.t.client_reports.coverage_report.shift_item.spots_remaining(count: spotsRemaining.toString()), style: TextStyle( fontSize: 11, color: isFullyStaffed 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 323cbcf6..a0b3c512 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 @@ -335,7 +335,7 @@ class _DailyOpsReportPageState extends State { const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: Center( - child: Text('No shifts scheduled for today'), + child: Text(context.t.client_reports.daily_ops_report.no_shifts_today), ), ) else @@ -579,7 +579,7 @@ class _ShiftListItem extends StatelessWidget { _infoItem( context, UiIcons.trendingUp, - 'Rate', + context.t.client_reports.daily_ops_report.shift_item.rate, rate), ], ), diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart index e6059237..b7e11efc 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/forecast_report_page.dart @@ -120,7 +120,7 @@ class _ForecastReportPageState extends State { children: [ Expanded( child: _ForecastSummaryCard( - label: 'Projected Spend', + label: context.t.client_reports.forecast_report.metrics.projected_spend, value: NumberFormat.currency(symbol: r'$') .format(report.projectedSpend), icon: UiIcons.dollar, @@ -130,7 +130,7 @@ class _ForecastReportPageState extends State { const SizedBox(width: 12), Expanded( child: _ForecastSummaryCard( - label: 'Workers Needed', + label: context.t.client_reports.forecast_report.metrics.workers_needed, value: report.projectedWorkers.toString(), icon: UiIcons.users, color: UiColors.primary, @@ -158,7 +158,7 @@ class _ForecastReportPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Spending Forecast', + context.t.client_reports.forecast_report.chart_title, style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, @@ -178,7 +178,7 @@ class _ForecastReportPageState extends State { // Daily List Text( - 'DAILY PROJECTIONS', + context.t.client_reports.forecast_report.daily_projections, style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, @@ -188,7 +188,7 @@ class _ForecastReportPageState extends State { ), const SizedBox(height: 16), if (report.chartData.isEmpty) - const Center(child: Text('No projections available')) + Center(child: Text(context.t.client_reports.forecast_report.empty_state)) else ...report.chartData.map((point) => _ForecastListItem( date: DateFormat('EEE, MMM dd').format(point.date), @@ -348,7 +348,7 @@ class _ForecastListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(date, style: const TextStyle(fontWeight: FontWeight.bold)), - Text('$workers workers needed', style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), + Text(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), ], ), Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)), 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 100f398e..5c94d928 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 @@ -158,7 +158,7 @@ class _NoShowReportPageState extends State { child: _SummaryChip( icon: UiIcons.warning, iconColor: UiColors.error, - label: 'No-Shows', + label: context.t.client_reports.no_show_report.metrics.no_shows, value: report.totalNoShows.toString(), ), ), @@ -167,7 +167,7 @@ class _NoShowReportPageState extends State { child: _SummaryChip( icon: UiIcons.trendingUp, iconColor: UiColors.textWarning, - label: 'Rate', + label: context.t.client_reports.no_show_report.metrics.rate, value: '${report.noShowRate.toStringAsFixed(1)}%', ), @@ -177,7 +177,7 @@ class _NoShowReportPageState extends State { child: _SummaryChip( icon: UiIcons.user, iconColor: UiColors.primary, - label: 'Workers', + label: context.t.client_reports.no_show_report.metrics.workers, value: uniqueWorkers.toString(), ), ), @@ -204,9 +204,9 @@ class _NoShowReportPageState extends State { Container( padding: const EdgeInsets.all(40), alignment: Alignment.center, - child: const Text( - 'No workers flagged for no-shows', - style: TextStyle( + child: Text( + context.t.client_reports.no_show_report.empty_state, + style: const TextStyle( color: UiColors.textSecondary, ), ), @@ -232,9 +232,9 @@ class _NoShowReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - '💡 Reliability Insights', - style: TextStyle( + Text( + '💡 ${context.t.client_reports.no_show_report.insights.title}', + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: UiColors.textPrimary, @@ -243,21 +243,19 @@ class _NoShowReportPageState extends State { const SizedBox(height: 12), _InsightLine( text: - '· Your no-show rate of ${report.noShowRate.toStringAsFixed(1)}% is ' - '${report.noShowRate < 5 ? 'below' : 'above'} industry average', + '· ${context.t.client_reports.no_show_report.insights.insight_1.prefix}${context.t.client_reports.no_show_report.insights.insight_1.highlight(rate: report.noShowRate.toStringAsFixed(1))}${context.t.client_reports.no_show_report.insights.insight_1.suffix(comparison: report.noShowRate < 5 ? 'below' : 'above')}', ), 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', + '· ${context.t.client_reports.no_show_report.insights.insight_2.highlight(count: report.flaggedWorkers.where((w) => w.noShowCount > 1).length.toString())} ${context.t.client_reports.no_show_report.insights.insight_2.suffix}', bold: true, ), - const _InsightLine( + _InsightLine( text: - '· Consider implementing confirmation reminders 24hrs before shifts', + '· ${context.t.client_reports.no_show_report.insights.insight_3.prefix}${context.t.client_reports.no_show_report.insights.insight_3.highlight}${context.t.client_reports.no_show_report.insights.insight_3.suffix}', bold: true, ), ], @@ -352,9 +350,9 @@ class _WorkerCard extends StatelessWidget { const _WorkerCard({required this.worker}); String _riskLabel(int count) { - if (count >= 3) return 'High Risk'; - if (count == 2) return 'Medium Risk'; - return 'Low Risk'; + if (count >= 3) return context.t.client_reports.no_show_report.risks.high; + if (count == 2) return context.t.client_reports.no_show_report.risks.medium; + return context.t.client_reports.no_show_report.risks.low; } Color _riskColor(int count) { @@ -421,7 +419,7 @@ class _WorkerCard extends StatelessWidget { ), ), Text( - '${worker.noShowCount} no-show${worker.noShowCount > 1 ? 's' : ''}', + context.t.client_reports.no_show_report.no_show_count(count: worker.noShowCount.toString()), style: const TextStyle( fontSize: 12, color: UiColors.textSecondary, @@ -458,9 +456,9 @@ class _WorkerCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Latest incident', - style: TextStyle( + Text( + context.t.client_reports.no_show_report.latest_incident, + style: const TextStyle( fontSize: 11, color: UiColors.textSecondary, ), 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 837053fd..d1455b42 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 @@ -50,10 +50,10 @@ class _PerformanceReportPageState extends State { .clamp(0.0, 100.0); final scoreLabel = overallScore >= 90 - ? 'Excellent' + ? context.t.client_reports.performance_report.overall_score.excellent : overallScore >= 75 - ? 'Good' - : 'Needs Work'; + ? context.t.client_reports.performance_report.overall_score.good + : context.t.client_reports.performance_report.overall_score.needs_work; final scoreLabelColor = overallScore >= 90 ? UiColors.success : overallScore >= 75 @@ -70,8 +70,8 @@ class _PerformanceReportPageState extends State { _KpiData( icon: UiIcons.users, iconColor: UiColors.primary, - label: 'Fill Rate', - target: 'Target: 95%', + label: context.t.client_reports.performance_report.kpis.fill_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '95'), value: report.fillRate, displayValue: '${report.fillRate.toStringAsFixed(0)}%', barColor: UiColors.primary, @@ -81,8 +81,8 @@ class _PerformanceReportPageState extends State { _KpiData( icon: UiIcons.checkCircle, iconColor: UiColors.success, - label: 'Completion Rate', - target: 'Target: 98%', + label: context.t.client_reports.performance_report.kpis.completion_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '98'), value: report.completionRate, displayValue: '${report.completionRate.toStringAsFixed(0)}%', barColor: UiColors.success, @@ -92,8 +92,8 @@ class _PerformanceReportPageState extends State { _KpiData( icon: UiIcons.clock, iconColor: const Color(0xFF9B59B6), - label: 'On-Time Rate', - target: 'Target: 97%', + label: context.t.client_reports.performance_report.kpis.on_time_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), value: report.onTimeRate, displayValue: '${report.onTimeRate.toStringAsFixed(0)}%', barColor: const Color(0xFF9B59B6), @@ -103,8 +103,8 @@ class _PerformanceReportPageState extends State { _KpiData( icon: UiIcons.trendingUp, iconColor: const Color(0xFFF39C12), - label: 'Avg Fill Time', - target: 'Target: 3 hrs', + label: context.t.client_reports.performance_report.kpis.avg_fill_time, + target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), // invert: lower is better — show as % of target met value: report.avgFillTimeHours == 0 ? 100 @@ -256,8 +256,8 @@ class _PerformanceReportPageState extends State { color: UiColors.primary, ), const SizedBox(height: 12), - const Text( - 'Overall Performance Score', + Text( + context.t.client_reports.performance_report.overall_score.title, style: TextStyle( fontSize: 13, color: UiColors.textSecondary, @@ -313,9 +313,9 @@ class _PerformanceReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'KEY PERFORMANCE INDICATORS', - style: TextStyle( + Text( + context.t.client_reports.performance_report.kpis_title, + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: UiColors.textSecondary, @@ -381,10 +381,10 @@ class _KpiRow extends StatelessWidget { @override Widget build(BuildContext context) { final badgeText = kpi.met - ? '✓ Met' + ? context.t.client_reports.performance_report.kpis.met : kpi.close - ? '→ Close' - : '✗ Miss'; + ? context.t.client_reports.performance_report.kpis.close + : context.t.client_reports.performance_report.kpis.miss; final badgeColor = kpi.met ? UiColors.success : kpi.close diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart index a09aa76c..77798c80 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -220,9 +220,9 @@ class _SpendReportPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Daily Spend Trend', - style: TextStyle( + Text( + context.t.client_reports.spend_report.chart_title, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: UiColors.textPrimary, @@ -475,9 +475,9 @@ class _SpendByIndustryCard extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Spend by Industry', - style: TextStyle( + Text( + context.t.client_reports.spend_report.spend_by_industry, + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.bold, color: UiColors.textPrimary, @@ -485,12 +485,12 @@ class _SpendByIndustryCard extends StatelessWidget { ), const SizedBox(height: 24), if (industries.isEmpty) - const Center( + Center( child: Padding( - padding: EdgeInsets.all(16.0), + padding: const EdgeInsets.all(16.0), child: Text( - 'No industry data available', - style: TextStyle(color: UiColors.textSecondary), + context.t.client_reports.spend_report.no_industry_data, + style: const TextStyle(color: UiColors.textSecondary), ), ), ) @@ -533,7 +533,7 @@ class _SpendByIndustryCard extends StatelessWidget { ), const SizedBox(height: 6), Text( - '${ind.percentage.toStringAsFixed(1)}% of total', + context.t.client_reports.spend_report.percent_total(percent: ind.percentage.toStringAsFixed(1)), style: const TextStyle( fontSize: 10, color: UiColors.textDescription, From 9234c26dad3bfec680582ce46eb4a2521c741af7 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 19 Feb 2026 13:41:58 +0530 Subject: [PATCH 08/10] fix compilations --- .../lib/src/widgets/session_listener.dart | 12 -- .../lib/src/l10n/en.i18n.json | 90 ----------- .../lib/src/l10n/es.i18n.json | 149 +++--------------- .../client_create_order_repository_impl.dart | 4 +- .../pages/daily_ops_report_page.dart | 4 +- .../pages/no_show_report_page.dart | 74 +-------- .../src/presentation/pages/reports_page.dart | 119 +------------- .../auth_repository_impl.dart | 50 +----- .../auth_repository_interface.dart | 2 - .../lib/src/staff_authentication_module.dart | 5 +- 10 files changed, 36 insertions(+), 473 deletions(-) diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart index 225c67ec..3fdac2c5 100644 --- a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -66,18 +66,6 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); - if (StaffSessionStore.instance.session == null) { - try { - final AuthRepositoryInterface authRepo = - Modular.get(); - await authRepo.restoreSession(); - } catch (e) { - if (mounted) { - _proceedToLogin(); - } - return; - } - } // Navigate to the main app Modular.to.toStaffHome(); diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 274a2416..0fbaa7bd 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1214,24 +1214,6 @@ "performance": "Performance Report" } }, - "ai_insights": { - "title": "AI Insights", - "insight_1": { - "prefix": "You could save ", - "highlight": "USD 1,200/month", - "suffix": " by booking workers 48hrs in advance" - }, - "insight_2": { - "prefix": "Weekend demand is ", - "highlight": "40% higher", - "suffix": " - consider scheduling earlier" - }, - "insight_3": { - "prefix": "Your top 5 workers complete ", - "highlight": "95% of shifts", - "suffix": " - mark them as preferred" - } - }, "daily_ops_report": { "title": "Daily Ops Report", "subtitle": "Real-time shift tracking", @@ -1297,24 +1279,6 @@ "retail": "Retail" }, "percent_total": "$percent% of total", - "insights": { - "title": "Cost Insights", - "insight_1": { - "prefix": "Your spend is ", - "highlight": "8% lower", - "suffix": " than last week" - }, - "insight_2": { - "prefix": "", - "highlight": "Friday", - "suffix": " had the highest spend (USD 4.1k)" - }, - "insight_3": { - "prefix": "Hospitality accounts for ", - "highlight": "48%", - "suffix": " of total costs" - } - }, "placeholders": { "export_message": "Exporting Spend Report (Placeholder)" } @@ -1365,24 +1329,6 @@ "worker_pool": "Worker Pool", "avg_rating": "Avg Rating" }, - "insights": { - "title": "Performance Insights", - "insight_1": { - "prefix": "Your fill rate is ", - "highlight": "4% above", - "suffix": " industry benchmark" - }, - "insight_2": { - "prefix": "Worker retention is at ", - "highlight": "high", - "suffix": " levels this quarter" - }, - "insight_3": { - "prefix": "On-time arrival improved by ", - "highlight": "12%", - "suffix": " since last month" - } - }, "placeholders": { "export_message": "Exporting Performance Report (Placeholder)" } @@ -1403,24 +1349,6 @@ "medium": "Medium Risk", "low": "Low Risk" }, - "insights": { - "title": "Reliability Insights", - "insight_1": { - "prefix": "Your no-show rate of ", - "highlight": "$rate%", - "suffix": " is $comparison industry average" - }, - "insight_2": { - "prefix": "", - "highlight": "$count worker(s)", - "suffix": " have multiple incidents this month" - }, - "insight_3": { - "prefix": "Consider implementing ", - "highlight": "confirmation reminders", - "suffix": " 24hrs before shifts" - } - }, "empty_state": "No workers flagged for no-shows", "placeholders": { "export_message": "Exporting No-Show Report (Placeholder)" @@ -1442,24 +1370,6 @@ "one_spot_remaining": "1 spot remaining", "fully_staffed": "Fully staffed" }, - "insights": { - "title": "Coverage Insights", - "insight_1": { - "prefix": "Your average coverage rate is ", - "highlight": "96%", - "suffix": " - above industry standard" - }, - "insight_2": { - "prefix": "", - "highlight": "2 days", - "suffix": " need immediate attention to reach full coverage" - }, - "insight_3": { - "prefix": "Weekend coverage is typically ", - "highlight": "98%", - "suffix": " vs weekday 94%" - } - }, "placeholders": { "export_message": "Exporting Coverage Report (Placeholder)" } diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 17891b86..8f9451a9 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1214,24 +1214,6 @@ "performance": "Informe de Rendimiento" } }, - "ai_insights": { - "title": "Perspectivas de IA", - "insight_1": { - "prefix": "Podrías ahorrar ", - "highlight": "USD 1,200/mes", - "suffix": " reservando trabajadores con 48h de antelación" - }, - "insight_2": { - "prefix": "La demanda del fin de semana es un ", - "highlight": "40% superior", - "suffix": " - considera programar antes" - }, - "insight_3": { - "prefix": "Tus 5 mejores trabajadores completan el ", - "highlight": "95% de los turnos", - "suffix": " - márcalos como preferidos" - } - }, "daily_ops_report": { "title": "Informe de Ops Diarias", "subtitle": "Seguimiento de turnos en tiempo real", @@ -1254,6 +1236,7 @@ } }, "all_shifts_title": "TODOS LOS TURNOS", + "no_shifts_today": "No hay turnos programados para hoy", "shift_item": { "time": "Hora", "workers": "Trabajadores", @@ -1266,7 +1249,7 @@ "completed": "Completado" }, "placeholders": { - "export_message": "Exportando Informe de Operaciones Diarias (Marcador de posición)" + "export_message": "Exportando Informe de Ops Diarias (Marcador de posición)" } }, "spend_report": { @@ -1295,65 +1278,23 @@ "retail": "Venta minorista" }, "percent_total": "$percent% del total", - "insights": { - "title": "Perspectivas de Costos", - "insight_1": { - "prefix": "Tu gasto es un ", - "highlight": "8% menor", - "suffix": " que la semana pasada" - }, - "insight_2": { - "prefix": "El ", - "highlight": "Viernes", - "suffix": " tuvo el mayor gasto (USD 4.1k)" - }, - "insight_3": { - "prefix": "La hostelería representa el ", - "highlight": "48%", - "suffix": " de los costos totales" - } - }, + "no_industry_data": "No hay datos de la industria disponibles", "placeholders": { "export_message": "Exportando Informe de Gastos (Marcador de posición)" } }, "forecast_report": { "title": "Informe de Previsión", - "subtitle": "Proyección para las próximas 4 semanas", - "summary": { - "four_week": "Previsión de 4 Semanas", - "avg_weekly": "Promedio Semanal", - "total_shifts": "Total de Turnos", - "total_hours": "Total de Horas", - "total_projected": "Total proyectado", - "per_week": "Por semana", - "scheduled": "Programado", - "worker_hours": "Horas de trabajadores" + "subtitle": "Gastos y personal proyectados", + "metrics": { + "projected_spend": "Gasto Proyectado", + "workers_needed": "Trabajadores Necesarios" }, - "spending_forecast": "Previsión de Gastos", - "weekly_breakdown": "DESGLOSE SEMANAL", - "breakdown_headings": { - "shifts": "Turnos", - "hours": "Horas", - "avg_shift": "Prom/Turno" - }, - "insights": { - "title": "Perspectivas de Previsión", - "insight_1": { - "prefix": "Se espera que la demanda aumente un ", - "highlight": "25%", - "suffix": " en la semana 3" - }, - "insight_2": { - "prefix": "El gasto proyectado para el próximo mes es de ", - "highlight": "USD 68.4k", - "suffix": "" - }, - "insight_3": { - "prefix": "Considera aumentar el presupuesto para la cobertura de ", - "highlight": "Temporada de Vacaciones", - "suffix": "" - } + "chart_title": "Previsión de Gastos", + "daily_projections": "PROYECCIONES DIARIAS", + "empty_state": "No hay proyecciones disponibles", + "shift_item": { + "workers_needed": "$count trabajadores necesarios" }, "placeholders": { "export_message": "Exportando Informe de Previsión (Marcador de posición)" @@ -1364,7 +1305,9 @@ "subtitle": "Métricas clave y comparativas", "overall_score": { "title": "Puntuación de Rendimiento General", - "label": "Excelente" + "excellent": "Excelente", + "good": "Bueno", + "needs_work": "Necesita Mejorar" }, "kpis_title": "INDICADORES CLAVE DE RENDIMIENTO (KPI)", "kpis": { @@ -1373,8 +1316,11 @@ "on_time_rate": "Tasa de Puntualidad", "avg_fill_time": "Tiempo Promedio de Llenado", "target_prefix": "Objetivo: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", "met": "✓ Cumplido", - "close": "↗ Cerca" + "close": "→ Cerca", + "miss": "✗ Fallido" }, "additional_metrics_title": "MÉTRICAS ADICIONALES", "additional_metrics": { @@ -1383,24 +1329,6 @@ "worker_pool": "Grupo de Trabajadores", "avg_rating": "Calificación Promedio" }, - "insights": { - "title": "Perspectivas de Rendimiento", - "insight_1": { - "prefix": "Tu tasa de llenado es un ", - "highlight": "4% superior", - "suffix": " al promedio de la industria" - }, - "insight_2": { - "prefix": "La retención de trabajadores está en niveles ", - "highlight": "altos", - "suffix": " este trimestre" - }, - "insight_3": { - "prefix": "La llegada puntual mejoró un ", - "highlight": "12%", - "suffix": " desde el mes pasado" - } - }, "placeholders": { "export_message": "Exportando Informe de Rendimiento (Marcador de posición)" } @@ -1421,24 +1349,7 @@ "medium": "Riesgo Medio", "low": "Riesgo Bajo" }, - "insights": { - "title": "Perspectivas de Confiabilidad", - "insight_1": { - "prefix": "Tu tasa de faltas del ", - "highlight": "1.2%", - "suffix": " está por debajo del promedio de la industria" - }, - "insight_2": { - "prefix": "", - "highlight": "1 trabajador", - "suffix": " tiene múltiples incidentes este mes" - }, - "insight_3": { - "prefix": "Considera implementar ", - "highlight": "recordatorios de confirmación", - "suffix": " 24h antes de los turnos" - } - }, + "empty_state": "No hay trabajadores señalados por faltas", "placeholders": { "export_message": "Exportando Informe de Faltas (Marcador de posición)" } @@ -1452,29 +1363,13 @@ "needs_help": "Necesita Ayuda" }, "next_7_days": "PRÓXIMOS 7 DÍAS", + "empty_state": "No hay turnos programados", "shift_item": { "confirmed_workers": "$confirmed/$needed trabajadores confirmados", "spots_remaining": "$count puestos restantes", + "one_spot_remaining": "1 puesto restante", "fully_staffed": "Totalmente cubierto" }, - "insights": { - "title": "Perspectivas de Cobertura", - "insight_1": { - "prefix": "Tu tasa de cobertura promedio es del ", - "highlight": "96%", - "suffix": " - por encima del estándar de la industria" - }, - "insight_2": { - "prefix": "", - "highlight": "2 días", - "suffix": " necesitan atención inmediata para alcanzar la cobertura completa" - }, - "insight_3": { - "prefix": "La cobertura de fin de semana es típicamente del ", - "highlight": "98%", - "suffix": " vs 94% en días laborables" - } - }, "placeholders": { "export_message": "Exportando Informe de Cobertura (Marcador de posición)" } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 757aff1f..4012ebc4 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -179,7 +179,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .date(orderTimestamp) .startDate(startTimestamp) .endDate(endTimestamp) - .recurringDays(order.recurringDays) + .recurringDays(fdc.AnyValue(order.recurringDays)) .execute(); final String orderId = orderResult.data.order_insert.id; @@ -299,7 +299,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .status(dc.OrderStatus.POSTED) .date(orderTimestamp) .startDate(startTimestamp) - .permanentDays(order.permanentDays) + .permanentDays(fdc.AnyValue(order.permanentDays)) .execute(); final String orderId = orderResult.data.order_insert.id; 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 a0b3c512..8514004c 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 @@ -332,8 +332,8 @@ class _DailyOpsReportPageState extends State { // Shift List if (report.shifts.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 40), + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), child: Center( child: Text(context.t.client_reports.daily_ops_report.no_shifts_today), ), 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 5c94d928..392a4300 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 @@ -216,53 +216,7 @@ class _NoShowReportPageState extends State { (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: [ - Text( - '💡 ${context.t.client_reports.no_show_report.insights.title}', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.bold, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 12), - _InsightLine( - text: - '· ${context.t.client_reports.no_show_report.insights.insight_1.prefix}${context.t.client_reports.no_show_report.insights.insight_1.highlight(rate: report.noShowRate.toStringAsFixed(1))}${context.t.client_reports.no_show_report.insights.insight_1.suffix(comparison: report.noShowRate < 5 ? 'below' : 'above')}', - ), - if (report.flaggedWorkers.any( - (w) => w.noShowCount > 1, - )) - _InsightLine( - text: - '· ${context.t.client_reports.no_show_report.insights.insight_2.highlight(count: report.flaggedWorkers.where((w) => w.noShowCount > 1).length.toString())} ${context.t.client_reports.no_show_report.insights.insight_2.suffix}', - bold: true, - ), - _InsightLine( - text: - '· ${context.t.client_reports.no_show_report.insights.insight_3.prefix}${context.t.client_reports.no_show_report.insights.insight_3.highlight}${context.t.client_reports.no_show_report.insights.insight_3.suffix}', - bold: true, - ), - ], - ), - ), - - const SizedBox(height: 100), + const SizedBox(height: 40), ], ), ), @@ -349,7 +303,7 @@ class _WorkerCard extends StatelessWidget { const _WorkerCard({required this.worker}); - String _riskLabel(int count) { + String _riskLabel(BuildContext context, int count) { if (count >= 3) return context.t.client_reports.no_show_report.risks.high; if (count == 2) return context.t.client_reports.no_show_report.risks.medium; return context.t.client_reports.no_show_report.risks.low; @@ -369,7 +323,7 @@ class _WorkerCard extends StatelessWidget { @override Widget build(BuildContext context) { - final riskLabel = _riskLabel(worker.noShowCount); + final riskLabel = _riskLabel(context, worker.noShowCount); final riskColor = _riskColor(worker.noShowCount); final riskBg = _riskBg(worker.noShowCount); @@ -487,25 +441,3 @@ class _WorkerCard 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/reports_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart index f3a3f59e..6c3f538e 100644 --- a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -316,6 +316,7 @@ class _ReportsPageState extends State color: UiColors.textPrimary, ), ), + /* TextButton.icon( onPressed: () {}, icon: const Icon(UiIcons.download, size: 16), @@ -328,6 +329,7 @@ class _ReportsPageState extends State tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ), + */ ], ), @@ -392,89 +394,7 @@ class _ReportsPageState extends State const SizedBox(height: 24), - // AI Insights - Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: UiColors.tagInProgress.withOpacity(0.3), - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: UiColors.black.withOpacity(0.02), - blurRadius: 2, - offset: const Offset(0, 1), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '💡 ${context.t.client_reports.ai_insights.title}', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: UiColors.textPrimary, - ), - ), - const SizedBox(height: 12), - _InsightRow( - children: [ - TextSpan( - text: context.t.client_reports.ai_insights - .insight_1.prefix), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_1.highlight, - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_1.suffix, - ), - ], - ), - _InsightRow( - children: [ - TextSpan( - text: context.t.client_reports.ai_insights - .insight_2.prefix), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_2.highlight, - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_2.suffix, - ), - ], - ), - _InsightRow( - children: [ - TextSpan( - text: context.t.client_reports.ai_insights - .insight_3.prefix, - ), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_3.highlight, - style: const TextStyle( - fontWeight: FontWeight.bold), - ), - TextSpan( - text: context.t.client_reports.ai_insights - .insight_3.suffix), - ], - ), - ], - ), - ), - - const SizedBox(height: 100), + const SizedBox(height: 40), ], ), ), @@ -661,36 +581,3 @@ class _ReportCard extends StatelessWidget { } } -class _InsightRow extends StatelessWidget { - final List children; - - const _InsightRow({required this.children}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '• ', - style: TextStyle(color: UiColors.textSecondary, fontSize: 14), - ), - Expanded( - child: Text.rich( - TextSpan( - style: const TextStyle( - fontSize: 14, - color: UiColors.textSecondary, - height: 1.4, - ), - children: children, - ), - ), - ), - ], - ), - ); - } -} diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 85b4954a..7b6bc1bc 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -215,57 +215,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { staffRecord = staffResponse.data.staffs.first; } - return _setSession(firebaseUser.uid, user, staffRecord); - } - - @override - Future restoreSession() async { - final User? firebaseUser = await _service.auth.authStateChanges().first; - if (firebaseUser == null) { - return; - } - - // Reuse the same logic as verifyOtp to fetch user/staff and set session - // We can't reuse verifyOtp directly because it requires verificationId/smsCode - // So we fetch the data manually here. - - final QueryResult response = - await _service.run( - () => _service.connector.getUserById(id: firebaseUser.uid).execute(), - requiresAuthentication: false, - ); - final GetUserByIdUser? user = response.data.user; - - if (user == null) { - // User authenticated in Firebase but not in our DB? - // Should likely sign out or handle gracefully. - await _service.auth.signOut(); - return; - } - - final QueryResult - staffResponse = await _service.run( - () => _service.connector.getStaffByUserId(userId: firebaseUser.uid).execute(), - requiresAuthentication: false, - ); - - final GetStaffByUserIdStaffs? staffRecord = - staffResponse.data.staffs.firstOrNull; - - _setSession(firebaseUser.uid, user, staffRecord); - } - - domain.User _setSession( - String uid, - GetUserByIdUser? user, - GetStaffByUserIdStaffs? staffRecord, - ) { //TO-DO: create(registration) user and staff account //TO-DO: save user data locally final domain.User domainUser = domain.User( - id: uid, + id: firebaseUser.uid, email: user?.email ?? '', - phone: user?.phone, // Use user.phone locally if needed, but domain.User expects it + phone: user?.phone, role: user?.role.stringValue ?? 'USER', ); final domain.Staff? domainStaff = staffRecord == null @@ -288,4 +243,5 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } + } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index a2a6b804..bbdc1e63 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -21,6 +21,4 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); - /// Restores the user session if a user is already signed in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart index e089bcb7..b9721c85 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -30,6 +30,7 @@ class StaffAuthenticationModule extends Module { // Repositories i.addLazySingleton(ProfileSetupRepositoryImpl.new); i.addLazySingleton(PlaceRepositoryImpl.new); + i.addLazySingleton(AuthRepositoryImpl.new); // UseCases i.addLazySingleton(SignInWithPhoneUseCase.new); @@ -52,10 +53,6 @@ class StaffAuthenticationModule extends Module { ); } - @override - void exportedBinds(Injector i) { - i.addLazySingleton(AuthRepositoryImpl.new); - } @override void routes(RouteManager r) { From da8f9a44369bdb1e36c48bfeeab8a1d07ecaccd9 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 19 Feb 2026 13:56:35 +0530 Subject: [PATCH 09/10] chore: restore stashed work - new order usecases and domain entities --- .../src/entities/orders/permanent_order.dart | 76 +++---------------- .../src/entities/orders/recurring_order.dart | 33 ++++++++ .../create_permanent_order_usecase.dart | 11 ++- .../create_recurring_order_usecase.dart | 11 ++- 4 files changed, 52 insertions(+), 79 deletions(-) diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart index f7712bc4..c9b85fff 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -1,12 +1,12 @@ import 'package:equatable/equatable.dart'; -import 'permanent_order_position.dart'; +import 'one_time_order.dart'; +import 'one_time_order_position.dart'; -/// Represents a permanent staffing request spanning a date range. +/// Represents a customer's request for permanent/ongoing staffing. class PermanentOrder extends Equatable { const PermanentOrder({ required this.startDate, required this.permanentDays, - required this.location, required this.positions, this.hub, this.eventName, @@ -14,35 +14,21 @@ class PermanentOrder extends Equatable { this.roleRates = const {}, }); - /// Start date for the permanent schedule. final DateTime startDate; - - /// Days of the week to repeat on (e.g., ["SUN", "MON", ...]). + + /// List of days (e.g., ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']) final List permanentDays; - - /// The primary location where the work will take place. - final String location; - - /// The list of positions and headcounts required for this order. - final List positions; - - /// Selected hub details for this order. - final PermanentOrderHubDetails? hub; - - /// Optional order name. + + final List positions; + final OneTimeOrderHubDetails? hub; final String? eventName; - - /// Selected vendor id for this order. final String? vendorId; - - /// Role hourly rates keyed by role id. final Map roleRates; @override - List get props => [ + List get props => [ startDate, permanentDays, - location, positions, hub, eventName, @@ -50,47 +36,3 @@ class PermanentOrder extends Equatable { roleRates, ]; } - -/// Minimal hub details used during permanent order creation. -class PermanentOrderHubDetails extends Equatable { - const PermanentOrderHubDetails({ - required this.id, - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - }); - - final String id; - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - - @override - List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - ]; -} diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart index f11b63ec..df942ad3 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -1,13 +1,23 @@ import 'package:equatable/equatable.dart'; +<<<<<<< Updated upstream import 'recurring_order_position.dart'; /// Represents a recurring staffing request spanning a date range. +======= +import 'one_time_order.dart'; +import 'one_time_order_position.dart'; + +/// Represents a customer's request for recurring staffing. +>>>>>>> Stashed changes class RecurringOrder extends Equatable { const RecurringOrder({ required this.startDate, required this.endDate, required this.recurringDays, +<<<<<<< Updated upstream required this.location, +======= +>>>>>>> Stashed changes required this.positions, this.hub, this.eventName, @@ -15,6 +25,7 @@ class RecurringOrder extends Equatable { this.roleRates = const {}, }); +<<<<<<< Updated upstream /// Start date for the recurring schedule. final DateTime startDate; @@ -48,6 +59,25 @@ class RecurringOrder extends Equatable { endDate, recurringDays, location, +======= + final DateTime startDate; + final DateTime endDate; + + /// List of days (e.g., ['Monday', 'Wednesday']) or bitmask. + final List recurringDays; + + final List positions; + final OneTimeOrderHubDetails? hub; + final String? eventName; + final String? vendorId; + final Map roleRates; + + @override + List get props => [ + startDate, + endDate, + recurringDays, +>>>>>>> Stashed changes positions, hub, eventName, @@ -55,6 +85,7 @@ class RecurringOrder extends Equatable { roleRates, ]; } +<<<<<<< Updated upstream /// Minimal hub details used during recurring order creation. class RecurringOrderHubDetails extends Equatable { @@ -99,3 +130,5 @@ class RecurringOrderHubDetails extends Equatable { zipCode, ]; } +======= +>>>>>>> Stashed changes diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index b3afda92..68aa0aa1 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,16 +1,15 @@ import 'package:krow_core/core.dart'; -import '../arguments/permanent_order_arguments.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a permanent staffing order. -class CreatePermanentOrderUseCase - implements UseCase { - /// Creates a [CreatePermanentOrderUseCase]. +class CreatePermanentOrderUseCase implements UseCase, PermanentOrder> { const CreatePermanentOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override - Future call(PermanentOrderArguments input) { - return _repository.createPermanentOrder(input.order); + Future call(PermanentOrder params) { + return _repository.createPermanentOrder(params); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index f24c5841..193b20ef 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,16 +1,15 @@ import 'package:krow_core/core.dart'; -import '../arguments/recurring_order_arguments.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a recurring staffing order. -class CreateRecurringOrderUseCase - implements UseCase { - /// Creates a [CreateRecurringOrderUseCase]. +class CreateRecurringOrderUseCase implements UseCase, RecurringOrder> { const CreateRecurringOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override - Future call(RecurringOrderArguments input) { - return _repository.createRecurringOrder(input.order); + Future call(RecurringOrder params) { + return _repository.createRecurringOrder(params); } } From 9e9eb0f374a3bc6cbb156090e16f1ecf04e71109 Mon Sep 17 00:00:00 2001 From: Suriya Date: Thu, 19 Feb 2026 16:09:54 +0530 Subject: [PATCH 10/10] finalcommitform4 --- .../lib/src/l10n/en.i18n.json | 19 ++ .../lib/src/l10n/es.i18n.json | 19 ++ .../src/entities/orders/recurring_order.dart | 33 --- .../client_create_order_repository_impl.dart | 12 +- .../create_permanent_order_usecase.dart | 2 +- .../create_recurring_order_usecase.dart | 2 +- .../blocs/permanent_order_bloc.dart | 10 +- .../blocs/recurring_order_bloc.dart | 5 +- .../widgets/client_home_edit_banner.dart | 32 +-- .../features/client/hubs/lib/client_hubs.dart | 2 + .../hub_repository_impl.dart | 72 ++++++ .../hub_repository_interface.dart | 17 ++ .../domain/usecases/update_hub_usecase.dart | 21 +- .../presentation/blocs/client_hubs_bloc.dart | 45 ++++ .../presentation/blocs/client_hubs_event.dart | 44 ++++ .../src/presentation/pages/edit_hub_page.dart | 240 ++++++++++++++++++ .../presentation/pages/hub_details_page.dart | 149 +++++------ .../pages/no_show_report_page.dart | 9 +- .../pages/client_settings_page.dart | 1 + .../settings_actions.dart | 133 ++++++++-- .../settings_profile_header.dart | 166 +++++++----- 21 files changed, 799 insertions(+), 234 deletions(-) create mode 100644 apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 0fbaa7bd..4e60c7fe 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -239,6 +239,24 @@ "address_hint": "Full address", "create_button": "Create Hub" }, + "edit_hub": { + "title": "Edit Hub", + "subtitle": "Update hub details", + "name_label": "Hub Name *", + "name_hint": "e.g., Main Kitchen, Front Desk", + "address_label": "Address", + "address_hint": "Full address", + "save_button": "Save Changes", + "success": "Hub updated successfully!" + }, + "hub_details": { + "title": "Hub Details", + "name_label": "Name", + "address_label": "Address", + "nfc_label": "NFC Tag", + "nfc_not_assigned": "Not Assigned", + "edit_button": "Edit Hub" + }, "nfc_dialog": { "title": "Identify NFC Tag", "instruction": "Tap your phone to the NFC tag to identify it", @@ -1154,6 +1172,7 @@ "success": { "hub": { "created": "Hub created successfully!", + "updated": "Hub updated successfully!", "deleted": "Hub deleted successfully!", "nfc_assigned": "NFC tag assigned successfully!" }, diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 8f9451a9..18ec6f7c 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -253,6 +253,24 @@ "dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.", "cancel": "Cancelar", "delete": "Eliminar" + }, + "edit_hub": { + "title": "Editar Hub", + "subtitle": "Actualizar detalles del hub", + "name_label": "Nombre del Hub", + "name_hint": "Ingresar nombre del hub", + "address_label": "Dirección", + "address_hint": "Ingresar dirección", + "save_button": "Guardar Cambios", + "success": "¡Hub actualizado exitosamente!" + }, + "hub_details": { + "title": "Detalles del Hub", + "edit_button": "Editar", + "name_label": "Nombre del Hub", + "address_label": "Dirección", + "nfc_label": "Etiqueta NFC", + "nfc_not_assigned": "No asignada" } }, "client_create_order": { @@ -1154,6 +1172,7 @@ "success": { "hub": { "created": "¡Hub creado exitosamente!", + "updated": "¡Hub actualizado exitosamente!", "deleted": "¡Hub eliminado exitosamente!", "nfc_assigned": "¡Etiqueta NFC asignada exitosamente!" }, diff --git a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart index df942ad3..f11b63ec 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/recurring_order.dart @@ -1,23 +1,13 @@ import 'package:equatable/equatable.dart'; -<<<<<<< Updated upstream import 'recurring_order_position.dart'; /// Represents a recurring staffing request spanning a date range. -======= -import 'one_time_order.dart'; -import 'one_time_order_position.dart'; - -/// Represents a customer's request for recurring staffing. ->>>>>>> Stashed changes class RecurringOrder extends Equatable { const RecurringOrder({ required this.startDate, required this.endDate, required this.recurringDays, -<<<<<<< Updated upstream required this.location, -======= ->>>>>>> Stashed changes required this.positions, this.hub, this.eventName, @@ -25,7 +15,6 @@ class RecurringOrder extends Equatable { this.roleRates = const {}, }); -<<<<<<< Updated upstream /// Start date for the recurring schedule. final DateTime startDate; @@ -59,25 +48,6 @@ class RecurringOrder extends Equatable { endDate, recurringDays, location, -======= - final DateTime startDate; - final DateTime endDate; - - /// List of days (e.g., ['Monday', 'Wednesday']) or bitmask. - final List recurringDays; - - final List positions; - final OneTimeOrderHubDetails? hub; - final String? eventName; - final String? vendorId; - final Map roleRates; - - @override - List get props => [ - startDate, - endDate, - recurringDays, ->>>>>>> Stashed changes positions, hub, eventName, @@ -85,7 +55,6 @@ class RecurringOrder extends Equatable { roleRates, ]; } -<<<<<<< Updated upstream /// Minimal hub details used during recurring order creation. class RecurringOrderHubDetails extends Equatable { @@ -130,5 +99,3 @@ class RecurringOrderHubDetails extends Equatable { zipCode, ]; } -======= ->>>>>>> Stashed changes diff --git a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 4012ebc4..fff9a19c 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -179,7 +179,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .date(orderTimestamp) .startDate(startTimestamp) .endDate(endTimestamp) - .recurringDays(fdc.AnyValue(order.recurringDays)) + .recurringDays(order.recurringDays) .execute(); final String orderId = orderResult.data.order_insert.id; @@ -274,7 +274,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte if (vendorId == null || vendorId.isEmpty) { throw Exception('Vendor is missing.'); } - final domain.PermanentOrderHubDetails? hub = order.hub; + final domain.OneTimeOrderHubDetails? hub = order.hub; if (hub == null || hub.id.isEmpty) { throw Exception('Hub is missing.'); } @@ -299,7 +299,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte .status(dc.OrderStatus.POSTED) .date(orderTimestamp) .startDate(startTimestamp) - .permanentDays(fdc.AnyValue(order.permanentDays)) + .permanentDays(order.permanentDays) .execute(); final String orderId = orderResult.data.order_insert.id; @@ -311,7 +311,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte final Set selectedDays = Set.from(order.permanentDays); final int workersNeeded = order.positions.fold( 0, - (int sum, domain.PermanentOrderPosition position) => sum + position.count, + (int sum, domain.OneTimeOrderPosition position) => sum + position.count, ); final double shiftCost = _calculatePermanentShiftCost(order); @@ -352,7 +352,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte final String shiftId = shiftResult.data.shift_insert.id; shiftIds.add(shiftId); - for (final domain.PermanentOrderPosition position in order.positions) { + for (final domain.OneTimeOrderPosition position in order.positions) { final DateTime start = _parseTime(day, position.startTime); final DateTime end = _parseTime(day, position.endTime); final DateTime normalizedEnd = @@ -420,7 +420,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte double _calculatePermanentShiftCost(domain.PermanentOrder order) { double total = 0; - for (final domain.PermanentOrderPosition position in order.positions) { + for (final domain.OneTimeOrderPosition position in order.positions) { final DateTime start = _parseTime(order.startDate, position.startTime); final DateTime end = _parseTime(order.startDate, position.endTime); final DateTime normalizedEnd = diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index 68aa0aa1..b79b3359 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a permanent staffing order. -class CreatePermanentOrderUseCase implements UseCase, PermanentOrder> { +class CreatePermanentOrderUseCase implements UseCase { const CreatePermanentOrderUseCase(this._repository); final ClientCreateOrderRepositoryInterface _repository; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index 193b20ef..561a5ef8 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -3,7 +3,7 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a recurring staffing order. -class CreateRecurringOrderUseCase implements UseCase, RecurringOrder> { +class CreateRecurringOrderUseCase implements UseCase { const CreateRecurringOrderUseCase(this._repository); final ClientCreateOrderRepositoryInterface _repository; diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart index 731a8018..48a75b27 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; -import '../../domain/arguments/permanent_order_arguments.dart'; import '../../domain/usecases/create_permanent_order_usecase.dart'; import 'permanent_order_event.dart'; import 'permanent_order_state.dart'; @@ -286,10 +285,9 @@ class PermanentOrderBloc extends Bloc final domain.PermanentOrder order = domain.PermanentOrder( startDate: state.startDate, permanentDays: state.permanentDays, - location: selectedHub.name, positions: state.positions .map( - (PermanentOrderPosition p) => domain.PermanentOrderPosition( + (PermanentOrderPosition p) => domain.OneTimeOrderPosition( role: p.role, count: p.count, startTime: p.startTime, @@ -299,7 +297,7 @@ class PermanentOrderBloc extends Bloc ), ) .toList(), - hub: domain.PermanentOrderHubDetails( + hub: domain.OneTimeOrderHubDetails( id: selectedHub.id, name: selectedHub.name, address: selectedHub.address, @@ -316,9 +314,7 @@ class PermanentOrderBloc extends Bloc vendorId: state.selectedVendor?.id, roleRates: roleRates, ); - await _createPermanentOrderUseCase( - PermanentOrderArguments(order: order), - ); + await _createPermanentOrderUseCase(order); emit(state.copyWith(status: PermanentOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart index b94ed6c1..fc975068 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; -import '../../domain/arguments/recurring_order_arguments.dart'; import '../../domain/usecases/create_recurring_order_usecase.dart'; import 'recurring_order_event.dart'; import 'recurring_order_state.dart'; @@ -334,9 +333,7 @@ class RecurringOrderBloc extends Bloc vendorId: state.selectedVendor?.id, roleRates: roleRates, ); - await _createRecurringOrderUseCase( - RecurringOrderArguments(order: order), - ); + await _createRecurringOrderUseCase(order); emit(state.copyWith(status: RecurringOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index 9c2931d7..bcfe0d31 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -26,7 +26,7 @@ class ClientHomeEditBanner extends StatelessWidget { builder: (BuildContext context, ClientHomeState state) { return AnimatedContainer( duration: const Duration(milliseconds: 300), - height: state.isEditMode ? 76 : 0, + height: state.isEditMode ? 80 : 0, clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -43,21 +43,23 @@ class ClientHomeEditBanner extends StatelessWidget { children: [ const Icon(UiIcons.edit, size: 16, color: UiColors.primary), const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - i18n.dashboard.edit_mode_active, - style: UiTypography.footnote1b.copyWith( - color: UiColors.primary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + i18n.dashboard.edit_mode_active, + style: UiTypography.footnote1b.copyWith( + color: UiColors.primary, + ), ), - ), - Text( - i18n.dashboard.drag_instruction, - style: UiTypography.footnote2r.textSecondary, - ), - ], + Text( + i18n.dashboard.drag_instruction, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ), UiButton.secondary( text: i18n.dashboard.reset, diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 1f7c0eb9..e3dd08f4 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -9,6 +9,7 @@ import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/create_hub_usecase.dart'; import 'src/domain/usecases/delete_hub_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart'; +import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart'; import 'src/presentation/pages/client_hubs_page.dart'; @@ -29,6 +30,7 @@ class ClientHubsModule extends Module { i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new); + i.addLazySingleton(UpdateHubUseCase.new); // BLoCs i.add(ClientHubsBloc.new); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 91de3bdf..c79d15cd 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -124,6 +124,78 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } + @override + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + }) async { + return _service.run(() async { + final _PlaceAddress? placeAddress = + placeId == null || placeId.isEmpty + ? null + : await _fetchPlaceAddress(placeId); + + final dc.UpdateTeamHubVariablesBuilder builder = _service.connector + .updateTeamHub(id: id); + + if (name != null) builder.hubName(name); + if (address != null) builder.address(address); + if (placeId != null || placeAddress != null) { + builder.placeId(placeId ?? placeAddress?.street); + } + if (latitude != null) builder.latitude(latitude); + if (longitude != null) builder.longitude(longitude); + if (city != null || placeAddress?.city != null) { + builder.city(city ?? placeAddress?.city); + } + if (state != null || placeAddress?.state != null) { + builder.state(state ?? placeAddress?.state); + } + if (street != null || placeAddress?.street != null) { + builder.street(street ?? placeAddress?.street); + } + if (country != null || placeAddress?.country != null) { + builder.country(country ?? placeAddress?.country); + } + if (zipCode != null || placeAddress?.zipCode != null) { + builder.zipCode(zipCode ?? placeAddress?.zipCode); + } + + await builder.execute(); + + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); + final String teamId = await _getOrCreateTeamId(business); + final List hubs = await _fetchHubsForTeam( + teamId: teamId, + businessId: business.id, + ); + + for (final domain.Hub hub in hubs) { + if (hub.id == id) return hub; + } + + // Fallback: return a reconstructed Hub from the update inputs. + return domain.Hub( + id: id, + businessId: business.id, + name: name ?? '', + address: address ?? '', + nfcTagId: null, + status: domain.HubStatus.active, + ); + }); + } + Future _getBusinessForCurrentUser() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 5580e6e4..0288d180 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -35,4 +35,21 @@ abstract interface class HubRepositoryInterface { /// /// Takes the [hubId] and the [nfcTagId] to be associated. Future assignNfcTag({required String hubId, required String nfcTagId}); + + /// Updates an existing hub by its [id]. + /// + /// All fields other than [id] are optional — only supplied values are updated. + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart index d62e0f92..97af203e 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -1,10 +1,10 @@ +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../repositories/hub_repository_interface.dart'; -import '../../domain/arguments/create_hub_arguments.dart'; /// Arguments for the UpdateHubUseCase. -class UpdateHubArguments { +class UpdateHubArguments extends UseCaseArgument { const UpdateHubArguments({ required this.id, this.name, @@ -30,10 +30,25 @@ class UpdateHubArguments { final String? street; final String? country; final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; } /// Use case for updating an existing hub. -class UpdateHubUseCase implements UseCase, UpdateHubArguments> { +class UpdateHubUseCase implements UseCase { UpdateHubUseCase(this.repository); final HubRepositoryInterface repository; diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart index 2c2acb02..5096ed70 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -9,6 +9,7 @@ import '../../domain/usecases/assign_nfc_tag_usecase.dart'; import '../../domain/usecases/create_hub_usecase.dart'; import '../../domain/usecases/delete_hub_usecase.dart'; import '../../domain/usecases/get_hubs_usecase.dart'; +import '../../domain/usecases/update_hub_usecase.dart'; import 'client_hubs_event.dart'; import 'client_hubs_state.dart'; @@ -25,13 +26,16 @@ class ClientHubsBloc extends Bloc required CreateHubUseCase createHubUseCase, required DeleteHubUseCase deleteHubUseCase, required AssignNfcTagUseCase assignNfcTagUseCase, + required UpdateHubUseCase updateHubUseCase, }) : _getHubsUseCase = getHubsUseCase, _createHubUseCase = createHubUseCase, _deleteHubUseCase = deleteHubUseCase, _assignNfcTagUseCase = assignNfcTagUseCase, + _updateHubUseCase = updateHubUseCase, super(const ClientHubsState()) { on(_onFetched); on(_onAddRequested); + on(_onUpdateRequested); on(_onDeleteRequested); on(_onNfcTagAssignRequested); on(_onMessageCleared); @@ -42,6 +46,7 @@ class ClientHubsBloc extends Bloc final CreateHubUseCase _createHubUseCase; final DeleteHubUseCase _deleteHubUseCase; final AssignNfcTagUseCase _assignNfcTagUseCase; + final UpdateHubUseCase _updateHubUseCase; void _onAddDialogToggled( ClientHubsAddDialogToggled event, @@ -120,6 +125,46 @@ class ClientHubsBloc extends Bloc ); } + Future _onUpdateRequested( + ClientHubsUpdateRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); + + await handleError( + emit: emit, + action: () async { + await _updateHubUseCase( + UpdateHubArguments( + id: event.id, + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + ), + ); + final List hubs = await _getHubsUseCase(); + emit( + state.copyWith( + status: ClientHubsStatus.actionSuccess, + hubs: hubs, + successMessage: 'Hub updated successfully!', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: ClientHubsStatus.actionFailure, + errorMessage: errorKey, + ), + ); + } + Future _onDeleteRequested( ClientHubsDeleteRequested event, Emitter emit, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart index 9e539c8e..03fd5194 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart @@ -55,6 +55,50 @@ class ClientHubsAddRequested extends ClientHubsEvent { ]; } +/// Event triggered to update an existing hub. +class ClientHubsUpdateRequested extends ClientHubsEvent { + const ClientHubsUpdateRequested({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + /// Event triggered to delete a hub. class ClientHubsDeleteRequested extends ClientHubsEvent { diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart new file mode 100644 index 00000000..c5b53a91 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -0,0 +1,240 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../blocs/client_hubs_bloc.dart'; +import '../blocs/client_hubs_event.dart'; +import '../blocs/client_hubs_state.dart'; +import '../widgets/hub_address_autocomplete.dart'; + +/// A dedicated full-screen page for editing an existing hub. +/// +/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the +/// updated hub list is reflected on the hubs list page when the user +/// saves and navigates back. +class EditHubPage extends StatefulWidget { + const EditHubPage({ + required this.hub, + required this.bloc, + super.key, + }); + + final Hub hub; + final ClientHubsBloc bloc; + + @override + State createState() => _EditHubPageState(); +} + +class _EditHubPageState extends State { + final GlobalKey _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final FocusNode _addressFocusNode; + Prediction? _selectedPrediction; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.hub.name); + _addressController = TextEditingController(text: widget.hub.address); + _addressFocusNode = FocusNode(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _addressFocusNode.dispose(); + super.dispose(); + } + + void _onSave() { + if (!_formKey.currentState!.validate()) return; + + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show( + context, + message: t.client_hubs.add_hub_dialog.address_hint, + type: UiSnackbarType.error, + ); + return; + } + + context.read().add( + ClientHubsUpdateRequested( + id: widget.hub.id, + name: _nameController.text.trim(), + address: _addressController.text.trim(), + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse(_selectedPrediction?.lat ?? ''), + longitude: double.tryParse(_selectedPrediction?.lng ?? ''), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: BlocListener( + listenWhen: (ClientHubsState prev, ClientHubsState curr) => + prev.status != curr.status || prev.successMessage != curr.successMessage, + listener: (BuildContext context, ClientHubsState state) { + if (state.status == ClientHubsStatus.actionSuccess && + state.successMessage != null) { + UiSnackbar.show( + context, + message: state.successMessage!, + type: UiSnackbarType.success, + ); + // Pop back to details page with updated hub + Navigator.of(context).pop(true); + } + if (state.status == ClientHubsStatus.actionFailure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: state.errorMessage!, + type: UiSnackbarType.error, + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, ClientHubsState state) { + final bool isSaving = + state.status == ClientHubsStatus.actionInProgress; + + return Scaffold( + backgroundColor: UiColors.bgMenu, + appBar: AppBar( + backgroundColor: UiColors.foreground, + leading: IconButton( + icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.edit_hub.title, + style: UiTypography.headline3m.white, + ), + Text( + t.client_hubs.edit_hub.subtitle, + style: UiTypography.footnote1r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Name field ────────────────────────────────── + _FieldLabel(t.client_hubs.edit_hub.name_label), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _inputDecoration( + t.client_hubs.edit_hub.name_hint, + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Address field ──────────────────────────────── + _FieldLabel(t.client_hubs.edit_hub.address_label), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.edit_hub.address_hint, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + + const SizedBox(height: UiConstants.space8), + + // ── Save button ────────────────────────────────── + UiButton.primary( + onPressed: isSaving ? null : _onSave, + text: t.client_hubs.edit_hub.save_button, + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + + // ── Loading overlay ────────────────────────────────────── + if (isSaving) + Container( + color: UiColors.black.withValues(alpha: 0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(text, style: UiTypography.body2m.textPrimary), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart index e3eccc0a..bcb9255b 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -1,12 +1,16 @@ +import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_domain/krow_domain.dart'; -import '../blocs/client_hubs_bloc.dart'; -import '../blocs/client_hubs_event.dart'; -import '../widgets/hub_form_dialog.dart'; +import '../blocs/client_hubs_bloc.dart'; +import 'edit_hub_page.dart'; + +/// A read-only details page for a single [Hub]. +/// +/// Shows hub name, address, and NFC tag assignment. +/// Tapping the edit button navigates to [EditHubPage] (a dedicated page, +/// not a dialog), satisfying the "separate edit hub page" acceptance criterion. class HubDetailsPage extends StatelessWidget { const HubDetailsPage({ required this.hub, @@ -19,50 +23,51 @@ class HubDetailsPage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocProvider.value( - value: bloc, - child: Scaffold( - appBar: AppBar( - title: Text(hub.name), - backgroundColor: UiColors.foreground, - leading: IconButton( - icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), - onPressed: () => Modular.to.pop(), + return Scaffold( + appBar: AppBar( + title: Text(hub.name), + backgroundColor: UiColors.foreground, + leading: IconButton( + icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + TextButton.icon( + onPressed: () => _navigateToEditPage(context), + icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16), + label: Text( + t.client_hubs.hub_details.edit_button, + style: const TextStyle(color: UiColors.white), + ), ), - actions: [ - IconButton( - icon: const Icon(UiIcons.edit, color: UiColors.white), - onPressed: () => _showEditDialog(context), + ], + ), + backgroundColor: UiColors.bgMenu, + body: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailItem( + label: t.client_hubs.hub_details.name_label, + value: hub.name, + icon: UiIcons.home, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: t.client_hubs.hub_details.address_label, + value: hub.address, + icon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: t.client_hubs.hub_details.nfc_label, + value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned, + icon: UiIcons.nfc, + isHighlight: hub.nfcTagId != null, ), ], ), - backgroundColor: UiColors.bgMenu, - body: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildDetailItem( - label: 'Name', - value: hub.name, - icon: UiIcons.home, - ), - const SizedBox(height: UiConstants.space4), - _buildDetailItem( - label: 'Address', - value: hub.address, - icon: UiIcons.mapPin, - ), - const SizedBox(height: UiConstants.space4), - _buildDetailItem( - label: 'NFC Tag', - value: hub.nfcTagId ?? 'Not Assigned', - icon: UiIcons.nfc, - isHighlight: hub.nfcTagId != null, - ), - ], - ), - ), ), ); } @@ -78,7 +83,7 @@ class HubDetailsPage extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: BorderRadius.circular(UiConstants.radiusBase), - boxShadow: const [ + boxShadow: const [ BoxShadow( color: UiColors.popupShadow, blurRadius: 10, @@ -87,11 +92,11 @@ class HubDetailsPage extends StatelessWidget { ], ), child: Row( - children: [ + children: [ Container( padding: const EdgeInsets.all(UiConstants.space3), decoration: BoxDecoration( - color: isHighlight ? UiColors.tagInProgress : UiColors.bgInput, + color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField, borderRadius: BorderRadius.circular(UiConstants.radiusBase), ), child: Icon( @@ -104,16 +109,10 @@ class HubDetailsPage extends StatelessWidget { Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: UiTypography.footnote1r.textSecondary, - ), + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), const SizedBox(height: UiConstants.space1), - Text( - value, - style: UiTypography.body1m.textPrimary, - ), + Text(value, style: UiTypography.body1m.textPrimary), ], ), ), @@ -122,33 +121,17 @@ class HubDetailsPage extends StatelessWidget { ); } - void _showEditDialog(BuildContext context) { - showDialog( - context: context, - barrierDismissible: false, - builder: (_) => HubFormDialog( - hub: hub, - onSave: (name, address, {placeId, latitude, longitude, city, state, street, country, zipCode}) { - bloc.add( - ClientHubsUpdateRequested( - id: hub.id, - name: name, - address: address, - placeId: placeId, - latitude: latitude, - longitude: longitude, - city: city, - state: state, - street: street, - country: country, - zipCode: zipCode, - ), - ); - Navigator.of(context).pop(); // Close dialog - Navigator.of(context).pop(); // Go back to list to refresh - }, - onCancel: () => Navigator.of(context).pop(), + Future _navigateToEditPage(BuildContext context) async { + // Navigate to the dedicated edit page and await result. + // If the page returns `true` (save succeeded), pop the details page too so + // the user sees the refreshed hub list (the BLoC already holds updated data). + final bool? saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditHubPage(hub: hub, bloc: bloc), ), ); + if (saved == true && context.mounted) { + Navigator.of(context).pop(); + } } } 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 392a4300..d2411711 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 @@ -52,7 +52,14 @@ class _NoShowReportPageState extends State { bottom: 32, ), decoration: const BoxDecoration( - color: Color(0xFF1A1A2E), + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), ), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index edf6b8e3..508b5396 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -42,6 +42,7 @@ class ClientSettingsPage extends StatelessWidget { } }, child: const Scaffold( + backgroundColor: UiColors.bgMenu, body: CustomScrollView( slivers: [ SettingsProfileHeader(), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 5f275b01..64543f96 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -14,27 +14,46 @@ class SettingsActions extends StatelessWidget { @override /// Builds the settings actions UI. Widget build(BuildContext context) { - // Get the translations for the client settings profile. final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; + // Yellow button style matching the prototype + final ButtonStyle yellowStyle = ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + ), + ); + return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: UiConstants.space5), - /// TODO: FEATURE_NOT_YET_IMPLEMENTED - // Edit profile is not yet implemented + // Edit Profile button (yellow) + UiButton.primary( + text: labels.edit_profile, + style: yellowStyle, + onPressed: () {}, + ), + const SizedBox(height: UiConstants.space4), - // Hubs button + // Hubs button (yellow) UiButton.primary( text: labels.hubs, + style: yellowStyle, onPressed: () => Modular.to.toClientHubs(), ), const SizedBox(height: UiConstants.space4), - // Log out button + // Quick Links card + _QuickLinksCard(labels: labels), + const SizedBox(height: UiConstants.space4), + + // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { return UiButton.secondary( @@ -45,17 +64,11 @@ class SettingsActions extends StatelessWidget { ); }, ), + const SizedBox(height: UiConstants.space8), ]), ), ); } - - /// Handles the sign-out button click event. - void _onSignoutClicked(BuildContext context) { - ReadContext( - context, - ).read().add(const ClientSettingsSignOutRequested()); - } /// Shows a confirmation dialog for signing out. Future _showSignOutDialog(BuildContext context) { @@ -74,13 +87,10 @@ class SettingsActions extends StatelessWidget { style: UiTypography.body2r.textSecondary, ), actions: [ - // Log out button UiButton.secondary( text: t.client_settings.profile.log_out, onPressed: () => _onSignoutClicked(context), ), - - // Cancel button UiButton.secondary( text: t.common.cancel, onPressed: () => Modular.to.pop(), @@ -89,4 +99,97 @@ class SettingsActions extends StatelessWidget { ), ); } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext(context) + .read() + .add(const ClientSettingsSignOutRequested()); + } +} + +/// Quick Links card — inline here since it's always part of SettingsActions ordering. +class _QuickLinksCard extends StatelessWidget { + final TranslationsClientSettingsProfileEn labels; + + const _QuickLinksCard({required this.labels}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide(color: UiColors.border), + ), + color: UiColors.white, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + labels.quick_links, + style: UiTypography.footnote1b.textPrimary, + ), + const SizedBox(height: UiConstants.space3), + _QuickLinkItem( + icon: UiIcons.nfc, + title: labels.clock_in_hubs, + onTap: () => Modular.to.toClientHubs(), + ), + _QuickLinkItem( + icon: UiIcons.building, + title: labels.billing_payments, + onTap: () => Modular.to.toClientBilling(), + ), + ], + ), + ), + ); + } +} + +/// A single quick link row item. +class _QuickLinkItem extends StatelessWidget { + final IconData icon; + final String title; + final VoidCallback onTap; + + const _QuickLinkItem({ + required this.icon, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: UiConstants.radiusMd, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + horizontal: UiConstants.space2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Text(title, style: UiTypography.footnote1m.textPrimary), + ], + ), + const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconThird, + ), + ], + ), + ), + ); + } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index b9ddd93e..706e1e4b 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -11,7 +11,6 @@ class SettingsProfileHeader extends StatelessWidget { const SettingsProfileHeader({super.key}); @override - /// Builds the profile header UI. Widget build(BuildContext context) { final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; final dc.ClientSession? session = dc.ClientSessionStore.instance.session; @@ -23,78 +22,115 @@ class SettingsProfileHeader extends StatelessWidget { ? businessName.trim()[0].toUpperCase() : 'C'; - return SliverAppBar( - backgroundColor: UiColors.bgSecondary, - expandedHeight: 140, - pinned: true, - elevation: 0, - shape: const Border(bottom: BorderSide(color: UiColors.border, width: 1)), - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.toClientHome(), - ), - flexibleSpace: FlexibleSpaceBar( - background: Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8), - margin: const EdgeInsets.only(top: UiConstants.space24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: UiColors.border, width: 2), - color: UiColors.white, + return SliverToBoxAdapter( + child: Container( + width: double.infinity, + padding: const EdgeInsets.only(bottom: 36), + decoration: const BoxDecoration( + color: UiColors.primary, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ── Top bar: back arrow + title ────────────────── + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, ), - child: CircleAvatar( - backgroundColor: UiColors.primary.withValues(alpha: 0.1), - backgroundImage: - photoUrl != null && photoUrl.isNotEmpty - ? NetworkImage(photoUrl) - : null, - child: - photoUrl != null && photoUrl.isNotEmpty - ? null - : Text( - avatarLetter, - style: UiTypography.headline1m.copyWith( - color: UiColors.primary, - ), - ), + child: Row( + children: [ + GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 22, + ), + ), + const SizedBox(width: UiConstants.space3), + Text( + labels.title, + style: UiTypography.body1b.copyWith( + color: UiColors.white, + ), + ), + ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(businessName, style: UiTypography.body1b.textPrimary), - const SizedBox(height: UiConstants.space1), - Row( - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space1, - children: [ - Icon( - UiIcons.mail, - size: 14, - color: UiColors.textSecondary, - ), - Text( - email, - style: UiTypography.footnote1r.textSecondary, - ), - ], + ), + + const SizedBox(height: UiConstants.space6), + + // ── Avatar ─────────────────────────────────────── + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: UiColors.white, + border: Border.all( + color: UiColors.white.withValues(alpha: 0.6), + width: 3, + ), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, 6), ), ], ), - ], - ), + child: ClipOval( + child: photoUrl != null && photoUrl.isNotEmpty + ? Image.network(photoUrl, fit: BoxFit.cover) + : Center( + child: Text( + avatarLetter, + style: UiTypography.headline1m.copyWith( + color: UiColors.primary, + fontSize: 32, + ), + ), + ), + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Business Name ───────────────────────────────── + Text( + businessName, + style: UiTypography.headline3m.copyWith( + color: UiColors.white, + ), + ), + + const SizedBox(height: UiConstants.space2), + + // ── Email ───────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + UiIcons.mail, + size: 14, + color: UiColors.white.withValues(alpha: 0.75), + ), + const SizedBox(width: 6), + Text( + email, + style: UiTypography.footnote1r.copyWith( + color: UiColors.white.withValues(alpha: 0.75), + ), + ), + ], + ), + ], ), ), - title: Text(labels.title, style: UiTypography.body1b.textPrimary), ); } }