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..3fdac2c5 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,7 @@ class _SessionListenerState extends State { _sessionExpiredDialogShown = false; debugPrint('[SessionListener] Authenticated: ${state.userId}'); + // 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 0ba97bd4..4e60c7fe 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -239,6 +239,24 @@ "address_hint": "Full address", "create_button": "Create Hub" }, + "edit_hub": { + "title": "Edit Hub", + "subtitle": "Update hub details", + "name_label": "Hub Name *", + "name_hint": "e.g., Main Kitchen, Front Desk", + "address_label": "Address", + "address_hint": "Full address", + "save_button": "Save Changes", + "success": "Hub updated successfully!" + }, + "hub_details": { + "title": "Hub Details", + "name_label": "Name", + "address_label": "Address", + "nfc_label": "NFC Tag", + "nfc_not_assigned": "Not Assigned", + "edit_button": "Edit Hub" + }, "nfc_dialog": { "title": "Identify NFC Tag", "instruction": "Tap your phone to the NFC tag to identify it", @@ -1154,6 +1172,7 @@ "success": { "hub": { "created": "Hub created successfully!", + "updated": "Hub updated successfully!", "deleted": "Hub deleted successfully!", "nfc_assigned": "NFC tag assigned successfully!" }, @@ -1166,5 +1185,213 @@ "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" + } + }, + "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", + "no_shifts_today": "No shifts scheduled for today", + "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", + "no_industry_data": "No industry data available", + "industries": { + "hospitality": "Hospitality", + "events": "Events", + "retail": "Retail" + }, + "percent_total": "$percent% of total", + "placeholders": { + "export_message": "Exporting Spend Report (Placeholder)" + } + }, + "forecast_report": { + "title": "Forecast Report", + "subtitle": "Projected spend & staffing", + "metrics": { + "projected_spend": "Projected Spend", + "workers_needed": "Workers Needed" + }, + "chart_title": "Spending Forecast", + "daily_projections": "DAILY PROJECTIONS", + "empty_state": "No projections available", + "shift_item": { + "workers_needed": "$count workers needed" + }, + "placeholders": { + "export_message": "Exporting Forecast Report (Placeholder)" + } + }, + "performance_report": { + "title": "Performance Report", + "subtitle": "Key metrics & benchmarks", + "overall_score": { + "title": "Overall Performance Score", + "excellent": "Excellent", + "good": "Good", + "needs_work": "Needs Work" + }, + "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: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", + "met": "✓ Met", + "close": "→ Close", + "miss": "✗ Miss" + }, + "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" + }, + "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" + }, + "empty_state": "No workers flagged for no-shows", + "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", + "empty_state": "No shifts scheduled", + "shift_item": { + "confirmed_workers": "$confirmed/$needed workers confirmed", + "spots_remaining": "$count spots remaining", + "one_spot_remaining": "1 spot remaining", + "fully_staffed": "Fully staffed" + }, + "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 6ce171fc..18ec6f7c 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -253,6 +253,24 @@ "dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.", "cancel": "Cancelar", "delete": "Eliminar" + }, + "edit_hub": { + "title": "Editar Hub", + "subtitle": "Actualizar detalles del hub", + "name_label": "Nombre del Hub", + "name_hint": "Ingresar nombre del hub", + "address_label": "Dirección", + "address_hint": "Ingresar dirección", + "save_button": "Guardar Cambios", + "success": "¡Hub actualizado exitosamente!" + }, + "hub_details": { + "title": "Detalles del Hub", + "edit_button": "Editar", + "name_label": "Nombre del Hub", + "address_label": "Dirección", + "nfc_label": "Etiqueta NFC", + "nfc_not_assigned": "No asignada" } }, "client_create_order": { @@ -1154,6 +1172,7 @@ "success": { "hub": { "created": "¡Hub creado exitosamente!", + "updated": "¡Hub actualizado exitosamente!", "deleted": "¡Hub eliminado exitosamente!", "nfc_assigned": "¡Etiqueta NFC asignada exitosamente!" }, @@ -1166,5 +1185,213 @@ "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" + } + }, + "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", + "no_shifts_today": "No hay turnos programados para hoy", + "shift_item": { + "time": "Hora", + "workers": "Trabajadores", + "rate": "Tarifa" + }, + "statuses": { + "processing": "Procesando", + "filling": "Llenando", + "confirmed": "Confirmado", + "completed": "Completado" + }, + "placeholders": { + "export_message": "Exportando Informe de Ops 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", + "no_industry_data": "No hay datos de la industria disponibles", + "placeholders": { + "export_message": "Exportando Informe de Gastos (Marcador de posición)" + } + }, + "forecast_report": { + "title": "Informe de Previsión", + "subtitle": "Gastos y personal proyectados", + "metrics": { + "projected_spend": "Gasto Proyectado", + "workers_needed": "Trabajadores Necesarios" + }, + "chart_title": "Previsión de Gastos", + "daily_projections": "PROYECCIONES DIARIAS", + "empty_state": "No hay proyecciones disponibles", + "shift_item": { + "workers_needed": "$count trabajadores necesarios" + }, + "placeholders": { + "export_message": "Exportando Informe de Previsión (Marcador de posición)" + } + }, + "performance_report": { + "title": "Informe de Rendimiento", + "subtitle": "Métricas clave y comparativas", + "overall_score": { + "title": "Puntuación de Rendimiento General", + "excellent": "Excelente", + "good": "Bueno", + "needs_work": "Necesita Mejorar" + }, + "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: ", + "target_hours": "$hours hrs", + "target_percent": "$percent%", + "met": "✓ Cumplido", + "close": "→ Cerca", + "miss": "✗ Fallido" + }, + "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" + }, + "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" + }, + "empty_state": "No hay trabajadores señalados por faltas", + "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", + "empty_state": "No hay turnos programados", + "shift_item": { + "confirmed_workers": "$confirmed/$needed trabajadores confirmados", + "spots_remaining": "$count puestos restantes", + "one_spot_remaining": "1 puesto restante", + "fully_staffed": "Totalmente cubierto" + }, + "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 cf446f0a..55a7841b 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/domain/lib/src/entities/orders/permanent_order.dart b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart index f7712bc4..c9b85fff 100644 --- a/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart +++ b/apps/mobile/packages/domain/lib/src/entities/orders/permanent_order.dart @@ -1,12 +1,12 @@ import 'package:equatable/equatable.dart'; -import 'permanent_order_position.dart'; +import 'one_time_order.dart'; +import 'one_time_order_position.dart'; -/// Represents a permanent staffing request spanning a date range. +/// Represents a customer's request for permanent/ongoing staffing. class PermanentOrder extends Equatable { const PermanentOrder({ required this.startDate, required this.permanentDays, - required this.location, required this.positions, this.hub, this.eventName, @@ -14,35 +14,21 @@ class PermanentOrder extends Equatable { this.roleRates = const {}, }); - /// Start date for the permanent schedule. final DateTime startDate; - - /// Days of the week to repeat on (e.g., ["SUN", "MON", ...]). + + /// List of days (e.g., ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']) final List permanentDays; - - /// The primary location where the work will take place. - final String location; - - /// The list of positions and headcounts required for this order. - final List positions; - - /// Selected hub details for this order. - final PermanentOrderHubDetails? hub; - - /// Optional order name. + + final List positions; + final OneTimeOrderHubDetails? hub; final String? eventName; - - /// Selected vendor id for this order. final String? vendorId; - - /// Role hourly rates keyed by role id. final Map roleRates; @override - List get props => [ + List get props => [ startDate, permanentDays, - location, positions, hub, eventName, @@ -50,47 +36,3 @@ class PermanentOrder extends Equatable { roleRates, ]; } - -/// Minimal hub details used during permanent order creation. -class PermanentOrderHubDetails extends Equatable { - const PermanentOrderHubDetails({ - required this.id, - required this.name, - required this.address, - this.placeId, - this.latitude, - this.longitude, - this.city, - this.state, - this.street, - this.country, - this.zipCode, - }); - - final String id; - final String name; - final String address; - final String? placeId; - final double? latitude; - final double? longitude; - final String? city; - final String? state; - final String? street; - final String? country; - final String? zipCode; - - @override - List get props => [ - id, - name, - address, - placeId, - latitude, - longitude, - city, - state, - street, - country, - zipCode, - ]; -} diff --git a/apps/mobile/packages/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/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart index 757aff1f..fff9a19c 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/data/repositories_impl/client_create_order_repository_impl.dart @@ -274,7 +274,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte if (vendorId == null || vendorId.isEmpty) { throw Exception('Vendor is missing.'); } - final domain.PermanentOrderHubDetails? hub = order.hub; + final domain.OneTimeOrderHubDetails? hub = order.hub; if (hub == null || hub.id.isEmpty) { throw Exception('Hub is missing.'); } @@ -311,7 +311,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte final Set selectedDays = Set.from(order.permanentDays); final int workersNeeded = order.positions.fold( 0, - (int sum, domain.PermanentOrderPosition position) => sum + position.count, + (int sum, domain.OneTimeOrderPosition position) => sum + position.count, ); final double shiftCost = _calculatePermanentShiftCost(order); @@ -352,7 +352,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte final String shiftId = shiftResult.data.shift_insert.id; shiftIds.add(shiftId); - for (final domain.PermanentOrderPosition position in order.positions) { + for (final domain.OneTimeOrderPosition position in order.positions) { final DateTime start = _parseTime(day, position.startTime); final DateTime end = _parseTime(day, position.endTime); final DateTime normalizedEnd = @@ -420,7 +420,7 @@ class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInte double _calculatePermanentShiftCost(domain.PermanentOrder order) { double total = 0; - for (final domain.PermanentOrderPosition position in order.positions) { + for (final domain.OneTimeOrderPosition position in order.positions) { final DateTime start = _parseTime(order.startDate, position.startTime); final DateTime end = _parseTime(order.startDate, position.endTime); final DateTime normalizedEnd = diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart index b3afda92..b79b3359 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_permanent_order_usecase.dart @@ -1,16 +1,15 @@ import 'package:krow_core/core.dart'; -import '../arguments/permanent_order_arguments.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a permanent staffing order. -class CreatePermanentOrderUseCase - implements UseCase { - /// Creates a [CreatePermanentOrderUseCase]. +class CreatePermanentOrderUseCase implements UseCase { const CreatePermanentOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override - Future call(PermanentOrderArguments input) { - return _repository.createPermanentOrder(input.order); + Future call(PermanentOrder params) { + return _repository.createPermanentOrder(params); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart index f24c5841..561a5ef8 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/create_recurring_order_usecase.dart @@ -1,16 +1,15 @@ import 'package:krow_core/core.dart'; -import '../arguments/recurring_order_arguments.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../repositories/client_create_order_repository_interface.dart'; /// Use case for creating a recurring staffing order. -class CreateRecurringOrderUseCase - implements UseCase { - /// Creates a [CreateRecurringOrderUseCase]. +class CreateRecurringOrderUseCase implements UseCase { const CreateRecurringOrderUseCase(this._repository); + final ClientCreateOrderRepositoryInterface _repository; @override - Future call(RecurringOrderArguments input) { - return _repository.createRecurringOrder(input.order); + Future call(RecurringOrder params) { + return _repository.createRecurringOrder(params); } } diff --git a/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart new file mode 100644 index 00000000..296816cf --- /dev/null +++ b/apps/mobile/packages/features/client/create_order/lib/src/domain/usecases/reorder_usecase.dart @@ -0,0 +1,25 @@ +import 'package:krow_core/core.dart'; +import '../repositories/client_create_order_repository_interface.dart'; + +/// Arguments for the ReorderUseCase. +class ReorderArguments { + const ReorderArguments({ + required this.previousOrderId, + required this.newDate, + }); + + final String previousOrderId; + final DateTime newDate; +} + +/// Use case for reordering an existing staffing order. +class ReorderUseCase implements UseCase, ReorderArguments> { + const ReorderUseCase(this._repository); + + final ClientCreateOrderRepositoryInterface _repository; + + @override + Future call(ReorderArguments params) { + return _repository.reorder(params.previousOrderId, params.newDate); + } +} diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart index 731a8018..48a75b27 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/permanent_order_bloc.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; -import '../../domain/arguments/permanent_order_arguments.dart'; import '../../domain/usecases/create_permanent_order_usecase.dart'; import 'permanent_order_event.dart'; import 'permanent_order_state.dart'; @@ -286,10 +285,9 @@ class PermanentOrderBloc extends Bloc final domain.PermanentOrder order = domain.PermanentOrder( startDate: state.startDate, permanentDays: state.permanentDays, - location: selectedHub.name, positions: state.positions .map( - (PermanentOrderPosition p) => domain.PermanentOrderPosition( + (PermanentOrderPosition p) => domain.OneTimeOrderPosition( role: p.role, count: p.count, startTime: p.startTime, @@ -299,7 +297,7 @@ class PermanentOrderBloc extends Bloc ), ) .toList(), - hub: domain.PermanentOrderHubDetails( + hub: domain.OneTimeOrderHubDetails( id: selectedHub.id, name: selectedHub.name, address: selectedHub.address, @@ -316,9 +314,7 @@ class PermanentOrderBloc extends Bloc vendorId: state.selectedVendor?.id, roleRates: roleRates, ); - await _createPermanentOrderUseCase( - PermanentOrderArguments(order: order), - ); + await _createPermanentOrderUseCase(order); emit(state.copyWith(status: PermanentOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart index b94ed6c1..fc975068 100644 --- a/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart +++ b/apps/mobile/packages/features/client/create_order/lib/src/presentation/blocs/recurring_order_bloc.dart @@ -3,7 +3,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_domain/krow_domain.dart' as domain; -import '../../domain/arguments/recurring_order_arguments.dart'; import '../../domain/usecases/create_recurring_order_usecase.dart'; import 'recurring_order_event.dart'; import 'recurring_order_state.dart'; @@ -334,9 +333,7 @@ class RecurringOrderBloc extends Bloc vendorId: state.selectedVendor?.id, roleRates: roleRates, ); - await _createRecurringOrderUseCase( - RecurringOrderArguments(order: order), - ); + await _createRecurringOrderUseCase(order); emit(state.copyWith(status: RecurringOrderStatus.success)); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart index 9c2931d7..bcfe0d31 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/client_home_edit_banner.dart @@ -26,7 +26,7 @@ class ClientHomeEditBanner extends StatelessWidget { builder: (BuildContext context, ClientHomeState state) { return AnimatedContainer( duration: const Duration(milliseconds: 300), - height: state.isEditMode ? 76 : 0, + height: state.isEditMode ? 80 : 0, clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric( horizontal: UiConstants.space4, @@ -43,21 +43,23 @@ class ClientHomeEditBanner extends StatelessWidget { children: [ const Icon(UiIcons.edit, size: 16, color: UiColors.primary), const SizedBox(width: UiConstants.space2), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - i18n.dashboard.edit_mode_active, - style: UiTypography.footnote1b.copyWith( - color: UiColors.primary, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + i18n.dashboard.edit_mode_active, + style: UiTypography.footnote1b.copyWith( + color: UiColors.primary, + ), ), - ), - Text( - i18n.dashboard.drag_instruction, - style: UiTypography.footnote2r.textSecondary, - ), - ], + Text( + i18n.dashboard.drag_instruction, + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), ), UiButton.secondary( text: i18n.dashboard.reset, diff --git a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart index 1f7c0eb9..e3dd08f4 100644 --- a/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart +++ b/apps/mobile/packages/features/client/hubs/lib/client_hubs.dart @@ -9,6 +9,7 @@ import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/create_hub_usecase.dart'; import 'src/domain/usecases/delete_hub_usecase.dart'; import 'src/domain/usecases/get_hubs_usecase.dart'; +import 'src/domain/usecases/update_hub_usecase.dart'; import 'src/presentation/blocs/client_hubs_bloc.dart'; import 'src/presentation/pages/client_hubs_page.dart'; @@ -29,6 +30,7 @@ class ClientHubsModule extends Module { i.addLazySingleton(CreateHubUseCase.new); i.addLazySingleton(DeleteHubUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new); + i.addLazySingleton(UpdateHubUseCase.new); // BLoCs i.add(ClientHubsBloc.new); diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index 91de3bdf..c79d15cd 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -124,6 +124,78 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } + @override + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + }) async { + return _service.run(() async { + final _PlaceAddress? placeAddress = + placeId == null || placeId.isEmpty + ? null + : await _fetchPlaceAddress(placeId); + + final dc.UpdateTeamHubVariablesBuilder builder = _service.connector + .updateTeamHub(id: id); + + if (name != null) builder.hubName(name); + if (address != null) builder.address(address); + if (placeId != null || placeAddress != null) { + builder.placeId(placeId ?? placeAddress?.street); + } + if (latitude != null) builder.latitude(latitude); + if (longitude != null) builder.longitude(longitude); + if (city != null || placeAddress?.city != null) { + builder.city(city ?? placeAddress?.city); + } + if (state != null || placeAddress?.state != null) { + builder.state(state ?? placeAddress?.state); + } + if (street != null || placeAddress?.street != null) { + builder.street(street ?? placeAddress?.street); + } + if (country != null || placeAddress?.country != null) { + builder.country(country ?? placeAddress?.country); + } + if (zipCode != null || placeAddress?.zipCode != null) { + builder.zipCode(zipCode ?? placeAddress?.zipCode); + } + + await builder.execute(); + + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); + final String teamId = await _getOrCreateTeamId(business); + final List hubs = await _fetchHubsForTeam( + teamId: teamId, + businessId: business.id, + ); + + for (final domain.Hub hub in hubs) { + if (hub.id == id) return hub; + } + + // Fallback: return a reconstructed Hub from the update inputs. + return domain.Hub( + id: id, + businessId: business.id, + name: name ?? '', + address: address ?? '', + nfcTagId: null, + status: domain.HubStatus.active, + ); + }); + } + Future _getBusinessForCurrentUser() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart index 5580e6e4..0288d180 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/repositories/hub_repository_interface.dart @@ -35,4 +35,21 @@ abstract interface class HubRepositoryInterface { /// /// Takes the [hubId] and the [nfcTagId] to be associated. Future assignNfcTag({required String hubId, required String nfcTagId}); + + /// Updates an existing hub by its [id]. + /// + /// All fields other than [id] are optional — only supplied values are updated. + Future updateHub({ + required String id, + String? name, + String? address, + String? placeId, + double? latitude, + double? longitude, + String? city, + String? state, + String? street, + String? country, + String? zipCode, + }); } diff --git a/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart new file mode 100644 index 00000000..97af203e --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/domain/usecases/update_hub_usecase.dart @@ -0,0 +1,72 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../repositories/hub_repository_interface.dart'; + +/// Arguments for the UpdateHubUseCase. +class UpdateHubArguments extends UseCaseArgument { + const UpdateHubArguments({ + required this.id, + this.name, + this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String? name; + final String? address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + +/// Use case for updating an existing hub. +class UpdateHubUseCase implements UseCase { + UpdateHubUseCase(this.repository); + + final HubRepositoryInterface repository; + + @override + Future call(UpdateHubArguments params) { + return repository.updateHub( + id: params.id, + name: params.name, + address: params.address, + placeId: params.placeId, + latitude: params.latitude, + longitude: params.longitude, + city: params.city, + state: params.state, + street: params.street, + country: params.country, + zipCode: params.zipCode, + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart index 2c2acb02..5096ed70 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_bloc.dart @@ -9,6 +9,7 @@ import '../../domain/usecases/assign_nfc_tag_usecase.dart'; import '../../domain/usecases/create_hub_usecase.dart'; import '../../domain/usecases/delete_hub_usecase.dart'; import '../../domain/usecases/get_hubs_usecase.dart'; +import '../../domain/usecases/update_hub_usecase.dart'; import 'client_hubs_event.dart'; import 'client_hubs_state.dart'; @@ -25,13 +26,16 @@ class ClientHubsBloc extends Bloc required CreateHubUseCase createHubUseCase, required DeleteHubUseCase deleteHubUseCase, required AssignNfcTagUseCase assignNfcTagUseCase, + required UpdateHubUseCase updateHubUseCase, }) : _getHubsUseCase = getHubsUseCase, _createHubUseCase = createHubUseCase, _deleteHubUseCase = deleteHubUseCase, _assignNfcTagUseCase = assignNfcTagUseCase, + _updateHubUseCase = updateHubUseCase, super(const ClientHubsState()) { on(_onFetched); on(_onAddRequested); + on(_onUpdateRequested); on(_onDeleteRequested); on(_onNfcTagAssignRequested); on(_onMessageCleared); @@ -42,6 +46,7 @@ class ClientHubsBloc extends Bloc final CreateHubUseCase _createHubUseCase; final DeleteHubUseCase _deleteHubUseCase; final AssignNfcTagUseCase _assignNfcTagUseCase; + final UpdateHubUseCase _updateHubUseCase; void _onAddDialogToggled( ClientHubsAddDialogToggled event, @@ -120,6 +125,46 @@ class ClientHubsBloc extends Bloc ); } + Future _onUpdateRequested( + ClientHubsUpdateRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: ClientHubsStatus.actionInProgress)); + + await handleError( + emit: emit, + action: () async { + await _updateHubUseCase( + UpdateHubArguments( + id: event.id, + name: event.name, + address: event.address, + placeId: event.placeId, + latitude: event.latitude, + longitude: event.longitude, + city: event.city, + state: event.state, + street: event.street, + country: event.country, + zipCode: event.zipCode, + ), + ); + final List hubs = await _getHubsUseCase(); + emit( + state.copyWith( + status: ClientHubsStatus.actionSuccess, + hubs: hubs, + successMessage: 'Hub updated successfully!', + ), + ); + }, + onError: (String errorKey) => state.copyWith( + status: ClientHubsStatus.actionFailure, + errorMessage: errorKey, + ), + ); + } + Future _onDeleteRequested( ClientHubsDeleteRequested event, Emitter emit, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart index 9e539c8e..03fd5194 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/blocs/client_hubs_event.dart @@ -55,6 +55,50 @@ class ClientHubsAddRequested extends ClientHubsEvent { ]; } +/// Event triggered to update an existing hub. +class ClientHubsUpdateRequested extends ClientHubsEvent { + const ClientHubsUpdateRequested({ + required this.id, + required this.name, + required this.address, + this.placeId, + this.latitude, + this.longitude, + this.city, + this.state, + this.street, + this.country, + this.zipCode, + }); + + final String id; + final String name; + final String address; + final String? placeId; + final double? latitude; + final double? longitude; + final String? city; + final String? state; + final String? street; + final String? country; + final String? zipCode; + + @override + List get props => [ + id, + name, + address, + placeId, + latitude, + longitude, + city, + state, + street, + country, + zipCode, + ]; +} + /// Event triggered to delete a hub. class ClientHubsDeleteRequested extends ClientHubsEvent { diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart new file mode 100644 index 00000000..c5b53a91 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/edit_hub_page.dart @@ -0,0 +1,240 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../blocs/client_hubs_bloc.dart'; +import '../blocs/client_hubs_event.dart'; +import '../blocs/client_hubs_state.dart'; +import '../widgets/hub_address_autocomplete.dart'; + +/// A dedicated full-screen page for editing an existing hub. +/// +/// Takes the parent [ClientHubsBloc] via [BlocProvider.value] so the +/// updated hub list is reflected on the hubs list page when the user +/// saves and navigates back. +class EditHubPage extends StatefulWidget { + const EditHubPage({ + required this.hub, + required this.bloc, + super.key, + }); + + final Hub hub; + final ClientHubsBloc bloc; + + @override + State createState() => _EditHubPageState(); +} + +class _EditHubPageState extends State { + final GlobalKey _formKey = GlobalKey(); + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final FocusNode _addressFocusNode; + Prediction? _selectedPrediction; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.hub.name); + _addressController = TextEditingController(text: widget.hub.address); + _addressFocusNode = FocusNode(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _addressFocusNode.dispose(); + super.dispose(); + } + + void _onSave() { + if (!_formKey.currentState!.validate()) return; + + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show( + context, + message: t.client_hubs.add_hub_dialog.address_hint, + type: UiSnackbarType.error, + ); + return; + } + + context.read().add( + ClientHubsUpdateRequested( + id: widget.hub.id, + name: _nameController.text.trim(), + address: _addressController.text.trim(), + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse(_selectedPrediction?.lat ?? ''), + longitude: double.tryParse(_selectedPrediction?.lng ?? ''), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: widget.bloc, + child: BlocListener( + listenWhen: (ClientHubsState prev, ClientHubsState curr) => + prev.status != curr.status || prev.successMessage != curr.successMessage, + listener: (BuildContext context, ClientHubsState state) { + if (state.status == ClientHubsStatus.actionSuccess && + state.successMessage != null) { + UiSnackbar.show( + context, + message: state.successMessage!, + type: UiSnackbarType.success, + ); + // Pop back to details page with updated hub + Navigator.of(context).pop(true); + } + if (state.status == ClientHubsStatus.actionFailure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: state.errorMessage!, + type: UiSnackbarType.error, + ); + } + }, + child: BlocBuilder( + builder: (BuildContext context, ClientHubsState state) { + final bool isSaving = + state.status == ClientHubsStatus.actionInProgress; + + return Scaffold( + backgroundColor: UiColors.bgMenu, + appBar: AppBar( + backgroundColor: UiColors.foreground, + leading: IconButton( + icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), + onPressed: () => Navigator.of(context).pop(), + ), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + t.client_hubs.edit_hub.title, + style: UiTypography.headline3m.white, + ), + Text( + t.client_hubs.edit_hub.subtitle, + style: UiTypography.footnote1r.copyWith( + color: UiColors.white.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + body: Stack( + children: [ + SingleChildScrollView( + padding: const EdgeInsets.all(UiConstants.space5), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // ── Name field ────────────────────────────────── + _FieldLabel(t.client_hubs.edit_hub.name_label), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + textInputAction: TextInputAction.next, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _inputDecoration( + t.client_hubs.edit_hub.name_hint, + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Address field ──────────────────────────────── + _FieldLabel(t.client_hubs.edit_hub.address_label), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.edit_hub.address_hint, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + + const SizedBox(height: UiConstants.space8), + + // ── Save button ────────────────────────────────── + UiButton.primary( + onPressed: isSaving ? null : _onSave, + text: t.client_hubs.edit_hub.save_button, + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + + // ── Loading overlay ────────────────────────────────────── + if (isSaving) + Container( + color: UiColors.black.withValues(alpha: 0.1), + child: const Center(child: CircularProgressIndicator()), + ), + ], + ), + ); + }, + ), + ), + ); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } +} + +class _FieldLabel extends StatelessWidget { + const _FieldLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(text, style: UiTypography.body2m.textPrimary), + ); + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart new file mode 100644 index 00000000..bcb9255b --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/pages/hub_details_page.dart @@ -0,0 +1,137 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../blocs/client_hubs_bloc.dart'; +import 'edit_hub_page.dart'; + +/// A read-only details page for a single [Hub]. +/// +/// Shows hub name, address, and NFC tag assignment. +/// Tapping the edit button navigates to [EditHubPage] (a dedicated page, +/// not a dialog), satisfying the "separate edit hub page" acceptance criterion. +class HubDetailsPage extends StatelessWidget { + const HubDetailsPage({ + required this.hub, + required this.bloc, + super.key, + }); + + final Hub hub; + final ClientHubsBloc bloc; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(hub.name), + backgroundColor: UiColors.foreground, + leading: IconButton( + icon: const Icon(UiIcons.arrowLeft, color: UiColors.white), + onPressed: () => Navigator.of(context).pop(), + ), + actions: [ + TextButton.icon( + onPressed: () => _navigateToEditPage(context), + icon: const Icon(UiIcons.edit, color: UiColors.white, size: 16), + label: Text( + t.client_hubs.hub_details.edit_button, + style: const TextStyle(color: UiColors.white), + ), + ), + ], + ), + backgroundColor: UiColors.bgMenu, + body: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailItem( + label: t.client_hubs.hub_details.name_label, + value: hub.name, + icon: UiIcons.home, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: t.client_hubs.hub_details.address_label, + value: hub.address, + icon: UiIcons.mapPin, + ), + const SizedBox(height: UiConstants.space4), + _buildDetailItem( + label: t.client_hubs.hub_details.nfc_label, + value: hub.nfcTagId ?? t.client_hubs.hub_details.nfc_not_assigned, + icon: UiIcons.nfc, + isHighlight: hub.nfcTagId != null, + ), + ], + ), + ), + ); + } + + Widget _buildDetailItem({ + required String label, + required String value, + required IconData icon, + bool isHighlight = false, + }) { + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow( + color: UiColors.popupShadow, + blurRadius: 10, + offset: Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: isHighlight ? UiColors.tagInProgress : UiColors.bgInputField, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Icon( + icon, + color: isHighlight ? UiColors.iconSuccess : UiColors.iconPrimary, + size: 20, + ), + ), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: UiTypography.footnote1r.textSecondary), + const SizedBox(height: UiConstants.space1), + Text(value, style: UiTypography.body1m.textPrimary), + ], + ), + ), + ], + ), + ); + } + + Future _navigateToEditPage(BuildContext context) async { + // Navigate to the dedicated edit page and await result. + // If the page returns `true` (save succeeded), pop the details page too so + // the user sees the refreshed hub list (the BLoC already holds updated data). + final bool? saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => EditHubPage(hub: hub, bloc: bloc), + ), + ); + if (saved == true && context.mounted) { + Navigator.of(context).pop(); + } + } +} diff --git a/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart new file mode 100644 index 00000000..7a4d0cd7 --- /dev/null +++ b/apps/mobile/packages/features/client/hubs/lib/src/presentation/widgets/hub_form_dialog.dart @@ -0,0 +1,200 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:core_localization/core_localization.dart'; +import 'package:google_places_flutter/model/prediction.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'hub_address_autocomplete.dart'; + +/// A dialog for adding or editing a hub. +class HubFormDialog extends StatefulWidget { + + /// Creates a [HubFormDialog]. + const HubFormDialog({ + required this.onSave, + required this.onCancel, + this.hub, + super.key, + }); + + /// The hub to edit. If null, a new hub is created. + final Hub? hub; + + /// Callback when the "Save" button is pressed. + final void Function( + String name, + String address, { + String? placeId, + double? latitude, + double? longitude, + }) onSave; + + /// Callback when the dialog is cancelled. + final VoidCallback onCancel; + + @override + State createState() => _HubFormDialogState(); +} + +class _HubFormDialogState extends State { + late final TextEditingController _nameController; + late final TextEditingController _addressController; + late final FocusNode _addressFocusNode; + Prediction? _selectedPrediction; + + @override + void initState() { + super.initState(); + _nameController = TextEditingController(text: widget.hub?.name); + _addressController = TextEditingController(text: widget.hub?.address); + _addressFocusNode = FocusNode(); + } + + @override + void dispose() { + _nameController.dispose(); + _addressController.dispose(); + _addressFocusNode.dispose(); + super.dispose(); + } + + final GlobalKey _formKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + final bool isEditing = widget.hub != null; + final String title = isEditing + ? 'Edit Hub' // TODO: localize + : t.client_hubs.add_hub_dialog.title; + + final String buttonText = isEditing + ? 'Save Changes' // TODO: localize + : t.client_hubs.add_hub_dialog.create_button; + + return Container( + color: UiColors.bgOverlay, + child: Center( + child: SingleChildScrollView( + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + padding: const EdgeInsets.all(UiConstants.space5), + decoration: BoxDecoration( + color: UiColors.bgPopup, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + boxShadow: const [ + BoxShadow(color: UiColors.popupShadow, blurRadius: 20), + ], + ), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: UiTypography.headline3m.textPrimary, + ), + const SizedBox(height: UiConstants.space5), + _buildFieldLabel(t.client_hubs.add_hub_dialog.name_label), + TextFormField( + controller: _nameController, + style: UiTypography.body1r.textPrimary, + validator: (String? value) { + if (value == null || value.trim().isEmpty) { + return 'Name is required'; + } + return null; + }, + decoration: _buildInputDecoration( + t.client_hubs.add_hub_dialog.name_hint, + ), + ), + const SizedBox(height: UiConstants.space4), + _buildFieldLabel(t.client_hubs.add_hub_dialog.address_label), + HubAddressAutocomplete( + controller: _addressController, + hintText: t.client_hubs.add_hub_dialog.address_hint, + focusNode: _addressFocusNode, + onSelected: (Prediction prediction) { + _selectedPrediction = prediction; + }, + ), + const SizedBox(height: UiConstants.space8), + Row( + children: [ + Expanded( + child: UiButton.secondary( + onPressed: widget.onCancel, + text: t.common.cancel, + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: UiButton.primary( + onPressed: () { + if (_formKey.currentState!.validate()) { + if (_addressController.text.trim().isEmpty) { + UiSnackbar.show(context, message: 'Address is required', type: UiSnackbarType.error); + return; + } + + widget.onSave( + _nameController.text, + _addressController.text, + placeId: _selectedPrediction?.placeId, + latitude: double.tryParse( + _selectedPrediction?.lat ?? '', + ), + longitude: double.tryParse( + _selectedPrediction?.lng ?? '', + ), + ); + } + }, + text: buttonText, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildFieldLabel(String label) { + return Padding( + padding: const EdgeInsets.only(bottom: UiConstants.space2), + child: Text(label, style: UiTypography.body2m.textPrimary), + ); + } + + InputDecoration _buildInputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: UiTypography.body2r.textPlaceholder, + filled: true, + fillColor: UiColors.input, + contentPadding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: 14, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.border), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + borderSide: const BorderSide(color: UiColors.ring, width: 2), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/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..d395f8b8 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/data/repositories_impl/reports_repository_impl.dart @@ -0,0 +1,493 @@ +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 = {}; + final Map industryAggregates = {}; + + 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 industry = inv.vendor?.serviceSpecialty ?? 'Other'; + industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount; + + 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', + industry: industry, + )); + + // Chart data aggregation + final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day); + dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount; + } + + // Ensure chart data covers all days in range + final Map completeDailyAggregates = {}; + for (int i = 0; i <= endDate.difference(startDate).inDays; i++) { + final date = startDate.add(Duration(days: i)); + final normalizedDate = DateTime(date.year, date.month, date.day); + completeDailyAggregates[normalizedDate] = + dailyAggregates[normalizedDate] ?? 0.0; + } + + final List chartData = completeDailyAggregates.entries + .map((e) => SpendChartPoint(date: e.key, amount: e.value)) + .toList() + ..sort((a, b) => a.date.compareTo(b.date)); + + final List industryBreakdown = industryAggregates.entries + .map((e) => SpendIndustryCategory( + name: e.key, + amount: e.value, + percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0, + )) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final daysCount = endDate.difference(startDate).inDays + 1; + + return SpendReport( + totalSpend: totalSpend, + averageCost: daysCount > 0 ? totalSpend / daysCount : 0, + paidInvoices: paidInvoices, + pendingInvoices: pendingInvoices, + overdueInvoices: overdueInvoices, + invoices: spendInvoices, + chartData: chartData, + industryBreakdown: industryBreakdown, + ); + }); + } + + @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..fabf262d --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/daily_ops_report.dart @@ -0,0 +1,63 @@ +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; + final double? hourlyRate; + + 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, + this.hourlyRate, + }); + + @override + List get props => [ + id, + title, + location, + startTime, + endTime, + workersNeeded, + filled, + status, + hourlyRate, + ]; +} 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..3e342c00 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/domain/entities/spend_report.dart @@ -0,0 +1,85 @@ +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, + required this.industryBreakdown, + }); + + final List industryBreakdown; + + @override + List get props => [ + totalSpend, + averageCost, + paidInvoices, + pendingInvoices, + overdueInvoices, + invoices, + chartData, + industryBreakdown, + ]; +} + +class SpendIndustryCategory extends Equatable { + final String name; + final double amount; + final double percentage; + + const SpendIndustryCategory({ + required this.name, + required this.amount, + required this.percentage, + }); + + @override + List get props => [name, amount, percentage]; +} + +class SpendInvoice extends Equatable { + final String id; + final String invoiceNumber; + 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, + this.industry, + }); + + final String? industry; + + @override + List get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry]; +} + +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..24a0bef4 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/coverage_report_page.dart @@ -0,0 +1,464 @@ +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; + + // Compute "Full" and "Needs Help" counts from daily coverage + final fullDays = report.dailyCoverage + .where((d) => d.percentage >= 100) + .length; + final needsHelpDays = report.dailyCoverage + .where((d) => d.percentage < 80) + .length; + + return SingleChildScrollView( + child: Column( + children: [ + // ── Header ─────────────────────────────────────────── + Container( + padding: const EdgeInsets.only( + top: 60, + left: 20, + right: 20, + bottom: 80, // Increased bottom padding for overlap background + ), + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + UiColors.primary, + UiColors.buttonPrimaryHover, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: Column( + children: [ + // Title row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.coverage_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.coverage_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: + UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + // Export button +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + context.t.client_reports.coverage_report + .placeholders.export_message, + ), + duration: const Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(UiIcons.download, + size: 14, color: UiColors.primary), + SizedBox(width: 6), + Text( + 'Export', + style: TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ], + ), + ), + + // ── 3 summary stat chips (Moved here for overlap) ── + Transform.translate( + offset: const Offset(0, -60), // Pull up to overlap header + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + _CoverageStatCard( + icon: UiIcons.trendingUp, + label: context.t.client_reports.coverage_report.metrics.avg_coverage, + value: '${report.overallCoverage.toStringAsFixed(0)}%', + iconColor: UiColors.primary, + ), + const SizedBox(width: 12), + _CoverageStatCard( + icon: UiIcons.checkCircle, + label: context.t.client_reports.coverage_report.metrics.full, + value: fullDays.toString(), + iconColor: UiColors.success, + ), + const SizedBox(width: 12), + _CoverageStatCard( + icon: UiIcons.warning, + label: context.t.client_reports.coverage_report.metrics.needs_help, + value: needsHelpDays.toString(), + iconColor: UiColors.error, + ), + ], + ), + ), + ), + + // ── Content ────────────────────────────────────────── + Transform.translate( + offset: const Offset(0, -60), // Pull up to overlap header + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 32), + + // Section label + Text( + context.t.client_reports.coverage_report.next_7_days, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 16), + + if (report.dailyCoverage.isEmpty) + Container( + padding: const EdgeInsets.all(40), + alignment: Alignment.center, + child: Text( + context.t.client_reports.coverage_report.empty_state, + style: const TextStyle( + color: UiColors.textSecondary, + ), + ), + ) + else + ...report.dailyCoverage.map( + (day) => _DayCoverageCard( + date: DateFormat('EEE, MMM d').format(day.date), + filled: day.filled, + needed: day.needed, + percentage: day.percentage, + ), + ), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +// ── Header stat chip (inside the blue header) ───────────────────────────────── +// ── Header stat card (boxes inside the blue header overlap) ─────────────────── +class _CoverageStatCard extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final Color iconColor; + + const _CoverageStatCard({ + required this.icon, + required this.label, + required this.value, + required this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Container( + padding: const EdgeInsets.all(16), // Increased padding + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), // More rounded + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon( + icon, + size: 14, + color: iconColor, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 20, // Slightly smaller to fit if needed + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + ), + ); + } +} + +// ── Day coverage card ───────────────────────────────────────────────────────── +class _DayCoverageCard extends StatelessWidget { + final String date; + final int filled; + final int needed; + final double percentage; + + const _DayCoverageCard({ + required this.date, + required this.filled, + required this.needed, + required this.percentage, + }); + + @override + Widget build(BuildContext context) { + final isFullyStaffed = percentage >= 100; + final spotsRemaining = (needed - filled).clamp(0, needed); + + final barColor = percentage >= 95 + ? UiColors.success + : percentage >= 80 + ? UiColors.primary + : UiColors.error; + + final badgeColor = percentage >= 95 + ? UiColors.success + : percentage >= 80 + ? UiColors.primary + : UiColors.error; + + final badgeBg = percentage >= 95 + ? UiColors.tagSuccess + : percentage >= 80 + ? UiColors.primary.withOpacity(0.1) // Blue tint + : UiColors.tagError; + + 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.03), + blurRadius: 6, + ), + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + context.t.client_reports.coverage_report.shift_item.confirmed_workers(confirmed: filled.toString(), needed: needed.toString()), + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + ], + ), + // Percentage badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${percentage.toStringAsFixed(0)}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: badgeColor, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (percentage / 100).clamp(0.0, 1.0), + backgroundColor: UiColors.bgSecondary, + valueColor: AlwaysStoppedAnimation(barColor), + minHeight: 6, + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: Text( + isFullyStaffed + ? context.t.client_reports.coverage_report.shift_item.fully_staffed + : spotsRemaining == 1 + ? context.t.client_reports.coverage_report.shift_item.one_spot_remaining + : context.t.client_reports.coverage_report.shift_item.spots_remaining(count: spotsRemaining.toString()), + style: TextStyle( + fontSize: 11, + color: isFullyStaffed + ? UiColors.success + : UiColors.textSecondary, + fontWeight: isFullyStaffed + ? FontWeight.w500 + : FontWeight.normal, + ), + ), + ), + ], + ), + ); + } +} 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..8514004c --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/daily_ops_report_page.dart @@ -0,0 +1,617 @@ +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 { + DateTime _selectedDate = DateTime.now(); + + Future _pickDate(BuildContext context) async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _selectedDate, + firstDate: DateTime(2020), + lastDate: DateTime.now().add(const Duration(days: 365)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: Theme.of(context).copyWith( + colorScheme: const ColorScheme.light( + primary: UiColors.primary, + onPrimary: UiColors.white, + surface: UiColors.white, + onSurface: UiColors.textPrimary, + ), + ), + child: child!, + ); + }, + ); + if (picked != null && picked != _selectedDate && mounted) { + setState(() => _selectedDate = picked); + if (context.mounted) { + BlocProvider.of(context).add(LoadDailyOpsReport(date: picked)); + } + } + } + + @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: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 + GestureDetector( + onTap: () => _pickDate(context), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 4, + ), + ], + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon( + UiIcons.calendar, + size: 16, + color: UiColors.primary, + ), + const 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.2, + 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: 8), + Text( + context.t.client_reports.daily_ops_report + .all_shifts_title + .toUpperCase(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + letterSpacing: 0.5, + ), + ), + const SizedBox(height: 12), + + // Shift List + if (report.shifts.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Text(context.t.client_reports.daily_ops_report.no_shifts_today), + ), + ) + else + ...report.shifts.map((shift) => _ShiftListItem( + title: shift.title, + location: shift.location, + time: + '${DateFormat('HH:mm').format(shift.startTime)} - ${DateFormat('HH:mm').format(shift.endTime)}', + workers: + '${shift.filled}/${shift.workersNeeded}', + rate: shift.hourlyRate != null + ? '\$${shift.hourlyRate!.toStringAsFixed(0)}/hr' + : '-', + status: shift.status.replaceAll('_', ' '), + statusColor: shift.status == 'COMPLETED' + ? UiColors.success + : 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), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 6), + // Colored pill badge (matches prototype) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + subValue, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _ShiftListItem extends StatelessWidget { + final String title; + final String location; + final String time; + final String workers; + final String rate; + final String status; + final Color statusColor; + + const _ShiftListItem({ + required this.title, + required this.location, + required this.time, + required this.workers, + required this.rate, + 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), + _infoItem( + context, + UiIcons.trendingUp, + context.t.client_reports.daily_ops_report.shift_item.rate, + rate), + ], + ), + ], + ), + ); + } + + 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..b7e11efc --- /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: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: context.t.client_reports.forecast_report.metrics.projected_spend, + value: NumberFormat.currency(symbol: r'$') + .format(report.projectedSpend), + icon: UiIcons.dollar, + color: UiColors.primary, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _ForecastSummaryCard( + label: context.t.client_reports.forecast_report.metrics.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( + context.t.client_reports.forecast_report.chart_title, + 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( + context.t.client_reports.forecast_report.daily_projections, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + if (report.chartData.isEmpty) + Center(child: Text(context.t.client_reports.forecast_report.empty_state)) + else + ...report.chartData.map((point) => _ForecastListItem( + date: DateFormat('EEE, MMM dd').format(point.date), + 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(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)), + ], + ), + Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart new file mode 100644 index 00000000..d2411711 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/no_show_report_page.dart @@ -0,0 +1,450 @@ +import 'package:client_reports/src/domain/entities/no_show_report.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart'; +import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart'; +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 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; + final uniqueWorkers = report.flaggedWorkers.length; + 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: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.15), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.no_show_report.title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.no_show_report.subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.6), + ), + ), + ], + ), + ], + ), + // Export button +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export coming soon'), + duration: Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon( + UiIcons.download, + size: 14, + color: Color(0xFF1A1A2E), + ), + SizedBox(width: 6), + Text( + 'Export', + style: TextStyle( + color: Color(0xFF1A1A2E), + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // ── Content ───────────────────────────────────────── + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 3-chip summary row (matches prototype) + Row( + children: [ + Expanded( + child: _SummaryChip( + icon: UiIcons.warning, + iconColor: UiColors.error, + label: context.t.client_reports.no_show_report.metrics.no_shows, + value: report.totalNoShows.toString(), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryChip( + icon: UiIcons.trendingUp, + iconColor: UiColors.textWarning, + label: context.t.client_reports.no_show_report.metrics.rate, + value: + '${report.noShowRate.toStringAsFixed(1)}%', + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SummaryChip( + icon: UiIcons.user, + iconColor: UiColors.primary, + label: context.t.client_reports.no_show_report.metrics.workers, + value: uniqueWorkers.toString(), + ), + ), + ], + ), + + const SizedBox(height: 24), + + // Section title + Text( + context.t.client_reports.no_show_report + .workers_list_title, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 16), + + // Worker cards with risk badges + if (report.flaggedWorkers.isEmpty) + Container( + padding: const EdgeInsets.all(40), + alignment: Alignment.center, + child: Text( + context.t.client_reports.no_show_report.empty_state, + style: const TextStyle( + color: UiColors.textSecondary, + ), + ), + ) + else + ...report.flaggedWorkers.map( + (worker) => _WorkerCard(worker: worker), + ), + + const SizedBox(height: 40), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +// ── Summary chip (top 3 stats) ─────────────────────────────────────────────── +class _SummaryChip extends StatelessWidget { + final IconData icon; + final Color iconColor; + final String label; + final String value; + + const _SummaryChip({ + required this.icon, + required this.iconColor, + required this.label, + required this.value, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.06), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 12, color: iconColor), + const SizedBox(width: 4), + Expanded( + child: Text( + label, + style: TextStyle( + fontSize: 10, + color: iconColor, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + value, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + ); + } +} + +// ── Worker card with risk badge + latest incident ──────────────────────────── +class _WorkerCard extends StatelessWidget { + final NoShowWorker worker; + + const _WorkerCard({required this.worker}); + + String _riskLabel(BuildContext context, int count) { + if (count >= 3) return context.t.client_reports.no_show_report.risks.high; + if (count == 2) return context.t.client_reports.no_show_report.risks.medium; + return context.t.client_reports.no_show_report.risks.low; + } + + Color _riskColor(int count) { + if (count >= 3) return UiColors.error; + if (count == 2) return UiColors.textWarning; + return UiColors.success; + } + + Color _riskBg(int count) { + if (count >= 3) return UiColors.tagError; + if (count == 2) return UiColors.tagPending; + return UiColors.tagSuccess; + } + + @override + Widget build(BuildContext context) { + final riskLabel = _riskLabel(context, worker.noShowCount); + final riskColor = _riskColor(worker.noShowCount); + final riskBg = _riskBg(worker.noShowCount); + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 6, + ), + ], + ), + child: Column( + children: [ + 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, + size: 20, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + worker.fullName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: UiColors.textPrimary, + ), + ), + Text( + context.t.client_reports.no_show_report.no_show_count(count: worker.noShowCount.toString()), + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + ), + ), + ], + ), + ], + ), + // Risk badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: riskBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + riskLabel, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: riskColor, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1, color: UiColors.bgSecondary), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.t.client_reports.no_show_report.latest_incident, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + Text( + // Use reliabilityScore as a proxy for last incident date offset + DateFormat('MMM dd, yyyy').format( + DateTime.now().subtract( + Duration( + days: ((1.0 - worker.reliabilityScore) * 60).round(), + ), + ), + ), + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ); + } +} + +// ── Insight line ───────────────────────────────────────────────────────────── 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..d1455b42 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/performance_report_page.dart @@ -0,0 +1,486 @@ +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; + + // Compute overall score (0–100) from the 4 KPIs + final overallScore = ((report.fillRate * 0.3) + + (report.completionRate * 0.3) + + (report.onTimeRate * 0.25) + + // avg fill time: 3h target → invert to score + ((report.avgFillTimeHours <= 3 + ? 100 + : (3 / report.avgFillTimeHours) * 100) * + 0.15)) + .clamp(0.0, 100.0); + + final scoreLabel = overallScore >= 90 + ? context.t.client_reports.performance_report.overall_score.excellent + : overallScore >= 75 + ? context.t.client_reports.performance_report.overall_score.good + : context.t.client_reports.performance_report.overall_score.needs_work; + final scoreLabelColor = overallScore >= 90 + ? UiColors.success + : overallScore >= 75 + ? UiColors.textWarning + : UiColors.error; + final scoreLabelBg = overallScore >= 90 + ? UiColors.tagSuccess + : overallScore >= 75 + ? UiColors.tagPending + : UiColors.tagError; + + // KPI rows: label, value, target, color, met status + final kpis = [ + _KpiData( + icon: UiIcons.users, + iconColor: UiColors.primary, + label: context.t.client_reports.performance_report.kpis.fill_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '95'), + value: report.fillRate, + displayValue: '${report.fillRate.toStringAsFixed(0)}%', + barColor: UiColors.primary, + met: report.fillRate >= 95, + close: report.fillRate >= 90, + ), + _KpiData( + icon: UiIcons.checkCircle, + iconColor: UiColors.success, + label: context.t.client_reports.performance_report.kpis.completion_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '98'), + value: report.completionRate, + displayValue: '${report.completionRate.toStringAsFixed(0)}%', + barColor: UiColors.success, + met: report.completionRate >= 98, + close: report.completionRate >= 93, + ), + _KpiData( + icon: UiIcons.clock, + iconColor: const Color(0xFF9B59B6), + label: context.t.client_reports.performance_report.kpis.on_time_rate, + target: context.t.client_reports.performance_report.kpis.target_percent(percent: '97'), + value: report.onTimeRate, + displayValue: '${report.onTimeRate.toStringAsFixed(0)}%', + barColor: const Color(0xFF9B59B6), + met: report.onTimeRate >= 97, + close: report.onTimeRate >= 92, + ), + _KpiData( + icon: UiIcons.trendingUp, + iconColor: const Color(0xFFF39C12), + label: context.t.client_reports.performance_report.kpis.avg_fill_time, + target: context.t.client_reports.performance_report.kpis.target_hours(hours: '3'), + // invert: lower is better — show as % of target met + value: report.avgFillTimeHours == 0 + ? 100 + : (3 / report.avgFillTimeHours * 100).clamp(0, 100), + displayValue: + '${report.avgFillTimeHours.toStringAsFixed(1)} hrs', + barColor: const Color(0xFFF39C12), + met: report.avgFillTimeHours <= 3, + close: report.avgFillTimeHours <= 4, + ), + ]; + + return SingleChildScrollView( + child: Column( + children: [ + // ── Header ─────────────────────────────────────────── + 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: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.performance_report + .title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: UiColors.white, + ), + ), + Text( + context.t.client_reports.performance_report + .subtitle, + style: TextStyle( + fontSize: 12, + color: UiColors.white.withOpacity(0.7), + ), + ), + ], + ), + ], + ), + // Export +/* + GestureDetector( + onTap: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Export coming soon'), + duration: Duration(seconds: 2), + ), + ); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(UiIcons.download, + size: 14, color: UiColors.primary), + SizedBox(width: 6), + Text( + 'Export', + style: TextStyle( + color: UiColors.primary, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), +*/ + ], + ), + ), + + // ── Content ────────────────────────────────────────── + Transform.translate( + offset: const Offset(0, -16), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + children: [ + // ── Overall Score Hero Card ─────────────────── + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 32, + horizontal: 20, + ), + decoration: BoxDecoration( + color: const Color(0xFFF0F4FF), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + const Icon( + UiIcons.chart, + size: 32, + color: UiColors.primary, + ), + const SizedBox(height: 12), + Text( + context.t.client_reports.performance_report.overall_score.title, + style: TextStyle( + fontSize: 13, + color: UiColors.textSecondary, + ), + ), + const SizedBox(height: 8), + Text( + '${overallScore.toStringAsFixed(0)}/100', + style: const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: UiColors.primary, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + decoration: BoxDecoration( + color: scoreLabelBg, + borderRadius: BorderRadius.circular(20), + ), + child: Text( + scoreLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: scoreLabelColor, + ), + ), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // ── KPI List ───────────────────────────────── + 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: [ + Text( + context.t.client_reports.performance_report.kpis_title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: UiColors.textSecondary, + letterSpacing: 1.2, + ), + ), + const SizedBox(height: 20), + ...kpis.map( + (kpi) => _KpiRow(kpi: kpi), + ), + ], + ), + ), + + const SizedBox(height: 100), + ], + ), + ), + ), + ], + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ), + ); + } +} + +// ── KPI data model ──────────────────────────────────────────────────────────── +class _KpiData { + final IconData icon; + final Color iconColor; + final String label; + final String target; + final double value; // 0–100 for bar + final String displayValue; + final Color barColor; + final bool met; + final bool close; + + const _KpiData({ + required this.icon, + required this.iconColor, + required this.label, + required this.target, + required this.value, + required this.displayValue, + required this.barColor, + required this.met, + required this.close, + }); +} + +// ── KPI row widget ──────────────────────────────────────────────────────────── +class _KpiRow extends StatelessWidget { + final _KpiData kpi; + + const _KpiRow({required this.kpi}); + + @override + Widget build(BuildContext context) { + final badgeText = kpi.met + ? context.t.client_reports.performance_report.kpis.met + : kpi.close + ? context.t.client_reports.performance_report.kpis.close + : context.t.client_reports.performance_report.kpis.miss; + final badgeColor = kpi.met + ? UiColors.success + : kpi.close + ? UiColors.textWarning + : UiColors.error; + final badgeBg = kpi.met + ? UiColors.tagSuccess + : kpi.close + ? UiColors.tagPending + : UiColors.tagError; + + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Column( + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: kpi.iconColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(kpi.icon, size: 18, color: kpi.iconColor), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + kpi.label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: UiColors.textPrimary, + ), + ), + Text( + kpi.target, + style: const TextStyle( + fontSize: 11, + color: UiColors.textSecondary, + ), + ), + ], + ), + ), + // Value + badge inline (matches prototype) + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + kpi.displayValue, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, + vertical: 3, + ), + decoration: BoxDecoration( + color: badgeBg, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + badgeText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: badgeColor, + ), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 10), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (kpi.value / 100).clamp(0.0, 1.0), + backgroundColor: UiColors.bgSecondary, + valueColor: AlwaysStoppedAnimation(kpi.barColor), + minHeight: 6, + ), + ), + ], + ), + ); + } +} 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..6c3f538e --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/reports_page.dart @@ -0,0 +1,583 @@ +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), + + const SizedBox(height: 40), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +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, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} + 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..77798c80 --- /dev/null +++ b/apps/mobile/packages/features/client/reports/lib/src/presentation/pages/spend_report_page.dart @@ -0,0 +1,549 @@ +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'; +import 'package:client_reports/src/domain/entities/spend_report.dart'; + +class SpendReportPage extends StatefulWidget { + const SpendReportPage({super.key}); + + @override + State createState() => _SpendReportPageState(); +} + +class _SpendReportPageState extends State { + late DateTime _startDate; + late DateTime _endDate; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + // Monday alignment logic + final diff = now.weekday - DateTime.monday; + final monday = now.subtract(Duration(days: diff)); + _startDate = DateTime(monday.year, monday.month, monday.day); + _endDate = _startDate.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59)); + } + + @override + Widget build(BuildContext context) { + 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: 80, // Overlap space + ), + decoration: const BoxDecoration( + color: UiColors.primary, // Blue background per prototype + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: UiColors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 20, + ), + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + 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.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, -60), // Pull up to overlap + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Summary Cards (New Style) + Row( + children: [ + Expanded( + child: _SpendStatCard( + label: context.t.client_reports.spend_report + .summary.total_spend, + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) + .format(report.totalSpend), + pillText: context.t.client_reports + .spend_report.summary.this_week, + themeColor: UiColors.success, + icon: UiIcons.dollar, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _SpendStatCard( + label: context.t.client_reports.spend_report + .summary.avg_daily, + value: NumberFormat.currency( + symbol: r'$', decimalDigits: 0) + .format(report.averageCost), + pillText: context.t.client_reports + .spend_report.summary.per_day, + themeColor: UiColors.primary, + icon: UiIcons.trendingUp, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Daily Spend Trend Chart + Container( + height: 320, + padding: const EdgeInsets.all(20), + 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: [ + Text( + context.t.client_reports.spend_report.chart_title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 32), + Expanded( + child: _SpendBarChart( + chartData: report.chartData), + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Spend by Industry + _SpendByIndustryCard( + industries: report.industryBreakdown, + ), + + 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, + reservedSize: 30, + getTitlesWidget: (value, meta) { + if (value.toInt() >= chartData.length) return const SizedBox(); + final date = chartData[value.toInt()].date; + return SideTitleWidget( + axisSide: meta.axisSide, + space: 8, + child: Text( + DateFormat('E').format(date), + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 11, + ), + ), + ); + }, + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + getTitlesWidget: (value, meta) { + if (value == 0) return const SizedBox(); + return SideTitleWidget( + axisSide: meta.axisSide, + child: Text( + '\$${(value / 1000).toStringAsFixed(0)}k', + style: const TextStyle( + color: UiColors.textSecondary, + fontSize: 10, + ), + ), + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: 1000, + getDrawingHorizontalLine: (value) => FlLine( + color: UiColors.bgSecondary, + strokeWidth: 1, + ), + ), + borderData: FlBorderData(show: false), + barGroups: List.generate( + chartData.length, + (index) => BarChartGroupData( + x: index, + barRods: [ + BarChartRodData( + toY: chartData[index].amount, + color: UiColors.success, + width: 12, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(4), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SpendStatCard extends StatelessWidget { + final String label; + final String value; + final String pillText; + final Color themeColor; + final IconData icon; + + const _SpendStatCard({ + required this.label, + required this.value, + required this.pillText, + required this.themeColor, + 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.06), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 14, color: themeColor), + const SizedBox(width: 8), + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + color: UiColors.textSecondary, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + value, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: themeColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + pillText, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: themeColor, + ), + ), + ), + ], + ), + ); + } +} + +class _SpendByIndustryCard extends StatelessWidget { + final List industries; + + const _SpendByIndustryCard({required this.industries}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: UiColors.black.withOpacity(0.04), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_reports.spend_report.spend_by_industry, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + const SizedBox(height: 24), + if (industries.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + context.t.client_reports.spend_report.no_industry_data, + style: const TextStyle(color: UiColors.textSecondary), + ), + ), + ) + else + ...industries.map((ind) => Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + ind.name, + style: const TextStyle( + fontSize: 13, + color: UiColors.textSecondary, + ), + ), + Text( + NumberFormat.currency(symbol: r'$', decimalDigits: 0) + .format(ind.amount), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: UiColors.textPrimary, + ), + ), + ], + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: ind.percentage / 100, + backgroundColor: UiColors.bgSecondary, + color: UiColors.success, + minHeight: 6, + ), + ), + const SizedBox(height: 6), + Text( + context.t.client_reports.spend_report.percent_total(percent: ind.percentage.toStringAsFixed(1)), + style: const TextStyle( + fontSize: 10, + color: UiColors.textDescription, + ), + ), + ], + ), + )), + ], + ), + ); + } +} 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/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index edf6b8e3..508b5396 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -42,6 +42,7 @@ class ClientSettingsPage extends StatelessWidget { } }, child: const Scaffold( + backgroundColor: UiColors.bgMenu, body: CustomScrollView( slivers: [ SettingsProfileHeader(), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart index 5f275b01..64543f96 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_actions.dart @@ -14,27 +14,46 @@ class SettingsActions extends StatelessWidget { @override /// Builds the settings actions UI. Widget build(BuildContext context) { - // Get the translations for the client settings profile. final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; + // Yellow button style matching the prototype + final ButtonStyle yellowStyle = ElevatedButton.styleFrom( + backgroundColor: UiColors.accent, + foregroundColor: UiColors.accentForeground, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + ), + ); + return SliverPadding( padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: UiConstants.space5), - /// TODO: FEATURE_NOT_YET_IMPLEMENTED - // Edit profile is not yet implemented + // Edit Profile button (yellow) + UiButton.primary( + text: labels.edit_profile, + style: yellowStyle, + onPressed: () {}, + ), + const SizedBox(height: UiConstants.space4), - // Hubs button + // Hubs button (yellow) UiButton.primary( text: labels.hubs, + style: yellowStyle, onPressed: () => Modular.to.toClientHubs(), ), const SizedBox(height: UiConstants.space4), - // Log out button + // Quick Links card + _QuickLinksCard(labels: labels), + const SizedBox(height: UiConstants.space4), + + // Log Out button (outlined) BlocBuilder( builder: (BuildContext context, ClientSettingsState state) { return UiButton.secondary( @@ -45,17 +64,11 @@ class SettingsActions extends StatelessWidget { ); }, ), + const SizedBox(height: UiConstants.space8), ]), ), ); } - - /// Handles the sign-out button click event. - void _onSignoutClicked(BuildContext context) { - ReadContext( - context, - ).read().add(const ClientSettingsSignOutRequested()); - } /// Shows a confirmation dialog for signing out. Future _showSignOutDialog(BuildContext context) { @@ -74,13 +87,10 @@ class SettingsActions extends StatelessWidget { style: UiTypography.body2r.textSecondary, ), actions: [ - // Log out button UiButton.secondary( text: t.client_settings.profile.log_out, onPressed: () => _onSignoutClicked(context), ), - - // Cancel button UiButton.secondary( text: t.common.cancel, onPressed: () => Modular.to.pop(), @@ -89,4 +99,97 @@ class SettingsActions extends StatelessWidget { ), ); } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext(context) + .read() + .add(const ClientSettingsSignOutRequested()); + } +} + +/// Quick Links card — inline here since it's always part of SettingsActions ordering. +class _QuickLinksCard extends StatelessWidget { + final TranslationsClientSettingsProfileEn labels; + + const _QuickLinksCard({required this.labels}); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusLg, + side: const BorderSide(color: UiColors.border), + ), + color: UiColors.white, + child: Padding( + padding: const EdgeInsets.all(UiConstants.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + labels.quick_links, + style: UiTypography.footnote1b.textPrimary, + ), + const SizedBox(height: UiConstants.space3), + _QuickLinkItem( + icon: UiIcons.nfc, + title: labels.clock_in_hubs, + onTap: () => Modular.to.toClientHubs(), + ), + _QuickLinkItem( + icon: UiIcons.building, + title: labels.billing_payments, + onTap: () => Modular.to.toClientBilling(), + ), + ], + ), + ), + ); + } +} + +/// A single quick link row item. +class _QuickLinkItem extends StatelessWidget { + final IconData icon; + final String title; + final VoidCallback onTap; + + const _QuickLinkItem({ + required this.icon, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: UiConstants.radiusMd, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + horizontal: UiConstants.space2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Icon(icon, size: 20, color: UiColors.iconSecondary), + const SizedBox(width: UiConstants.space3), + Text(title, style: UiTypography.footnote1m.textPrimary), + ], + ), + const Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconThird, + ), + ], + ), + ), + ); + } } diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart new file mode 100644 index 00000000..ea359254 --- /dev/null +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_logout.dart @@ -0,0 +1,87 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import '../../blocs/client_settings_bloc.dart'; + +/// A widget that displays the log out button. +class SettingsLogout extends StatelessWidget { + /// Creates a [SettingsLogout]. + const SettingsLogout({super.key}); + + @override + Widget build(BuildContext context) { + final TranslationsClientSettingsProfileEn labels = + t.client_settings.profile; + + return SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + sliver: SliverToBoxAdapter( + child: BlocBuilder( + builder: (BuildContext context, ClientSettingsState state) { + return UiButton.primary( + text: labels.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: state is ClientSettingsLoading + ? null + : () => _showSignOutDialog(context), + ); + }, + ), + ), + ); + } + + /// Handles the sign-out button click event. + void _onSignoutClicked(BuildContext context) { + ReadContext( + context, + ).read().add(const ClientSettingsSignOutRequested()); + } + + /// Shows a confirmation dialog for signing out. + Future _showSignOutDialog(BuildContext context) { + return showDialog( + context: context, + builder: (BuildContext dialogContext) => AlertDialog( + backgroundColor: UiColors.bgPopup, + elevation: 0, + shape: RoundedRectangleBorder(borderRadius: UiConstants.radiusLg), + title: Text( + t.client_settings.profile.log_out, + style: UiTypography.headline3m.textPrimary, + ), + content: Text( + t.client_settings.profile.log_out_confirmation, + style: UiTypography.body2r.textSecondary, + ), + actions: [ + // Log out button + UiButton.primary( + text: t.client_settings.profile.log_out, + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.white, + foregroundColor: UiColors.textPrimary, + side: const BorderSide(color: UiColors.textPrimary), + elevation: 0, + ), + onPressed: () => _onSignoutClicked(context), + ), + + // Cancel button + UiButton.secondary( + text: t.common.cancel, + onPressed: () => Modular.to.pop(), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index b9ddd93e..706e1e4b 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -11,7 +11,6 @@ class SettingsProfileHeader extends StatelessWidget { const SettingsProfileHeader({super.key}); @override - /// Builds the profile header UI. Widget build(BuildContext context) { final TranslationsClientSettingsProfileEn labels = t.client_settings.profile; final dc.ClientSession? session = dc.ClientSessionStore.instance.session; @@ -23,78 +22,115 @@ class SettingsProfileHeader extends StatelessWidget { ? businessName.trim()[0].toUpperCase() : 'C'; - return SliverAppBar( - backgroundColor: UiColors.bgSecondary, - expandedHeight: 140, - pinned: true, - elevation: 0, - shape: const Border(bottom: BorderSide(color: UiColors.border, width: 1)), - leading: IconButton( - icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary), - onPressed: () => Modular.to.toClientHome(), - ), - flexibleSpace: FlexibleSpaceBar( - background: Container( - padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8), - margin: const EdgeInsets.only(top: UiConstants.space24), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space4, - children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: UiColors.border, width: 2), - color: UiColors.white, + return SliverToBoxAdapter( + child: Container( + width: double.infinity, + padding: const EdgeInsets.only(bottom: 36), + decoration: const BoxDecoration( + color: UiColors.primary, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // ── Top bar: back arrow + title ────────────────── + SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space2, ), - child: CircleAvatar( - backgroundColor: UiColors.primary.withValues(alpha: 0.1), - backgroundImage: - photoUrl != null && photoUrl.isNotEmpty - ? NetworkImage(photoUrl) - : null, - child: - photoUrl != null && photoUrl.isNotEmpty - ? null - : Text( - avatarLetter, - style: UiTypography.headline1m.copyWith( - color: UiColors.primary, - ), - ), + child: Row( + children: [ + GestureDetector( + onTap: () => Modular.to.toClientHome(), + child: const Icon( + UiIcons.arrowLeft, + color: UiColors.white, + size: 22, + ), + ), + const SizedBox(width: UiConstants.space3), + Text( + labels.title, + style: UiTypography.body1b.copyWith( + color: UiColors.white, + ), + ), + ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(businessName, style: UiTypography.body1b.textPrimary), - const SizedBox(height: UiConstants.space1), - Row( - mainAxisAlignment: MainAxisAlignment.start, - spacing: UiConstants.space1, - children: [ - Icon( - UiIcons.mail, - size: 14, - color: UiColors.textSecondary, - ), - Text( - email, - style: UiTypography.footnote1r.textSecondary, - ), - ], + ), + + const SizedBox(height: UiConstants.space6), + + // ── Avatar ─────────────────────────────────────── + Container( + width: 88, + height: 88, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: UiColors.white, + border: Border.all( + color: UiColors.white.withValues(alpha: 0.6), + width: 3, + ), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, 6), ), ], ), - ], - ), + child: ClipOval( + child: photoUrl != null && photoUrl.isNotEmpty + ? Image.network(photoUrl, fit: BoxFit.cover) + : Center( + child: Text( + avatarLetter, + style: UiTypography.headline1m.copyWith( + color: UiColors.primary, + fontSize: 32, + ), + ), + ), + ), + ), + + const SizedBox(height: UiConstants.space4), + + // ── Business Name ───────────────────────────────── + Text( + businessName, + style: UiTypography.headline3m.copyWith( + color: UiColors.white, + ), + ), + + const SizedBox(height: UiConstants.space2), + + // ── Email ───────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + UiIcons.mail, + size: 14, + color: UiColors.white.withValues(alpha: 0.75), + ), + const SizedBox(width: 6), + Text( + email, + style: UiTypography.footnote1r.copyWith( + color: UiColors.white.withValues(alpha: 0.75), + ), + ), + ], + ), + ], ), ), - title: Text(labels.title, style: UiTypography.body1b.textPrimary), ); } } 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..7b6bc1bc 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -215,14 +215,12 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { staffRecord = staffResponse.data.staffs.first; } - final String email = user?.email ?? ''; - //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, + email: user?.email ?? '', + phone: user?.phone, role: user?.role.stringValue ?? 'USER', ); final domain.Staff? domainStaff = staffRecord == null @@ -245,4 +243,5 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } + } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 0ee6fc5a..bbdc1e63 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,4 +20,5 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); + } 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..b9721c85 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/staff_authentication_module.dart @@ -28,9 +28,9 @@ class StaffAuthenticationModule extends Module { @override void binds(Injector i) { // Repositories - i.addLazySingleton(AuthRepositoryImpl.new); i.addLazySingleton(ProfileSetupRepositoryImpl.new); i.addLazySingleton(PlaceRepositoryImpl.new); + i.addLazySingleton(AuthRepositoryImpl.new); // UseCases i.addLazySingleton(SignInWithPhoneUseCase.new); @@ -53,6 +53,7 @@ class StaffAuthenticationModule extends Module { ); } + @override void routes(RouteManager r) { r.child(StaffPaths.root, child: (_) => const IntroPage()); 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 diff --git a/backend/dataconnect/connector/reports/queries.gql b/backend/dataconnect/connector/reports/queries.gql index 10bceae5..84238101 100644 --- a/backend/dataconnect/connector/reports/queries.gql +++ b/backend/dataconnect/connector/reports/queries.gql @@ -281,7 +281,7 @@ query listInvoicesForSpendByBusiness( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -306,7 +306,7 @@ query listInvoicesForSpendByVendor( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } @@ -332,7 +332,7 @@ query listInvoicesForSpendByOrder( status invoiceNumber - vendor { id companyName } + vendor { id companyName serviceSpecialty } business { id businessName } order { id eventName } } diff --git a/backend/dataconnect/connector/user/mutations.gql b/backend/dataconnect/connector/user/mutations.gql index 05e233b6..f29b62d9 100644 --- a/backend/dataconnect/connector/user/mutations.gql +++ b/backend/dataconnect/connector/user/mutations.gql @@ -1,6 +1,7 @@ mutation CreateUser( $id: String!, # Firebase UID $email: String, + $phone: String, $fullName: String, $role: UserBaseRole!, $userRole: String, @@ -10,6 +11,7 @@ mutation CreateUser( data: { id: $id email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole @@ -21,6 +23,7 @@ mutation CreateUser( mutation UpdateUser( $id: String!, $email: String, + $phone: String, $fullName: String, $role: UserBaseRole, $userRole: String, @@ -30,6 +33,7 @@ mutation UpdateUser( id: $id, data: { email: $email + phone: $phone fullName: $fullName role: $role userRole: $userRole diff --git a/backend/dataconnect/connector/user/queries.gql b/backend/dataconnect/connector/user/queries.gql index 044abebf..760d633f 100644 --- a/backend/dataconnect/connector/user/queries.gql +++ b/backend/dataconnect/connector/user/queries.gql @@ -2,6 +2,7 @@ query listUsers @auth(level: USER) { users { id email + phone fullName role userRole @@ -17,6 +18,7 @@ query getUserById( user(id: $id) { id email + phone fullName role userRole @@ -40,6 +42,7 @@ query filterUsers( ) { id email + phone fullName role userRole diff --git a/backend/dataconnect/schema/user.gql b/backend/dataconnect/schema/user.gql index 4cb4ca24..4d932493 100644 --- a/backend/dataconnect/schema/user.gql +++ b/backend/dataconnect/schema/user.gql @@ -6,6 +6,7 @@ enum UserBaseRole { type User @table(name: "users") { id: String! # user_id / uid de Firebase email: String + phone: String fullName: String role: UserBaseRole! userRole: String