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 00000000..e9cdc382 Binary files /dev/null and b/apps/mobile/packages/features/client/reports/analysis_output.txt differ 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