Merge branch 'dev' into 408-feature-implement-paidunpaid-breaks---client-app-frontend-development
This commit is contained in:
@@ -40,7 +40,11 @@ void main() async {
|
||||
/// The main application module.
|
||||
class AppModule extends Module {
|
||||
@override
|
||||
List<Module> get imports => <Module>[core_localization.LocalizationModule()];
|
||||
List<Module> get imports =>
|
||||
<Module>[
|
||||
core_localization.LocalizationModule(),
|
||||
staff_authentication.StaffAuthenticationModule(),
|
||||
];
|
||||
|
||||
@override
|
||||
void routes(RouteManager r) {
|
||||
|
||||
@@ -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<SessionListener> {
|
||||
debugPrint('[SessionListener] Initialized session listener');
|
||||
}
|
||||
|
||||
void _handleSessionChange(SessionState state) {
|
||||
Future<void> _handleSessionChange(SessionState state) async {
|
||||
if (!mounted) return;
|
||||
|
||||
switch (state.type) {
|
||||
@@ -65,6 +66,7 @@ class _SessionListenerState extends State<SessionListener> {
|
||||
_sessionExpiredDialogShown = false;
|
||||
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||
|
||||
|
||||
// Navigate to the main app
|
||||
Modular.to.toStaffHome();
|
||||
break;
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 <String, double>{},
|
||||
});
|
||||
|
||||
/// 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<String> 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<PermanentOrderPosition> positions;
|
||||
|
||||
/// Selected hub details for this order.
|
||||
final PermanentOrderHubDetails? hub;
|
||||
|
||||
/// Optional order name.
|
||||
final List<OneTimeOrderPosition> 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<String, double> roleRates;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
List<Object?> 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<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<dynamic>(
|
||||
ModuleRoute<dynamic>(
|
||||
ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports),
|
||||
child: (BuildContext context) =>
|
||||
const PlaceholderPage(title: 'Reports'),
|
||||
module: ReportsModule(),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -24,6 +24,8 @@ dependencies:
|
||||
path: ../home
|
||||
client_coverage:
|
||||
path: ../client_coverage
|
||||
client_reports:
|
||||
path: ../reports
|
||||
view_orders:
|
||||
path: ../view_orders
|
||||
billing:
|
||||
|
||||
@@ -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<String> selectedDays = Set<String>.from(order.permanentDays);
|
||||
final int workersNeeded = order.positions.fold<int>(
|
||||
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 =
|
||||
|
||||
@@ -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<PermanentOrderArguments, void> {
|
||||
/// Creates a [CreatePermanentOrderUseCase].
|
||||
class CreatePermanentOrderUseCase implements UseCase<PermanentOrder, void> {
|
||||
const CreatePermanentOrderUseCase(this._repository);
|
||||
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(PermanentOrderArguments input) {
|
||||
return _repository.createPermanentOrder(input.order);
|
||||
Future<void> call(PermanentOrder params) {
|
||||
return _repository.createPermanentOrder(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RecurringOrderArguments, void> {
|
||||
/// Creates a [CreateRecurringOrderUseCase].
|
||||
class CreateRecurringOrderUseCase implements UseCase<RecurringOrder, void> {
|
||||
const CreateRecurringOrderUseCase(this._repository);
|
||||
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(RecurringOrderArguments input) {
|
||||
return _repository.createRecurringOrder(input.order);
|
||||
Future<void> call(RecurringOrder params) {
|
||||
return _repository.createRecurringOrder(params);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Future<void>, ReorderArguments> {
|
||||
const ReorderUseCase(this._repository);
|
||||
|
||||
final ClientCreateOrderRepositoryInterface _repository;
|
||||
|
||||
@override
|
||||
Future<void> call(ReorderArguments params) {
|
||||
return _repository.reorder(params.previousOrderId, params.newDate);
|
||||
}
|
||||
}
|
||||
@@ -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<PermanentOrderEvent, PermanentOrderState>
|
||||
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<PermanentOrderEvent, PermanentOrderState>
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
hub: domain.PermanentOrderHubDetails(
|
||||
hub: domain.OneTimeOrderHubDetails(
|
||||
id: selectedHub.id,
|
||||
name: selectedHub.name,
|
||||
address: selectedHub.address,
|
||||
@@ -316,9 +314,7 @@ class PermanentOrderBloc extends Bloc<PermanentOrderEvent, PermanentOrderState>
|
||||
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(
|
||||
|
||||
@@ -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<RecurringOrderEvent, RecurringOrderState>
|
||||
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(
|
||||
|
||||
@@ -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,7 +43,8 @@ class ClientHomeEditBanner extends StatelessWidget {
|
||||
children: <Widget>[
|
||||
const Icon(UiIcons.edit, size: 16, color: UiColors.primary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Column(
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
@@ -59,6 +60,7 @@ class ClientHomeEditBanner extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
UiButton.secondary(
|
||||
text: i18n.dashboard.reset,
|
||||
onPressed: () => BlocProvider.of<ClientHomeBloc>(
|
||||
|
||||
@@ -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>(ClientHubsBloc.new);
|
||||
|
||||
@@ -124,6 +124,78 @@ class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.Hub> 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<domain.Hub> 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<dc.GetBusinessesByUserIdBusinesses>
|
||||
_getBusinessForCurrentUser() async {
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
|
||||
@@ -35,4 +35,21 @@ abstract interface class HubRepositoryInterface {
|
||||
///
|
||||
/// Takes the [hubId] and the [nfcTagId] to be associated.
|
||||
Future<void> 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<Hub> updateHub({
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
/// Use case for updating an existing hub.
|
||||
class UpdateHubUseCase implements UseCase<UpdateHubArguments, Hub> {
|
||||
UpdateHubUseCase(this.repository);
|
||||
|
||||
final HubRepositoryInterface repository;
|
||||
|
||||
@override
|
||||
Future<Hub> 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ClientHubsEvent, ClientHubsState>
|
||||
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<ClientHubsFetched>(_onFetched);
|
||||
on<ClientHubsAddRequested>(_onAddRequested);
|
||||
on<ClientHubsUpdateRequested>(_onUpdateRequested);
|
||||
on<ClientHubsDeleteRequested>(_onDeleteRequested);
|
||||
on<ClientHubsNfcTagAssignRequested>(_onNfcTagAssignRequested);
|
||||
on<ClientHubsMessageCleared>(_onMessageCleared);
|
||||
@@ -42,6 +46,7 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
final CreateHubUseCase _createHubUseCase;
|
||||
final DeleteHubUseCase _deleteHubUseCase;
|
||||
final AssignNfcTagUseCase _assignNfcTagUseCase;
|
||||
final UpdateHubUseCase _updateHubUseCase;
|
||||
|
||||
void _onAddDialogToggled(
|
||||
ClientHubsAddDialogToggled event,
|
||||
@@ -120,6 +125,46 @@ class ClientHubsBloc extends Bloc<ClientHubsEvent, ClientHubsState>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onUpdateRequested(
|
||||
ClientHubsUpdateRequested event,
|
||||
Emitter<ClientHubsState> 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<Hub> 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<void> _onDeleteRequested(
|
||||
ClientHubsDeleteRequested event,
|
||||
Emitter<ClientHubsState> emit,
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
id,
|
||||
name,
|
||||
address,
|
||||
placeId,
|
||||
latitude,
|
||||
longitude,
|
||||
city,
|
||||
state,
|
||||
street,
|
||||
country,
|
||||
zipCode,
|
||||
];
|
||||
}
|
||||
|
||||
/// Event triggered to delete a hub.
|
||||
class ClientHubsDeleteRequested extends ClientHubsEvent {
|
||||
|
||||
|
||||
@@ -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<EditHubPage> createState() => _EditHubPageState();
|
||||
}
|
||||
|
||||
class _EditHubPageState extends State<EditHubPage> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||||
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<ClientHubsBloc>().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<ClientHubsBloc>.value(
|
||||
value: widget.bloc,
|
||||
child: BlocListener<ClientHubsBloc, ClientHubsState>(
|
||||
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<ClientHubsBloc, ClientHubsState>(
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
// ── 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
_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>[
|
||||
BoxShadow(
|
||||
color: UiColors.popupShadow,
|
||||
blurRadius: 10,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
Text(label, style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
Text(value, style: UiTypography.body1m.textPrimary),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<bool>(
|
||||
MaterialPageRoute<bool>(
|
||||
builder: (_) => EditHubPage(hub: hub, bloc: bloc),
|
||||
),
|
||||
);
|
||||
if (saved == true && context.mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<HubFormDialog> createState() => _HubFormDialogState();
|
||||
}
|
||||
|
||||
class _HubFormDialogState extends State<HubFormDialog> {
|
||||
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<FormState> _formKey = GlobalKey<FormState>();
|
||||
|
||||
@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>[
|
||||
BoxShadow(color: UiColors.popupShadow, blurRadius: 20),
|
||||
],
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: <Widget>[
|
||||
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: <Widget>[
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
BIN
apps/mobile/packages/features/client/reports/analysis_output.txt
Normal file
BIN
apps/mobile/packages/features/client/reports/analysis_output.txt
Normal file
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
library client_reports;
|
||||
|
||||
export 'src/reports_module.dart';
|
||||
export 'src/presentation/pages/reports_page.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<DailyOpsReport> 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<DailyOpsShift> 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<SpendReport> 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<SpendInvoice> spendInvoices = [];
|
||||
final Map<DateTime, double> dailyAggregates = {};
|
||||
final Map<String, double> 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<DateTime, double> 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<SpendChartPoint> chartData = completeDailyAggregates.entries
|
||||
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||
.toList()
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<SpendIndustryCategory> 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<CoverageReport> 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<DateTime, (int, int)> 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<CoverageDay> 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<ForecastReport> 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<DateTime, (double, int)> 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<ForecastPoint> 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<PerformanceReport> 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<NoShowReport> 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<NoShowWorker> 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<ReportsSummary> 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class CoverageReport extends Equatable {
|
||||
final double overallCoverage;
|
||||
final int totalNeeded;
|
||||
final int totalFilled;
|
||||
final List<CoverageDay> dailyCoverage;
|
||||
|
||||
const CoverageReport({
|
||||
required this.overallCoverage,
|
||||
required this.totalNeeded,
|
||||
required this.totalFilled,
|
||||
required this.dailyCoverage,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [date, needed, filled, percentage];
|
||||
}
|
||||
@@ -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<DailyOpsShift> shifts;
|
||||
|
||||
const DailyOpsReport({
|
||||
required this.scheduledShifts,
|
||||
required this.workersConfirmed,
|
||||
required this.inProgressShifts,
|
||||
required this.completedShifts,
|
||||
required this.shifts,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [
|
||||
id,
|
||||
title,
|
||||
location,
|
||||
startTime,
|
||||
endTime,
|
||||
workersNeeded,
|
||||
filled,
|
||||
status,
|
||||
hourlyRate,
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ForecastReport extends Equatable {
|
||||
final double projectedSpend;
|
||||
final int projectedWorkers;
|
||||
final double averageLaborCost;
|
||||
final List<ForecastPoint> chartData;
|
||||
|
||||
const ForecastReport({
|
||||
required this.projectedSpend,
|
||||
required this.projectedWorkers,
|
||||
required this.averageLaborCost,
|
||||
required this.chartData,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [date, projectedCost, workersNeeded];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class NoShowReport extends Equatable {
|
||||
final int totalNoShows;
|
||||
final double noShowRate;
|
||||
final List<NoShowWorker> flaggedWorkers;
|
||||
|
||||
const NoShowReport({
|
||||
required this.totalNoShows,
|
||||
required this.noShowRate,
|
||||
required this.flaggedWorkers,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [id, fullName, noShowCount, reliabilityScore];
|
||||
}
|
||||
@@ -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<PerformanceMetric> keyPerformanceIndicators;
|
||||
|
||||
const PerformanceReport({
|
||||
required this.fillRate,
|
||||
required this.completionRate,
|
||||
required this.onTimeRate,
|
||||
required this.avgFillTimeHours,
|
||||
required this.keyPerformanceIndicators,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [label, value, trend];
|
||||
}
|
||||
@@ -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<Object?> get props => [
|
||||
totalHours,
|
||||
otHours,
|
||||
totalSpend,
|
||||
fillRate,
|
||||
avgFillTimeHours,
|
||||
noShowRate,
|
||||
];
|
||||
}
|
||||
@@ -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<SpendInvoice> invoices;
|
||||
final List<SpendChartPoint> 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<SpendIndustryCategory> industryBreakdown;
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> 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<Object?> 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<Object?> get props => [date, amount];
|
||||
}
|
||||
@@ -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<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
});
|
||||
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
Future<ForecastReport> getForecastReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
Future<NoShowReport> getNoShowReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
Future<ReportsSummary> getReportsSummary({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
}
|
||||
@@ -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<DailyOpsEvent, DailyOpsState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
DailyOpsBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(DailyOpsInitial()) {
|
||||
on<LoadDailyOpsReport>(_onLoadDailyOpsReport);
|
||||
}
|
||||
|
||||
Future<void> _onLoadDailyOpsReport(
|
||||
LoadDailyOpsReport event,
|
||||
Emitter<DailyOpsState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class DailyOpsEvent extends Equatable {
|
||||
const DailyOpsEvent();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class LoadDailyOpsReport extends DailyOpsEvent {
|
||||
final String? businessId;
|
||||
final DateTime date;
|
||||
|
||||
const LoadDailyOpsReport({
|
||||
this.businessId,
|
||||
required this.date,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [businessId, date];
|
||||
}
|
||||
@@ -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<Object?> get props => [];
|
||||
}
|
||||
|
||||
class DailyOpsInitial extends DailyOpsState {}
|
||||
|
||||
class DailyOpsLoading extends DailyOpsState {}
|
||||
|
||||
class DailyOpsLoaded extends DailyOpsState {
|
||||
final DailyOpsReport report;
|
||||
|
||||
const DailyOpsLoaded(this.report);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [report];
|
||||
}
|
||||
|
||||
class DailyOpsError extends DailyOpsState {
|
||||
final String message;
|
||||
|
||||
const DailyOpsError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<ForecastEvent, ForecastState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
ForecastBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(ForecastInitial()) {
|
||||
on<LoadForecastReport>(_onLoadForecastReport);
|
||||
}
|
||||
|
||||
Future<void> _onLoadForecastReport(
|
||||
LoadForecastReport event,
|
||||
Emitter<ForecastState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ForecastEvent extends Equatable {
|
||||
const ForecastEvent();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [businessId, startDate, endDate];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/forecast_report.dart';
|
||||
|
||||
abstract class ForecastState extends Equatable {
|
||||
const ForecastState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ForecastInitial extends ForecastState {}
|
||||
|
||||
class ForecastLoading extends ForecastState {}
|
||||
|
||||
class ForecastLoaded extends ForecastState {
|
||||
final ForecastReport report;
|
||||
|
||||
const ForecastLoaded(this.report);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [report];
|
||||
}
|
||||
|
||||
class ForecastError extends ForecastState {
|
||||
final String message;
|
||||
|
||||
const ForecastError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<NoShowEvent, NoShowState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
NoShowBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(NoShowInitial()) {
|
||||
on<LoadNoShowReport>(_onLoadNoShowReport);
|
||||
}
|
||||
|
||||
Future<void> _onLoadNoShowReport(
|
||||
LoadNoShowReport event,
|
||||
Emitter<NoShowState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class NoShowEvent extends Equatable {
|
||||
const NoShowEvent();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [businessId, startDate, endDate];
|
||||
}
|
||||
@@ -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<Object?> get props => [];
|
||||
}
|
||||
|
||||
class NoShowInitial extends NoShowState {}
|
||||
|
||||
class NoShowLoading extends NoShowState {}
|
||||
|
||||
class NoShowLoaded extends NoShowState {
|
||||
final NoShowReport report;
|
||||
|
||||
const NoShowLoaded(this.report);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [report];
|
||||
}
|
||||
|
||||
class NoShowError extends NoShowState {
|
||||
final String message;
|
||||
|
||||
const NoShowError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<PerformanceEvent, PerformanceState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
PerformanceBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(PerformanceInitial()) {
|
||||
on<LoadPerformanceReport>(_onLoadPerformanceReport);
|
||||
}
|
||||
|
||||
Future<void> _onLoadPerformanceReport(
|
||||
LoadPerformanceReport event,
|
||||
Emitter<PerformanceState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class PerformanceEvent extends Equatable {
|
||||
const PerformanceEvent();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [businessId, startDate, endDate];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/performance_report.dart';
|
||||
|
||||
abstract class PerformanceState extends Equatable {
|
||||
const PerformanceState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class PerformanceInitial extends PerformanceState {}
|
||||
|
||||
class PerformanceLoading extends PerformanceState {}
|
||||
|
||||
class PerformanceLoaded extends PerformanceState {
|
||||
final PerformanceReport report;
|
||||
|
||||
const PerformanceLoaded(this.report);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [report];
|
||||
}
|
||||
|
||||
class PerformanceError extends PerformanceState {
|
||||
final String message;
|
||||
|
||||
const PerformanceError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<SpendEvent, SpendState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
SpendBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(SpendInitial()) {
|
||||
on<LoadSpendReport>(_onLoadSpendReport);
|
||||
}
|
||||
|
||||
Future<void> _onLoadSpendReport(
|
||||
LoadSpendReport event,
|
||||
Emitter<SpendState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class SpendEvent extends Equatable {
|
||||
const SpendEvent();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [businessId, startDate, endDate];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/spend_report.dart';
|
||||
|
||||
abstract class SpendState extends Equatable {
|
||||
const SpendState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class SpendInitial extends SpendState {}
|
||||
|
||||
class SpendLoading extends SpendState {}
|
||||
|
||||
class SpendLoaded extends SpendState {
|
||||
final SpendReport report;
|
||||
|
||||
const SpendLoaded(this.report);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [report];
|
||||
}
|
||||
|
||||
class SpendError extends SpendState {
|
||||
final String message;
|
||||
|
||||
const SpendError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<ReportsSummaryEvent, ReportsSummaryState> {
|
||||
final ReportsRepository _reportsRepository;
|
||||
|
||||
ReportsSummaryBloc({required ReportsRepository reportsRepository})
|
||||
: _reportsRepository = reportsRepository,
|
||||
super(ReportsSummaryInitial()) {
|
||||
on<LoadReportsSummary>(_onLoadReportsSummary);
|
||||
}
|
||||
|
||||
Future<void> _onLoadReportsSummary(
|
||||
LoadReportsSummary event,
|
||||
Emitter<ReportsSummaryState> 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
abstract class ReportsSummaryEvent extends Equatable {
|
||||
const ReportsSummaryEvent();
|
||||
|
||||
@override
|
||||
List<Object?> 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<Object?> get props => [businessId, startDate, endDate];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/reports_summary.dart';
|
||||
|
||||
abstract class ReportsSummaryState extends Equatable {
|
||||
const ReportsSummaryState();
|
||||
|
||||
@override
|
||||
List<Object?> get props => [];
|
||||
}
|
||||
|
||||
class ReportsSummaryInitial extends ReportsSummaryState {}
|
||||
|
||||
class ReportsSummaryLoading extends ReportsSummaryState {}
|
||||
|
||||
class ReportsSummaryLoaded extends ReportsSummaryState {
|
||||
final ReportsSummary summary;
|
||||
|
||||
const ReportsSummaryLoaded(this.summary);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [summary];
|
||||
}
|
||||
|
||||
class ReportsSummaryError extends ReportsSummaryState {
|
||||
final String message;
|
||||
|
||||
const ReportsSummaryError(this.message);
|
||||
|
||||
@override
|
||||
List<Object?> get props => [message];
|
||||
}
|
||||
@@ -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<CoverageReportPage> createState() => _CoverageReportPageState();
|
||||
}
|
||||
|
||||
class _CoverageReportPageState extends State<CoverageReportPage> {
|
||||
DateTime _startDate = DateTime.now();
|
||||
DateTime _endDate = DateTime.now().add(const Duration(days: 6));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<CoverageBloc>()
|
||||
..add(LoadCoverageReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<CoverageBloc, CoverageState>(
|
||||
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<Color>(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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<DailyOpsReportPage> createState() => _DailyOpsReportPageState();
|
||||
}
|
||||
|
||||
class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
|
||||
DateTime _selectedDate = DateTime.now();
|
||||
|
||||
Future<void> _pickDate(BuildContext context) async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _selectedDate,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
builder: (BuildContext context, Widget? child) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: UiColors.primary,
|
||||
onPrimary: UiColors.white,
|
||||
surface: UiColors.white,
|
||||
onSurface: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (picked != null && picked != _selectedDate && mounted) {
|
||||
setState(() => _selectedDate = picked);
|
||||
if (context.mounted) {
|
||||
BlocProvider.of<DailyOpsBloc>(context).add(LoadDailyOpsReport(date: picked));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<DailyOpsBloc>()
|
||||
..add(LoadDailyOpsReport(date: _selectedDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<DailyOpsBloc, DailyOpsState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ForecastReportPage> createState() => _ForecastReportPageState();
|
||||
}
|
||||
|
||||
class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
DateTime _startDate = DateTime.now();
|
||||
DateTime _endDate = DateTime.now().add(const Duration(days: 14));
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<ForecastBloc>()
|
||||
..add(LoadForecastReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<ForecastBloc, ForecastState>(
|
||||
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<ForecastPoint> 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)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<NoShowReportPage> createState() => _NoShowReportPageState();
|
||||
}
|
||||
|
||||
class _NoShowReportPageState extends State<NoShowReportPage> {
|
||||
DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
|
||||
DateTime _endDate = DateTime.now();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<NoShowBloc>()
|
||||
..add(LoadNoShowReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<NoShowBloc, NoShowState>(
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
@@ -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<PerformanceReportPage> createState() => _PerformanceReportPageState();
|
||||
}
|
||||
|
||||
class _PerformanceReportPageState extends State<PerformanceReportPage> {
|
||||
DateTime _startDate = DateTime.now().subtract(const Duration(days: 30));
|
||||
DateTime _endDate = DateTime.now();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider(
|
||||
create: (context) => Modular.get<PerformanceBloc>()
|
||||
..add(LoadPerformanceReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<PerformanceBloc, PerformanceState>(
|
||||
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<Color>(kpi.barColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<ReportsPage> createState() => _ReportsPageState();
|
||||
}
|
||||
|
||||
class _ReportsPageState extends State<ReportsPage>
|
||||
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<ReportsSummaryBloc>();
|
||||
_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<ReportsSummaryBloc, ReportsSummaryState>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SpendReportPage> createState() => _SpendReportPageState();
|
||||
}
|
||||
|
||||
class _SpendReportPageState extends State<SpendReportPage> {
|
||||
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<SpendBloc>()
|
||||
..add(LoadSpendReport(startDate: _startDate, endDate: _endDate)),
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: BlocBuilder<SpendBloc, SpendState>(
|
||||
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<dynamic> chartData;
|
||||
|
||||
const _SpendBarChart({required this.chartData});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BarChart(
|
||||
BarChartData(
|
||||
alignment: BarChartAlignment.spaceAround,
|
||||
maxY: (chartData.fold<double>(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<SpendIndustryCategory> 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Module> get imports => [DataConnectModule()];
|
||||
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
i.addLazySingleton<ReportsRepository>(ReportsRepositoryImpl.new);
|
||||
i.add<DailyOpsBloc>(DailyOpsBloc.new);
|
||||
i.add<SpendBloc>(SpendBloc.new);
|
||||
i.add<CoverageBloc>(CoverageBloc.new);
|
||||
i.add<ForecastBloc>(ForecastBloc.new);
|
||||
i.add<PerformanceBloc>(PerformanceBloc.new);
|
||||
i.add<NoShowBloc>(NoShowBloc.new);
|
||||
i.add<ReportsSummaryBloc>(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());
|
||||
}
|
||||
}
|
||||
39
apps/mobile/packages/features/client/reports/pubspec.yaml
Normal file
39
apps/mobile/packages/features/client/reports/pubspec.yaml
Normal file
@@ -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
|
||||
@@ -42,6 +42,7 @@ class ClientSettingsPage extends StatelessWidget {
|
||||
}
|
||||
},
|
||||
child: const Scaffold(
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
body: CustomScrollView(
|
||||
slivers: <Widget>[
|
||||
SettingsProfileHeader(),
|
||||
|
||||
@@ -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(<Widget>[
|
||||
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<ClientSettingsBloc, ClientSettingsState>(
|
||||
builder: (BuildContext context, ClientSettingsState state) {
|
||||
return UiButton.secondary(
|
||||
@@ -45,18 +64,12 @@ class SettingsActions extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Handles the sign-out button click event.
|
||||
void _onSignoutClicked(BuildContext context) {
|
||||
ReadContext(
|
||||
context,
|
||||
).read<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog for signing out.
|
||||
Future<void> _showSignOutDialog(BuildContext context) {
|
||||
return showDialog(
|
||||
@@ -74,13 +87,10 @@ class SettingsActions extends StatelessWidget {
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
actions: <Widget>[
|
||||
// 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<ClientSettingsBloc>()
|
||||
.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: <Widget>[
|
||||
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: <Widget>[
|
||||
Row(
|
||||
children: <Widget>[
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ClientSettingsBloc, ClientSettingsState>(
|
||||
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<ClientSettingsBloc>().add(const ClientSettingsSignOutRequested());
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog for signing out.
|
||||
Future<void> _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: <Widget>[
|
||||
// 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
return SliverToBoxAdapter(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.only(bottom: 36),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
background: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8),
|
||||
margin: const EdgeInsets.only(top: UiConstants.space24),
|
||||
child: Row(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
spacing: UiConstants.space4,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: UiColors.border, width: 2),
|
||||
// ── Top bar: back arrow + title ──────────────────
|
||||
SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
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,
|
||||
),
|
||||
child: CircleAvatar(
|
||||
backgroundColor: UiColors.primary.withValues(alpha: 0.1),
|
||||
backgroundImage:
|
||||
photoUrl != null && photoUrl.isNotEmpty
|
||||
? NetworkImage(photoUrl)
|
||||
: null,
|
||||
child:
|
||||
photoUrl != null && photoUrl.isNotEmpty
|
||||
? null
|
||||
: Text(
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Text(businessName, style: UiTypography.body1b.textPrimary),
|
||||
const SizedBox(height: UiConstants.space1),
|
||||
),
|
||||
|
||||
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.start,
|
||||
spacing: UiConstants.space1,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Icon(
|
||||
UiIcons.mail,
|
||||
size: 14,
|
||||
color: UiColors.textSecondary,
|
||||
color: UiColors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
email,
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.white.withValues(alpha: 0.75),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(labels.title, style: UiTypography.body1b.textPrimary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ abstract interface class AuthRepositoryInterface {
|
||||
|
||||
/// Signs out the current user.
|
||||
Future<void> signOut();
|
||||
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ class StaffAuthenticationModule extends Module {
|
||||
@override
|
||||
void binds(Injector i) {
|
||||
// Repositories
|
||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
|
||||
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
||||
i.addLazySingleton<AuthRepositoryInterface>(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());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user