reports page implementation
This commit is contained in:
@@ -40,7 +40,11 @@ void main() async {
|
|||||||
/// The main application module.
|
/// The main application module.
|
||||||
class AppModule extends Module {
|
class AppModule extends Module {
|
||||||
@override
|
@override
|
||||||
List<Module> get imports => <Module>[core_localization.LocalizationModule()];
|
List<Module> get imports =>
|
||||||
|
<Module>[
|
||||||
|
core_localization.LocalizationModule(),
|
||||||
|
staff_authentication.StaffAuthenticationModule(),
|
||||||
|
];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.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.
|
/// 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');
|
debugPrint('[SessionListener] Initialized session listener');
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleSessionChange(SessionState state) {
|
Future<void> _handleSessionChange(SessionState state) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
switch (state.type) {
|
switch (state.type) {
|
||||||
@@ -65,6 +66,19 @@ class _SessionListenerState extends State<SessionListener> {
|
|||||||
_sessionExpiredDialogShown = false;
|
_sessionExpiredDialogShown = false;
|
||||||
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||||
|
|
||||||
|
if (StaffSessionStore.instance.session == null) {
|
||||||
|
try {
|
||||||
|
final AuthRepositoryInterface authRepo =
|
||||||
|
Modular.get<AuthRepositoryInterface>();
|
||||||
|
await authRepo.restoreSession();
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
_proceedToLogin();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Navigate to the main app
|
// Navigate to the main app
|
||||||
Modular.to.toStaffHome();
|
Modular.to.toStaffHome();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1140,5 +1140,318 @@
|
|||||||
"availability": {
|
"availability": {
|
||||||
"updated": "Availability updated successfully"
|
"updated": "Availability updated successfully"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"client_reports": {
|
||||||
|
"title": "Workforce Control Tower",
|
||||||
|
"tabs": {
|
||||||
|
"today": "Today",
|
||||||
|
"week": "Week",
|
||||||
|
"month": "Month",
|
||||||
|
"quarter": "Quarter"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"total_hrs": {
|
||||||
|
"label": "Total Hrs",
|
||||||
|
"badge": "This period"
|
||||||
|
},
|
||||||
|
"ot_hours": {
|
||||||
|
"label": "OT Hours",
|
||||||
|
"badge": "5.1% of total"
|
||||||
|
},
|
||||||
|
"total_spend": {
|
||||||
|
"label": "Total Spend",
|
||||||
|
"badge": "↓ 8% vs last week"
|
||||||
|
},
|
||||||
|
"fill_rate": {
|
||||||
|
"label": "Fill Rate",
|
||||||
|
"badge": "↑ 2% improvement"
|
||||||
|
},
|
||||||
|
"avg_fill_time": {
|
||||||
|
"label": "Avg Fill Time",
|
||||||
|
"badge": "Industry best"
|
||||||
|
},
|
||||||
|
"no_show_rate": {
|
||||||
|
"label": "No-Show Rate",
|
||||||
|
"badge": "Below avg"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quick_reports": {
|
||||||
|
"title": "Quick Reports",
|
||||||
|
"export_all": "Export All",
|
||||||
|
"two_click_export": "2-click export",
|
||||||
|
"cards": {
|
||||||
|
"daily_ops": "Daily Ops Report",
|
||||||
|
"spend": "Spend Report",
|
||||||
|
"coverage": "Coverage Report",
|
||||||
|
"no_show": "No-Show Report",
|
||||||
|
"forecast": "Forecast Report",
|
||||||
|
"performance": "Performance Report"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ai_insights": {
|
||||||
|
"title": "AI Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "You could save ",
|
||||||
|
"highlight": "USD 1,200/month",
|
||||||
|
"suffix": " by booking workers 48hrs in advance"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "Weekend demand is ",
|
||||||
|
"highlight": "40% higher",
|
||||||
|
"suffix": " - consider scheduling earlier"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Your top 5 workers complete ",
|
||||||
|
"highlight": "95% of shifts",
|
||||||
|
"suffix": " - mark them as preferred"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"daily_ops_report": {
|
||||||
|
"title": "Daily Ops Report",
|
||||||
|
"subtitle": "Real-time shift tracking",
|
||||||
|
"metrics": {
|
||||||
|
"scheduled": {
|
||||||
|
"label": "Scheduled",
|
||||||
|
"sub_value": "shifts"
|
||||||
|
},
|
||||||
|
"workers": {
|
||||||
|
"label": "Workers",
|
||||||
|
"sub_value": "confirmed"
|
||||||
|
},
|
||||||
|
"in_progress": {
|
||||||
|
"label": "In Progress",
|
||||||
|
"sub_value": "active now"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"label": "Completed",
|
||||||
|
"sub_value": "done today"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"all_shifts_title": "ALL SHIFTS",
|
||||||
|
"shift_item": {
|
||||||
|
"time": "Time",
|
||||||
|
"workers": "Workers",
|
||||||
|
"rate": "Rate"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"processing": "Processing",
|
||||||
|
"filling": "Filling",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"completed": "Completed"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting Daily Operations Report (Placeholder)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spend_report": {
|
||||||
|
"title": "Spend Report",
|
||||||
|
"subtitle": "Cost analysis & breakdown",
|
||||||
|
"summary": {
|
||||||
|
"total_spend": "Total Spend",
|
||||||
|
"avg_daily": "Avg Daily",
|
||||||
|
"this_week": "This week",
|
||||||
|
"per_day": "Per day"
|
||||||
|
},
|
||||||
|
"chart_title": "Daily Spend Trend",
|
||||||
|
"charts": {
|
||||||
|
"mon": "Mon",
|
||||||
|
"tue": "Tue",
|
||||||
|
"wed": "Wed",
|
||||||
|
"thu": "Thu",
|
||||||
|
"fri": "Fri",
|
||||||
|
"sat": "Sat",
|
||||||
|
"sun": "Sun"
|
||||||
|
},
|
||||||
|
"spend_by_industry": "Spend by Industry",
|
||||||
|
"industries": {
|
||||||
|
"hospitality": "Hospitality",
|
||||||
|
"events": "Events",
|
||||||
|
"retail": "Retail"
|
||||||
|
},
|
||||||
|
"percent_total": "$percent% of total",
|
||||||
|
"insights": {
|
||||||
|
"title": "Cost Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Your spend is ",
|
||||||
|
"highlight": "8% lower",
|
||||||
|
"suffix": " than last week"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "",
|
||||||
|
"highlight": "Friday",
|
||||||
|
"suffix": " had the highest spend (USD 4.1k)"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Hospitality accounts for ",
|
||||||
|
"highlight": "48%",
|
||||||
|
"suffix": " of total costs"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting Spend Report (Placeholder)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forecast_report": {
|
||||||
|
"title": "Forecast Report",
|
||||||
|
"subtitle": "Next 4 weeks projection",
|
||||||
|
"summary": {
|
||||||
|
"four_week": "4-Week Forecast",
|
||||||
|
"avg_weekly": "Avg Weekly",
|
||||||
|
"total_shifts": "Total Shifts",
|
||||||
|
"total_hours": "Total Hours",
|
||||||
|
"total_projected": "Total projected",
|
||||||
|
"per_week": "Per week",
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"worker_hours": "Worker hours"
|
||||||
|
},
|
||||||
|
"spending_forecast": "Spending Forecast",
|
||||||
|
"weekly_breakdown": "WEEKLY BREAKDOWN",
|
||||||
|
"breakdown_headings": {
|
||||||
|
"shifts": "Shifts",
|
||||||
|
"hours": "Hours",
|
||||||
|
"avg_shift": "Avg/Shift"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Forecast Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Demand is expected to spike by ",
|
||||||
|
"highlight": "25%",
|
||||||
|
"suffix": " in week 3"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "Projected spend for next month is ",
|
||||||
|
"highlight": "USD 68.4k",
|
||||||
|
"suffix": ""
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Consider increasing budget for ",
|
||||||
|
"highlight": "Holiday Season",
|
||||||
|
"suffix": " coverage"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting Forecast Report (Placeholder)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"performance_report": {
|
||||||
|
"title": "Performance Report",
|
||||||
|
"subtitle": "Key metrics & benchmarks",
|
||||||
|
"overall_score": {
|
||||||
|
"title": "Overall Performance Score",
|
||||||
|
"label": "Excellent"
|
||||||
|
},
|
||||||
|
"kpis_title": "KEY PERFORMANCE INDICATORS",
|
||||||
|
"kpis": {
|
||||||
|
"fill_rate": "Fill Rate",
|
||||||
|
"completion_rate": "Completion Rate",
|
||||||
|
"on_time_rate": "On-Time Rate",
|
||||||
|
"avg_fill_time": "Avg Fill Time",
|
||||||
|
"target_prefix": "Target: ",
|
||||||
|
"met": "✓ Met",
|
||||||
|
"close": "↗ Close"
|
||||||
|
},
|
||||||
|
"additional_metrics_title": "ADDITIONAL METRICS",
|
||||||
|
"additional_metrics": {
|
||||||
|
"total_shifts": "Total Shifts",
|
||||||
|
"no_show_rate": "No-Show Rate",
|
||||||
|
"worker_pool": "Worker Pool",
|
||||||
|
"avg_rating": "Avg Rating"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Performance Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Your fill rate is ",
|
||||||
|
"highlight": "4% above",
|
||||||
|
"suffix": " industry benchmark"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "Worker retention is at ",
|
||||||
|
"highlight": "high",
|
||||||
|
"suffix": " levels this quarter"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "On-time arrival improved by ",
|
||||||
|
"highlight": "12%",
|
||||||
|
"suffix": " since last month"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting Performance Report (Placeholder)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"no_show_report": {
|
||||||
|
"title": "No-Show Report",
|
||||||
|
"subtitle": "Reliability tracking",
|
||||||
|
"metrics": {
|
||||||
|
"no_shows": "No-Shows",
|
||||||
|
"rate": "Rate",
|
||||||
|
"workers": "Workers"
|
||||||
|
},
|
||||||
|
"workers_list_title": "WORKERS WITH NO-SHOWS",
|
||||||
|
"no_show_count": "$count no-show(s)",
|
||||||
|
"latest_incident": "Latest incident",
|
||||||
|
"risks": {
|
||||||
|
"high": "High Risk",
|
||||||
|
"medium": "Medium Risk",
|
||||||
|
"low": "Low Risk"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Reliability Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Your no-show rate of ",
|
||||||
|
"highlight": "1.2%",
|
||||||
|
"suffix": " is below industry average"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "",
|
||||||
|
"highlight": "1 worker",
|
||||||
|
"suffix": " has multiple incidents this month"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Consider implementing ",
|
||||||
|
"highlight": "confirmation reminders",
|
||||||
|
"suffix": " 24hrs before shifts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting No-Show Report (Placeholder)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coverage_report": {
|
||||||
|
"title": "Coverage Report",
|
||||||
|
"subtitle": "Staffing levels & gaps",
|
||||||
|
"metrics": {
|
||||||
|
"avg_coverage": "Avg Coverage",
|
||||||
|
"full": "Full",
|
||||||
|
"needs_help": "Needs Help"
|
||||||
|
},
|
||||||
|
"next_7_days": "NEXT 7 DAYS",
|
||||||
|
"shift_item": {
|
||||||
|
"confirmed_workers": "$confirmed/$needed workers confirmed",
|
||||||
|
"spots_remaining": "$count spots remaining",
|
||||||
|
"fully_staffed": "Fully staffed"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Coverage Insights",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Your average coverage rate is ",
|
||||||
|
"highlight": "96%",
|
||||||
|
"suffix": " - above industry standard"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "",
|
||||||
|
"highlight": "2 days",
|
||||||
|
"suffix": " need immediate attention to reach full coverage"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Weekend coverage is typically ",
|
||||||
|
"highlight": "98%",
|
||||||
|
"suffix": " vs weekday 94%"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exporting Coverage Report (Placeholder)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1140,5 +1140,318 @@
|
|||||||
"availability": {
|
"availability": {
|
||||||
"updated": "Disponibilidad actualizada con éxito"
|
"updated": "Disponibilidad actualizada con éxito"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"client_reports": {
|
||||||
|
"title": "Torre de Control de Personal",
|
||||||
|
"tabs": {
|
||||||
|
"today": "Hoy",
|
||||||
|
"week": "Semana",
|
||||||
|
"month": "Mes",
|
||||||
|
"quarter": "Trimestre"
|
||||||
|
},
|
||||||
|
"metrics": {
|
||||||
|
"total_hrs": {
|
||||||
|
"label": "Total de Horas",
|
||||||
|
"badge": "Este período"
|
||||||
|
},
|
||||||
|
"ot_hours": {
|
||||||
|
"label": "Horas Extra",
|
||||||
|
"badge": "5.1% del total"
|
||||||
|
},
|
||||||
|
"total_spend": {
|
||||||
|
"label": "Gasto Total",
|
||||||
|
"badge": "↓ 8% vs semana pasada"
|
||||||
|
},
|
||||||
|
"fill_rate": {
|
||||||
|
"label": "Tasa de Cobertura",
|
||||||
|
"badge": "↑ 2% de mejora"
|
||||||
|
},
|
||||||
|
"avg_fill_time": {
|
||||||
|
"label": "Tiempo Promedio de Llenado",
|
||||||
|
"badge": "Mejor de la industria"
|
||||||
|
},
|
||||||
|
"no_show_rate": {
|
||||||
|
"label": "Tasa de Faltas",
|
||||||
|
"badge": "Bajo el promedio"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quick_reports": {
|
||||||
|
"title": "Informes Rápidos",
|
||||||
|
"export_all": "Exportar Todo",
|
||||||
|
"two_click_export": "Exportación en 2 clics",
|
||||||
|
"cards": {
|
||||||
|
"daily_ops": "Informe de Ops Diarias",
|
||||||
|
"spend": "Informe de Gastos",
|
||||||
|
"coverage": "Informe de Cobertura",
|
||||||
|
"no_show": "Informe de Faltas",
|
||||||
|
"forecast": "Informe de Previsión",
|
||||||
|
"performance": "Informe de Rendimiento"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ai_insights": {
|
||||||
|
"title": "Perspectivas de IA",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Podrías ahorrar ",
|
||||||
|
"highlight": "USD 1,200/mes",
|
||||||
|
"suffix": " reservando trabajadores con 48h de antelación"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "La demanda del fin de semana es un ",
|
||||||
|
"highlight": "40% superior",
|
||||||
|
"suffix": " - considera programar antes"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Tus 5 mejores trabajadores completan el ",
|
||||||
|
"highlight": "95% de los turnos",
|
||||||
|
"suffix": " - márcalos como preferidos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"daily_ops_report": {
|
||||||
|
"title": "Informe de Ops Diarias",
|
||||||
|
"subtitle": "Seguimiento de turnos en tiempo real",
|
||||||
|
"metrics": {
|
||||||
|
"scheduled": {
|
||||||
|
"label": "Programado",
|
||||||
|
"sub_value": "turnos"
|
||||||
|
},
|
||||||
|
"workers": {
|
||||||
|
"label": "Trabajadores",
|
||||||
|
"sub_value": "confirmados"
|
||||||
|
},
|
||||||
|
"in_progress": {
|
||||||
|
"label": "En Progreso",
|
||||||
|
"sub_value": "activos ahora"
|
||||||
|
},
|
||||||
|
"completed": {
|
||||||
|
"label": "Completado",
|
||||||
|
"sub_value": "hechos hoy"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"all_shifts_title": "TODOS LOS TURNOS",
|
||||||
|
"shift_item": {
|
||||||
|
"time": "Hora",
|
||||||
|
"workers": "Trabajadores",
|
||||||
|
"rate": "Tarifa"
|
||||||
|
},
|
||||||
|
"statuses": {
|
||||||
|
"processing": "Procesando",
|
||||||
|
"filling": "Llenando",
|
||||||
|
"confirmed": "Confirmado",
|
||||||
|
"completed": "Completado"
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Operaciones Diarias (Marcador de posición)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"spend_report": {
|
||||||
|
"title": "Informe de Gastos",
|
||||||
|
"subtitle": "Análisis y desglose de costos",
|
||||||
|
"summary": {
|
||||||
|
"total_spend": "Gasto Total",
|
||||||
|
"avg_daily": "Promedio Diario",
|
||||||
|
"this_week": "Esta semana",
|
||||||
|
"per_day": "Por día"
|
||||||
|
},
|
||||||
|
"chart_title": "Tendencia de Gasto Diario",
|
||||||
|
"charts": {
|
||||||
|
"mon": "Lun",
|
||||||
|
"tue": "Mar",
|
||||||
|
"wed": "Mié",
|
||||||
|
"thu": "Jue",
|
||||||
|
"fri": "Vie",
|
||||||
|
"sat": "Sáb",
|
||||||
|
"sun": "Dom"
|
||||||
|
},
|
||||||
|
"spend_by_industry": "Gasto por Industria",
|
||||||
|
"industries": {
|
||||||
|
"hospitality": "Hostelería",
|
||||||
|
"events": "Eventos",
|
||||||
|
"retail": "Venta minorista"
|
||||||
|
},
|
||||||
|
"percent_total": "$percent% del total",
|
||||||
|
"insights": {
|
||||||
|
"title": "Perspectivas de Costos",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Tu gasto es un ",
|
||||||
|
"highlight": "8% menor",
|
||||||
|
"suffix": " que la semana pasada"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "El ",
|
||||||
|
"highlight": "Viernes",
|
||||||
|
"suffix": " tuvo el mayor gasto (USD 4.1k)"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "La hostelería representa el ",
|
||||||
|
"highlight": "48%",
|
||||||
|
"suffix": " de los costos totales"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Gastos (Marcador de posición)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"forecast_report": {
|
||||||
|
"title": "Informe de Previsión",
|
||||||
|
"subtitle": "Proyección para las próximas 4 semanas",
|
||||||
|
"summary": {
|
||||||
|
"four_week": "Previsión de 4 Semanas",
|
||||||
|
"avg_weekly": "Promedio Semanal",
|
||||||
|
"total_shifts": "Total de Turnos",
|
||||||
|
"total_hours": "Total de Horas",
|
||||||
|
"total_projected": "Total proyectado",
|
||||||
|
"per_week": "Por semana",
|
||||||
|
"scheduled": "Programado",
|
||||||
|
"worker_hours": "Horas de trabajadores"
|
||||||
|
},
|
||||||
|
"spending_forecast": "Previsión de Gastos",
|
||||||
|
"weekly_breakdown": "DESGLOSE SEMANAL",
|
||||||
|
"breakdown_headings": {
|
||||||
|
"shifts": "Turnos",
|
||||||
|
"hours": "Horas",
|
||||||
|
"avg_shift": "Prom/Turno"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Perspectivas de Previsión",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Se espera que la demanda aumente un ",
|
||||||
|
"highlight": "25%",
|
||||||
|
"suffix": " en la semana 3"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "El gasto proyectado para el próximo mes es de ",
|
||||||
|
"highlight": "USD 68.4k",
|
||||||
|
"suffix": ""
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Considera aumentar el presupuesto para la cobertura de ",
|
||||||
|
"highlight": "Temporada de Vacaciones",
|
||||||
|
"suffix": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Previsión (Marcador de posición)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"performance_report": {
|
||||||
|
"title": "Informe de Rendimiento",
|
||||||
|
"subtitle": "Métricas clave y comparativas",
|
||||||
|
"overall_score": {
|
||||||
|
"title": "Puntuación de Rendimiento General",
|
||||||
|
"label": "Excelente"
|
||||||
|
},
|
||||||
|
"kpis_title": "INDICADORES CLAVE DE RENDIMIENTO (KPI)",
|
||||||
|
"kpis": {
|
||||||
|
"fill_rate": "Tasa de Llenado",
|
||||||
|
"completion_rate": "Tasa de Finalización",
|
||||||
|
"on_time_rate": "Tasa de Puntualidad",
|
||||||
|
"avg_fill_time": "Tiempo Promedio de Llenado",
|
||||||
|
"target_prefix": "Objetivo: ",
|
||||||
|
"met": "✓ Cumplido",
|
||||||
|
"close": "↗ Cerca"
|
||||||
|
},
|
||||||
|
"additional_metrics_title": "MÉTRICAS ADICIONALES",
|
||||||
|
"additional_metrics": {
|
||||||
|
"total_shifts": "Total de Turnos",
|
||||||
|
"no_show_rate": "Tasa de Faltas",
|
||||||
|
"worker_pool": "Grupo de Trabajadores",
|
||||||
|
"avg_rating": "Calificación Promedio"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Perspectivas de Rendimiento",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Tu tasa de llenado es un ",
|
||||||
|
"highlight": "4% superior",
|
||||||
|
"suffix": " al promedio de la industria"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "La retención de trabajadores está en niveles ",
|
||||||
|
"highlight": "altos",
|
||||||
|
"suffix": " este trimestre"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "La llegada puntual mejoró un ",
|
||||||
|
"highlight": "12%",
|
||||||
|
"suffix": " desde el mes pasado"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Rendimiento (Marcador de posición)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"no_show_report": {
|
||||||
|
"title": "Informe de Faltas",
|
||||||
|
"subtitle": "Seguimiento de confiabilidad",
|
||||||
|
"metrics": {
|
||||||
|
"no_shows": "Faltas",
|
||||||
|
"rate": "Tasa",
|
||||||
|
"workers": "Trabajadores"
|
||||||
|
},
|
||||||
|
"workers_list_title": "TRABAJADORES CON FALTAS",
|
||||||
|
"no_show_count": "$count falta(s)",
|
||||||
|
"latest_incident": "Último incidente",
|
||||||
|
"risks": {
|
||||||
|
"high": "Riesgo Alto",
|
||||||
|
"medium": "Riesgo Medio",
|
||||||
|
"low": "Riesgo Bajo"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Perspectivas de Confiabilidad",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Tu tasa de faltas del ",
|
||||||
|
"highlight": "1.2%",
|
||||||
|
"suffix": " está por debajo del promedio de la industria"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "",
|
||||||
|
"highlight": "1 trabajador",
|
||||||
|
"suffix": " tiene múltiples incidentes este mes"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "Considera implementar ",
|
||||||
|
"highlight": "recordatorios de confirmación",
|
||||||
|
"suffix": " 24h antes de los turnos"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Faltas (Marcador de posición)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"coverage_report": {
|
||||||
|
"title": "Informe de Cobertura",
|
||||||
|
"subtitle": "Niveles de personal y brechas",
|
||||||
|
"metrics": {
|
||||||
|
"avg_coverage": "Cobertura Promedio",
|
||||||
|
"full": "Completa",
|
||||||
|
"needs_help": "Necesita Ayuda"
|
||||||
|
},
|
||||||
|
"next_7_days": "PRÓXIMOS 7 DÍAS",
|
||||||
|
"shift_item": {
|
||||||
|
"confirmed_workers": "$confirmed/$needed trabajadores confirmados",
|
||||||
|
"spots_remaining": "$count puestos restantes",
|
||||||
|
"fully_staffed": "Totalmente cubierto"
|
||||||
|
},
|
||||||
|
"insights": {
|
||||||
|
"title": "Perspectivas de Cobertura",
|
||||||
|
"insight_1": {
|
||||||
|
"prefix": "Tu tasa de cobertura promedio es del ",
|
||||||
|
"highlight": "96%",
|
||||||
|
"suffix": " - por encima del estándar de la industria"
|
||||||
|
},
|
||||||
|
"insight_2": {
|
||||||
|
"prefix": "",
|
||||||
|
"highlight": "2 días",
|
||||||
|
"suffix": " necesitan atención inmediata para alcanzar la cobertura completa"
|
||||||
|
},
|
||||||
|
"insight_3": {
|
||||||
|
"prefix": "La cobertura de fin de semana es típicamente del ",
|
||||||
|
"highlight": "98%",
|
||||||
|
"suffix": " vs 94% en días laborables"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"placeholders": {
|
||||||
|
"export_message": "Exportando Informe de Cobertura (Marcador de posición)"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,6 +184,9 @@ class UiIcons {
|
|||||||
/// Trending down icon for insights
|
/// Trending down icon for insights
|
||||||
static const IconData trendingDown = _IconLib.trendingDown;
|
static const IconData trendingDown = _IconLib.trendingDown;
|
||||||
|
|
||||||
|
/// Trending up icon for insights
|
||||||
|
static const IconData trendingUp = _IconLib.trendingUp;
|
||||||
|
|
||||||
/// Target icon for metrics
|
/// Target icon for metrics
|
||||||
static const IconData target = _IconLib.target;
|
static const IconData target = _IconLib.target;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:billing/billing.dart';
|
import 'package:billing/billing.dart';
|
||||||
|
import 'package:client_reports/client_reports.dart';
|
||||||
import 'package:client_home/client_home.dart';
|
import 'package:client_home/client_home.dart';
|
||||||
import 'package:client_coverage/client_coverage.dart';
|
import 'package:client_coverage/client_coverage.dart';
|
||||||
import 'package:flutter/material.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/blocs/client_main_cubit.dart';
|
||||||
import 'presentation/pages/client_main_page.dart';
|
import 'presentation/pages/client_main_page.dart';
|
||||||
import 'presentation/pages/placeholder_page.dart';
|
|
||||||
|
|
||||||
class ClientMainModule extends Module {
|
class ClientMainModule extends Module {
|
||||||
@override
|
@override
|
||||||
@@ -38,10 +38,9 @@ class ClientMainModule extends Module {
|
|||||||
ClientPaths.childRoute(ClientPaths.main, ClientPaths.orders),
|
ClientPaths.childRoute(ClientPaths.main, ClientPaths.orders),
|
||||||
module: ViewOrdersModule(),
|
module: ViewOrdersModule(),
|
||||||
),
|
),
|
||||||
ChildRoute<dynamic>(
|
ModuleRoute<dynamic>(
|
||||||
ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports),
|
ClientPaths.childRoute(ClientPaths.main, ClientPaths.reports),
|
||||||
child: (BuildContext context) =>
|
module: ReportsModule(),
|
||||||
const PlaceholderPage(title: 'Reports'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class ClientMainBottomBar extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final t = Translations.of(context);
|
||||||
// Client App colors from design system
|
// Client App colors from design system
|
||||||
const Color activeColor = UiColors.textPrimary;
|
const Color activeColor = UiColors.textPrimary;
|
||||||
const Color inactiveColor = UiColors.textInactive;
|
const Color inactiveColor = UiColors.textInactive;
|
||||||
@@ -99,6 +100,13 @@ class ClientMainBottomBar extends StatelessWidget {
|
|||||||
activeColor: activeColor,
|
activeColor: activeColor,
|
||||||
inactiveColor: inactiveColor,
|
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
|
path: ../home
|
||||||
client_coverage:
|
client_coverage:
|
||||||
path: ../client_coverage
|
path: ../client_coverage
|
||||||
|
client_reports:
|
||||||
|
path: ../reports
|
||||||
view_orders:
|
view_orders:
|
||||||
path: ../view_orders
|
path: ../view_orders
|
||||||
billing:
|
billing:
|
||||||
|
|||||||
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,467 @@
|
|||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
import '../../domain/entities/daily_ops_report.dart';
|
||||||
|
import '../../domain/entities/spend_report.dart';
|
||||||
|
import '../../domain/entities/coverage_report.dart';
|
||||||
|
import '../../domain/entities/forecast_report.dart';
|
||||||
|
import '../../domain/entities/performance_report.dart';
|
||||||
|
import '../../domain/entities/no_show_report.dart';
|
||||||
|
import '../../domain/entities/reports_summary.dart';
|
||||||
|
import '../../domain/repositories/reports_repository.dart';
|
||||||
|
|
||||||
|
class ReportsRepositoryImpl implements ReportsRepository {
|
||||||
|
final DataConnectService _service;
|
||||||
|
|
||||||
|
ReportsRepositoryImpl({DataConnectService? service})
|
||||||
|
: _service = service ?? DataConnectService.instance;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<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 = {};
|
||||||
|
|
||||||
|
for (final inv in invoices) {
|
||||||
|
final amount = (inv.amount ?? 0.0).toDouble();
|
||||||
|
totalSpend += amount;
|
||||||
|
|
||||||
|
final statusStr = inv.status.stringValue;
|
||||||
|
if (statusStr == 'PAID') {
|
||||||
|
paidInvoices++;
|
||||||
|
} else if (statusStr == 'PENDING') {
|
||||||
|
pendingInvoices++;
|
||||||
|
} else if (statusStr == 'OVERDUE') {
|
||||||
|
overdueInvoices++;
|
||||||
|
}
|
||||||
|
|
||||||
|
final issueDateTime = inv.issueDate.toDateTime();
|
||||||
|
spendInvoices.add(SpendInvoice(
|
||||||
|
id: inv.id,
|
||||||
|
invoiceNumber: inv.invoiceNumber ?? '',
|
||||||
|
issueDate: issueDateTime,
|
||||||
|
amount: amount,
|
||||||
|
status: statusStr,
|
||||||
|
vendorName: inv.vendor?.companyName ?? 'Unknown',
|
||||||
|
));
|
||||||
|
|
||||||
|
// Chart data aggregation
|
||||||
|
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
|
||||||
|
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<SpendChartPoint> chartData = dailyAggregates.entries
|
||||||
|
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => a.date.compareTo(b.date));
|
||||||
|
|
||||||
|
return SpendReport(
|
||||||
|
totalSpend: totalSpend,
|
||||||
|
averageCost: invoices.isEmpty ? 0 : totalSpend / invoices.length,
|
||||||
|
paidInvoices: paidInvoices,
|
||||||
|
pendingInvoices: pendingInvoices,
|
||||||
|
overdueInvoices: overdueInvoices,
|
||||||
|
invoices: spendInvoices,
|
||||||
|
chartData: chartData,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<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,60 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
const DailyOpsShift({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.location,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.workersNeeded,
|
||||||
|
required this.filled,
|
||||||
|
required this.status,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
location,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
workersNeeded,
|
||||||
|
filled,
|
||||||
|
status,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -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,63 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
class SpendReport extends Equatable {
|
||||||
|
final double totalSpend;
|
||||||
|
final double averageCost;
|
||||||
|
final int paidInvoices;
|
||||||
|
final int pendingInvoices;
|
||||||
|
final int overdueInvoices;
|
||||||
|
final List<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,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
totalSpend,
|
||||||
|
averageCost,
|
||||||
|
paidInvoices,
|
||||||
|
pendingInvoices,
|
||||||
|
overdueInvoices,
|
||||||
|
invoices,
|
||||||
|
chartData,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpendInvoice extends Equatable {
|
||||||
|
final String id;
|
||||||
|
final String invoiceNumber;
|
||||||
|
final DateTime issueDate;
|
||||||
|
final double amount;
|
||||||
|
final String status;
|
||||||
|
final String vendorName;
|
||||||
|
|
||||||
|
const SpendInvoice({
|
||||||
|
required this.id,
|
||||||
|
required this.invoiceNumber,
|
||||||
|
required this.issueDate,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
required this.vendorName,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [id, invoiceNumber, issueDate, amount, status, vendorName];
|
||||||
|
}
|
||||||
|
|
||||||
|
class SpendChartPoint extends Equatable {
|
||||||
|
final DateTime date;
|
||||||
|
final double amount;
|
||||||
|
|
||||||
|
const SpendChartPoint({required this.date, required this.amount});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<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,471 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/coverage/coverage_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/coverage/coverage_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class CoverageReportPage extends StatefulWidget {
|
||||||
|
const CoverageReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 60,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 32,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
UiColors.primary,
|
||||||
|
UiColors.buttonPrimaryHover
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.coverage_report
|
||||||
|
.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.coverage_report
|
||||||
|
.subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.white.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.t.client_reports.coverage_report
|
||||||
|
.placeholders.export_message,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.download,
|
||||||
|
size: 14,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.quick_reports
|
||||||
|
.export_all
|
||||||
|
.split(' ')
|
||||||
|
.first,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: UiColors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_CoverageOverviewCard(
|
||||||
|
percentage: report.overallCoverage,
|
||||||
|
needed: report.totalNeeded,
|
||||||
|
filled: report.totalFilled,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'DAILY BREAKDOWN',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (report.dailyCoverage.isEmpty)
|
||||||
|
const Center(child: Text('No shifts scheduled'))
|
||||||
|
else
|
||||||
|
...report.dailyCoverage.map((day) => _DailyCoverageItem(
|
||||||
|
date: DateFormat('EEEE, MMM dd').format(day.date),
|
||||||
|
percentage: day.percentage,
|
||||||
|
details: '${day.filled}/${day.needed} workers filled',
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CoverageOverviewCard extends StatelessWidget {
|
||||||
|
final double percentage;
|
||||||
|
final int needed;
|
||||||
|
final int filled;
|
||||||
|
|
||||||
|
const _CoverageOverviewCard({
|
||||||
|
required this.percentage,
|
||||||
|
required this.needed,
|
||||||
|
required this.filled,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = percentage >= 90
|
||||||
|
? UiColors.success
|
||||||
|
: percentage >= 70
|
||||||
|
? UiColors.textWarning
|
||||||
|
: UiColors.error;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Overall Coverage',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${percentage.toStringAsFixed(1)}%',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_CircularProgress(
|
||||||
|
percentage: percentage / 100,
|
||||||
|
color: color,
|
||||||
|
size: 70,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Divider(height: 1, color: UiColors.bgSecondary),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricItem(
|
||||||
|
label: 'Total Needed',
|
||||||
|
value: needed.toString(),
|
||||||
|
icon: UiIcons.users,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricItem(
|
||||||
|
label: 'Total Filled',
|
||||||
|
value: filled.toString(),
|
||||||
|
icon: UiIcons.checkCircle,
|
||||||
|
color: UiColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricItem extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _MetricItem({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 16, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DailyCoverageItem extends StatelessWidget {
|
||||||
|
final String date;
|
||||||
|
final double percentage;
|
||||||
|
final String details;
|
||||||
|
|
||||||
|
const _DailyCoverageItem({
|
||||||
|
required this.date,
|
||||||
|
required this.percentage,
|
||||||
|
required this.details,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = percentage >= 95
|
||||||
|
? UiColors.success
|
||||||
|
: percentage >= 80
|
||||||
|
? UiColors.textWarning
|
||||||
|
: UiColors.error;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${percentage.toStringAsFixed(0)}%',
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 13,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: percentage / 100,
|
||||||
|
backgroundColor: UiColors.bgSecondary,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
minHeight: 6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
details,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CircularProgress extends StatelessWidget {
|
||||||
|
final double percentage;
|
||||||
|
final Color color;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _CircularProgress({
|
||||||
|
required this.percentage,
|
||||||
|
required this.color,
|
||||||
|
required this.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: percentage,
|
||||||
|
strokeWidth: 8,
|
||||||
|
backgroundColor: UiColors.bgSecondary,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
percentage >= 1.0 ? UiIcons.checkCircle : UiIcons.trendingUp,
|
||||||
|
color: color,
|
||||||
|
size: size * 0.4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/daily_ops/daily_ops_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class DailyOpsReportPage extends StatefulWidget {
|
||||||
|
const DailyOpsReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DailyOpsReportPage> createState() => _DailyOpsReportPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DailyOpsReportPageState extends State<DailyOpsReportPage> {
|
||||||
|
final DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
|
@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: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.daily_ops_report
|
||||||
|
.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.daily_ops_report
|
||||||
|
.subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.white.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.t.client_reports.daily_ops_report
|
||||||
|
.placeholders.export_message,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.download,
|
||||||
|
size: 14,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.quick_reports
|
||||||
|
.export_all
|
||||||
|
.split(' ')
|
||||||
|
.first,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: UiColors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Date Selector
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 4,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.calendar,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
DateFormat('MMM dd, yyyy')
|
||||||
|
.format(_selectedDate),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
UiIcons.chevronDown,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Stats Grid
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.4,
|
||||||
|
children: [
|
||||||
|
_OpsStatCard(
|
||||||
|
label: context.t.client_reports
|
||||||
|
.daily_ops_report.metrics.scheduled.label,
|
||||||
|
value: report.scheduledShifts.toString(),
|
||||||
|
subValue: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.scheduled
|
||||||
|
.sub_value,
|
||||||
|
color: UiColors.primary,
|
||||||
|
icon: UiIcons.calendar,
|
||||||
|
),
|
||||||
|
_OpsStatCard(
|
||||||
|
label: context.t.client_reports
|
||||||
|
.daily_ops_report.metrics.workers.label,
|
||||||
|
value: report.workersConfirmed.toString(),
|
||||||
|
subValue: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.workers
|
||||||
|
.sub_value,
|
||||||
|
color: UiColors.primary,
|
||||||
|
icon: UiIcons.users,
|
||||||
|
),
|
||||||
|
_OpsStatCard(
|
||||||
|
label: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.in_progress
|
||||||
|
.label,
|
||||||
|
value: report.inProgressShifts.toString(),
|
||||||
|
subValue: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.in_progress
|
||||||
|
.sub_value,
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
icon: UiIcons.clock,
|
||||||
|
),
|
||||||
|
_OpsStatCard(
|
||||||
|
label: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.completed
|
||||||
|
.label,
|
||||||
|
value: report.completedShifts.toString(),
|
||||||
|
subValue: context
|
||||||
|
.t
|
||||||
|
.client_reports
|
||||||
|
.daily_ops_report
|
||||||
|
.metrics
|
||||||
|
.completed
|
||||||
|
.sub_value,
|
||||||
|
color: UiColors.success,
|
||||||
|
icon: UiIcons.checkCircle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.daily_ops_report
|
||||||
|
.all_shifts_title
|
||||||
|
.toUpperCase(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Shift List
|
||||||
|
if (report.shifts.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(
|
||||||
|
child: Text('No shifts scheduled for today'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...report.shifts.map((shift) => _ShiftListItem(
|
||||||
|
title: shift.title,
|
||||||
|
location: shift.location,
|
||||||
|
time:
|
||||||
|
'${DateFormat('hh:mm a').format(shift.startTime)} - ${DateFormat('hh:mm a').format(shift.endTime)}',
|
||||||
|
workers:
|
||||||
|
'${shift.filled}/${shift.workersNeeded}',
|
||||||
|
status: shift.status.replaceAll('_', ' '),
|
||||||
|
statusColor: shift.status == 'COMPLETED'
|
||||||
|
? UiColors.success
|
||||||
|
: shift.status == 'IN_PROGRESS'
|
||||||
|
? UiColors.textWarning
|
||||||
|
: UiColors.primary,
|
||||||
|
)),
|
||||||
|
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OpsStatCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String subValue;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _OpsStatCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.subValue,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
border: Border(left: BorderSide(color: color, width: 4)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
subValue,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShiftListItem extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String location;
|
||||||
|
final String time;
|
||||||
|
final String workers;
|
||||||
|
final String status;
|
||||||
|
final Color statusColor;
|
||||||
|
|
||||||
|
const _ShiftListItem({
|
||||||
|
required this.title,
|
||||||
|
required this.location,
|
||||||
|
required this.time,
|
||||||
|
required this.workers,
|
||||||
|
required this.status,
|
||||||
|
required this.statusColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.mapPin,
|
||||||
|
size: 10,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
location,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(left: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Divider(height: 1, color: UiColors.bgSecondary),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
_infoItem(
|
||||||
|
context,
|
||||||
|
UiIcons.clock,
|
||||||
|
context.t.client_reports.daily_ops_report.shift_item.time,
|
||||||
|
time),
|
||||||
|
_infoItem(
|
||||||
|
context,
|
||||||
|
UiIcons.users,
|
||||||
|
context.t.client_reports.daily_ops_report.shift_item.workers,
|
||||||
|
workers),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _infoItem(
|
||||||
|
BuildContext context, IconData icon, String label, String value) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 12, color: UiColors.textSecondary),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 10, color: UiColors.pinInactive),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textDescription,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.forecast_report.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.forecast_report
|
||||||
|
.subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.white.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Summary Cards
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _ForecastSummaryCard(
|
||||||
|
label: 'Projected Spend',
|
||||||
|
value: NumberFormat.currency(symbol: r'$')
|
||||||
|
.format(report.projectedSpend),
|
||||||
|
icon: UiIcons.dollar,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _ForecastSummaryCard(
|
||||||
|
label: 'Workers Needed',
|
||||||
|
value: report.projectedWorkers.toString(),
|
||||||
|
icon: UiIcons.users,
|
||||||
|
color: UiColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Chart
|
||||||
|
Container(
|
||||||
|
height: 300,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Spending Forecast',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Expanded(
|
||||||
|
child: _ForecastChart(
|
||||||
|
points: report.chartData,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Daily List
|
||||||
|
Text(
|
||||||
|
'DAILY PROJECTIONS',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (report.chartData.isEmpty)
|
||||||
|
const Center(child: Text('No projections available'))
|
||||||
|
else
|
||||||
|
...report.chartData.map((point) => _ForecastListItem(
|
||||||
|
date: DateFormat('EEE, MMM dd').format(point.date),
|
||||||
|
cost: NumberFormat.currency(symbol: r'$')
|
||||||
|
.format(point.projectedCost),
|
||||||
|
workers: point.workersNeeded.toString(),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ForecastSummaryCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _ForecastSummaryCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 16, color: color),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ForecastChart extends StatelessWidget {
|
||||||
|
final List<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('$workers workers needed', style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
class NoShowReportPage extends StatefulWidget {
|
||||||
|
const NoShowReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 60,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 32,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [UiColors.error, UiColors.tagError],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.no_show_report.title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.no_show_report.subtitle,
|
||||||
|
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Summary
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.06), blurRadius: 10)],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||||
|
children: [
|
||||||
|
_SummaryItem(
|
||||||
|
label: 'Total No-Shows',
|
||||||
|
value: report.totalNoShows.toString(),
|
||||||
|
color: UiColors.error,
|
||||||
|
),
|
||||||
|
_SummaryItem(
|
||||||
|
label: 'No-Show Rate',
|
||||||
|
value: '${report.noShowRate.toStringAsFixed(1)}%',
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Flagged Workers
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
context.t.client_reports.no_show_report.workers_list_title,
|
||||||
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold, color: UiColors.textSecondary, letterSpacing: 1.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (report.flaggedWorkers.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(40.0),
|
||||||
|
child: Text('No workers flagged for no-shows'),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...report.flaggedWorkers.map((worker) => _WorkerListItem(worker: worker)),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryItem extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _SummaryItem({required this.label, required this.value, required this.color});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Text(value, style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: color)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 12, color: UiColors.textSecondary)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WorkerListItem extends StatelessWidget {
|
||||||
|
final dynamic worker;
|
||||||
|
|
||||||
|
const _WorkerListItem({required this.worker});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: const BoxDecoration(color: UiColors.bgSecondary, shape: BoxShape.circle),
|
||||||
|
child: const Icon(UiIcons.user, color: UiColors.textSecondary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(worker.fullName, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
Text('${worker.noShowCount} no-shows', style: const TextStyle(fontSize: 11, color: UiColors.error)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text('${(worker.reliabilityScore * 100).toStringAsFixed(0)}%', style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const Text('Reliability', style: TextStyle(fontSize: 10, color: UiColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/performance/performance_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/performance/performance_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/performance/performance_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
|
||||||
|
class PerformanceReportPage extends StatefulWidget {
|
||||||
|
const PerformanceReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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;
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 60,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: 32,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [UiColors.primary, UiColors.buttonPrimaryHover],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(UiIcons.arrowLeft, color: UiColors.white, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.performance_report.title,
|
||||||
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: UiColors.white),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.performance_report.subtitle,
|
||||||
|
style: TextStyle(fontSize: 12, color: UiColors.white.withOpacity(0.7)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Main Stats
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
children: [
|
||||||
|
_StatTile(
|
||||||
|
label: 'Fill Rate',
|
||||||
|
value: '${report.fillRate.toStringAsFixed(1)}%',
|
||||||
|
color: UiColors.primary,
|
||||||
|
icon: UiIcons.users,
|
||||||
|
),
|
||||||
|
_StatTile(
|
||||||
|
label: 'Completion',
|
||||||
|
value: '${report.completionRate.toStringAsFixed(1)}%',
|
||||||
|
color: UiColors.success,
|
||||||
|
icon: UiIcons.checkCircle,
|
||||||
|
),
|
||||||
|
_StatTile(
|
||||||
|
label: 'On-Time',
|
||||||
|
value: '${report.onTimeRate.toStringAsFixed(1)}%',
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
icon: UiIcons.clock,
|
||||||
|
),
|
||||||
|
_StatTile(
|
||||||
|
label: 'Avg Fill Time',
|
||||||
|
value: '${report.avgFillTimeHours.toStringAsFixed(1)}h',
|
||||||
|
color: UiColors.primary,
|
||||||
|
icon: UiIcons.trendingUp,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// KPI List
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 10)],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Key Performance Indicators',
|
||||||
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
...report.keyPerformanceIndicators.map((kpi) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(kpi.label, style: const TextStyle(color: UiColors.textSecondary)),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(kpi.value, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Icon(
|
||||||
|
kpi.trend >= 0 ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||||
|
size: 14,
|
||||||
|
color: kpi.trend >= 0 ? UiColors.success : UiColors.error,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatTile extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _StatTile({required this.label, required this.value, required this.color, required this.icon});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [BoxShadow(color: UiColors.black.withOpacity(0.04), blurRadius: 5)],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(value, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/summary/reports_summary_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class ReportsPage extends StatefulWidget {
|
||||||
|
const ReportsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<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),
|
||||||
|
|
||||||
|
// AI Insights
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.tagInProgress.withOpacity(0.3),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 2,
|
||||||
|
offset: const Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'💡 ${context.t.client_reports.ai_insights.title}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_InsightRow(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_1.prefix),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_1.highlight,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_1.suffix,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_InsightRow(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_2.prefix),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_2.highlight,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_2.suffix,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_InsightRow(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_3.prefix,
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_3.highlight,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: context.t.client_reports.ai_insights
|
||||||
|
.insight_3.suffix),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MetricCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String badgeText;
|
||||||
|
final Color badgeColor;
|
||||||
|
final Color badgeTextColor;
|
||||||
|
final Color iconColor;
|
||||||
|
|
||||||
|
const _MetricCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.badgeText,
|
||||||
|
required this.badgeColor,
|
||||||
|
required this.badgeTextColor,
|
||||||
|
required this.iconColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 4,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 16, color: iconColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: badgeColor,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
badgeText,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: badgeTextColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ReportCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String name;
|
||||||
|
final Color iconBgColor;
|
||||||
|
final Color iconColor;
|
||||||
|
final String route;
|
||||||
|
|
||||||
|
const _ReportCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.name,
|
||||||
|
required this.iconBgColor,
|
||||||
|
required this.iconColor,
|
||||||
|
required this.route,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => Modular.to.pushNamed(route),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconBgColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 20, color: iconColor),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.download,
|
||||||
|
size: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.quick_reports.two_click_export,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightRow extends StatelessWidget {
|
||||||
|
final List<InlineSpan> children;
|
||||||
|
|
||||||
|
const _InsightRow({required this.children});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'• ',
|
||||||
|
style: TextStyle(color: UiColors.textSecondary, fontSize: 14),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
children: children,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
import 'package:client_reports/src/presentation/blocs/spend/spend_bloc.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/spend/spend_event.dart';
|
||||||
|
import 'package:client_reports/src/presentation/blocs/spend/spend_state.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
class SpendReportPage extends StatefulWidget {
|
||||||
|
const SpendReportPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpendReportPage> createState() => _SpendReportPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpendReportPageState extends State<SpendReportPage> {
|
||||||
|
DateTime _startDate = DateTime.now().subtract(const Duration(days: 6));
|
||||||
|
DateTime _endDate = DateTime.now();
|
||||||
|
|
||||||
|
@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: 32,
|
||||||
|
),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [UiColors.success, UiColors.tagSuccess],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white.withOpacity(0.2),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
UiIcons.arrowLeft,
|
||||||
|
color: UiColors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.spend_report.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.spend_report
|
||||||
|
.subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.white.withOpacity(0.7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
context.t.client_reports.spend_report
|
||||||
|
.placeholders.export_message,
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
UiIcons.download,
|
||||||
|
size: 14,
|
||||||
|
color: UiColors.success,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.quick_reports
|
||||||
|
.export_all
|
||||||
|
.split(' ')
|
||||||
|
.first,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: UiColors.success,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Transform.translate(
|
||||||
|
offset: const Offset(0, -16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Summary Cards
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SpendSummaryCard(
|
||||||
|
label: context.t.client_reports.spend_report
|
||||||
|
.summary.total_spend,
|
||||||
|
value: NumberFormat.currency(symbol: r'$')
|
||||||
|
.format(report.totalSpend),
|
||||||
|
change: '', // Can be calculated if needed
|
||||||
|
period: context.t.client_reports
|
||||||
|
.spend_report.summary.this_week,
|
||||||
|
color: UiColors.textSuccess,
|
||||||
|
icon: UiIcons.dollar,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _SpendSummaryCard(
|
||||||
|
label: context.t.client_reports.spend_report
|
||||||
|
.summary.avg_daily,
|
||||||
|
value: NumberFormat.currency(symbol: r'$')
|
||||||
|
.format(report.averageCost),
|
||||||
|
change: '',
|
||||||
|
period: context.t.client_reports
|
||||||
|
.spend_report.summary.per_day,
|
||||||
|
color: UiColors.primary,
|
||||||
|
icon: UiIcons.chart,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// Chart Section
|
||||||
|
Container(
|
||||||
|
height: 300,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment:
|
||||||
|
MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.spend_report
|
||||||
|
.chart_title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgSecondary,
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
context.t.client_reports.tabs.week,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(
|
||||||
|
UiIcons.chevronDown,
|
||||||
|
size: 10,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Expanded(
|
||||||
|
child: _SpendBarChart(
|
||||||
|
chartData: report.chartData),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
// Status Distribution
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _StatusMiniCard(
|
||||||
|
label: 'Paid',
|
||||||
|
value: report.paidInvoices.toString(),
|
||||||
|
color: UiColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _StatusMiniCard(
|
||||||
|
label: 'Pending',
|
||||||
|
value: report.pendingInvoices.toString(),
|
||||||
|
color: UiColors.textWarning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _StatusMiniCard(
|
||||||
|
label: 'Overdue',
|
||||||
|
value: report.overdueInvoices.toString(),
|
||||||
|
color: UiColors.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
Text(
|
||||||
|
'RECENT INVOICES',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Invoice List
|
||||||
|
if (report.invoices.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 40),
|
||||||
|
child: Center(
|
||||||
|
child:
|
||||||
|
Text('No invoices found for this period'),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
...report.invoices.map((inv) => _InvoiceListItem(
|
||||||
|
invoice: inv.invoiceNumber,
|
||||||
|
vendor: inv.vendorName,
|
||||||
|
date: DateFormat('MMM dd, yyyy')
|
||||||
|
.format(inv.issueDate),
|
||||||
|
amount: NumberFormat.currency(symbol: r'$')
|
||||||
|
.format(inv.amount),
|
||||||
|
status: inv.status,
|
||||||
|
statusColor: inv.status == 'PAID'
|
||||||
|
? UiColors.success
|
||||||
|
: inv.status == 'PENDING'
|
||||||
|
? UiColors.textWarning
|
||||||
|
: UiColors.error,
|
||||||
|
)),
|
||||||
|
|
||||||
|
const SizedBox(height: 100),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpendBarChart extends StatelessWidget {
|
||||||
|
final List<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,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
if (value.toInt() >= chartData.length) return const SizedBox();
|
||||||
|
final date = chartData[value.toInt()].date;
|
||||||
|
return SideTitleWidget(
|
||||||
|
axisSide: meta.axisSide,
|
||||||
|
space: 4,
|
||||||
|
child: Text(
|
||||||
|
DateFormat('E').format(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
gridData: const FlGridData(show: false),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barGroups: List.generate(
|
||||||
|
chartData.length,
|
||||||
|
(index) => BarChartGroupData(
|
||||||
|
x: index,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: chartData[index].amount,
|
||||||
|
color: UiColors.success,
|
||||||
|
width: 16,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpendSummaryCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final String change;
|
||||||
|
final String period;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _SpendSummaryCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.change,
|
||||||
|
required this.period,
|
||||||
|
required this.color,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.1),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 16, color: color),
|
||||||
|
),
|
||||||
|
if (change.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
change,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: change.startsWith('+')
|
||||||
|
? UiColors.error
|
||||||
|
: UiColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
period,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: UiColors.textDescription,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StatusMiniCard extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _StatusMiniCard({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withOpacity(0.05),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: color.withOpacity(0.1)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceListItem extends StatelessWidget {
|
||||||
|
final String invoice;
|
||||||
|
final String vendor;
|
||||||
|
final String date;
|
||||||
|
final String amount;
|
||||||
|
final String status;
|
||||||
|
final Color statusColor;
|
||||||
|
|
||||||
|
const _InvoiceListItem({
|
||||||
|
required this.invoice,
|
||||||
|
required this.vendor,
|
||||||
|
required this.date,
|
||||||
|
required this.amount,
|
||||||
|
required this.status,
|
||||||
|
required this.statusColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 10,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(UiIcons.file, size: 20, color: statusColor),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
invoice,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
vendor,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: UiColors.textDescription,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
amount,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontSize: 14,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: statusColor.withOpacity(0.1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status.toUpperCase(),
|
||||||
|
style: TextStyle(
|
||||||
|
color: statusColor,
|
||||||
|
fontSize: 9,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -215,14 +215,57 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
|
|||||||
staffRecord = staffResponse.data.staffs.first;
|
staffRecord = staffResponse.data.staffs.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
final String email = user?.email ?? '';
|
return _setSession(firebaseUser.uid, user, staffRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> restoreSession() async {
|
||||||
|
final User? firebaseUser = await _service.auth.authStateChanges().first;
|
||||||
|
if (firebaseUser == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reuse the same logic as verifyOtp to fetch user/staff and set session
|
||||||
|
// We can't reuse verifyOtp directly because it requires verificationId/smsCode
|
||||||
|
// So we fetch the data manually here.
|
||||||
|
|
||||||
|
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
||||||
|
await _service.run(
|
||||||
|
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||||
|
requiresAuthentication: false,
|
||||||
|
);
|
||||||
|
final GetUserByIdUser? user = response.data.user;
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
// User authenticated in Firebase but not in our DB?
|
||||||
|
// Should likely sign out or handle gracefully.
|
||||||
|
await _service.auth.signOut();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||||
|
staffResponse = await _service.run(
|
||||||
|
() => _service.connector.getStaffByUserId(userId: firebaseUser.uid).execute(),
|
||||||
|
requiresAuthentication: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
final GetStaffByUserIdStaffs? staffRecord =
|
||||||
|
staffResponse.data.staffs.firstOrNull;
|
||||||
|
|
||||||
|
_setSession(firebaseUser.uid, user, staffRecord);
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.User _setSession(
|
||||||
|
String uid,
|
||||||
|
GetUserByIdUser? user,
|
||||||
|
GetStaffByUserIdStaffs? staffRecord,
|
||||||
|
) {
|
||||||
//TO-DO: create(registration) user and staff account
|
//TO-DO: create(registration) user and staff account
|
||||||
//TO-DO: save user data locally
|
//TO-DO: save user data locally
|
||||||
final domain.User domainUser = domain.User(
|
final domain.User domainUser = domain.User(
|
||||||
id: firebaseUser.uid,
|
id: uid,
|
||||||
email: email,
|
email: user?.email ?? '',
|
||||||
phone: firebaseUser.phoneNumber,
|
phone: user?.phone, // Use user.phone locally if needed, but domain.User expects it
|
||||||
role: user?.role.stringValue ?? 'USER',
|
role: user?.role.stringValue ?? 'USER',
|
||||||
);
|
);
|
||||||
final domain.Staff? domainStaff = staffRecord == null
|
final domain.Staff? domainStaff = staffRecord == null
|
||||||
|
|||||||
@@ -20,4 +20,7 @@ abstract interface class AuthRepositoryInterface {
|
|||||||
|
|
||||||
/// Signs out the current user.
|
/// Signs out the current user.
|
||||||
Future<void> signOut();
|
Future<void> signOut();
|
||||||
|
|
||||||
|
/// Restores the user session if a user is already signed in.
|
||||||
|
Future<void> restoreSession();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class StaffAuthenticationModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
|
||||||
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
|
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
|
||||||
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
||||||
|
|
||||||
@@ -53,6 +52,11 @@ class StaffAuthenticationModule extends Module {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void exportedBinds(Injector i) {
|
||||||
|
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(StaffPaths.root, child: (_) => const IntroPage());
|
r.child(StaffPaths.root, child: (_) => const IntroPage());
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ export 'src/presentation/pages/get_started_page.dart';
|
|||||||
export 'src/presentation/pages/phone_verification_page.dart';
|
export 'src/presentation/pages/phone_verification_page.dart';
|
||||||
export 'src/presentation/pages/profile_setup_page.dart';
|
export 'src/presentation/pages/profile_setup_page.dart';
|
||||||
export 'src/staff_authentication_module.dart';
|
export 'src/staff_authentication_module.dart';
|
||||||
|
export 'src/domain/repositories/auth_repository_interface.dart';
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ workspace:
|
|||||||
- packages/features/client/view_orders
|
- packages/features/client/view_orders
|
||||||
- packages/features/client/client_coverage
|
- packages/features/client/client_coverage
|
||||||
- packages/features/client/client_main
|
- packages/features/client/client_main
|
||||||
|
- packages/features/client/reports
|
||||||
- apps/staff
|
- apps/staff
|
||||||
- apps/client
|
- apps/client
|
||||||
- apps/design_system_viewer
|
- apps/design_system_viewer
|
||||||
|
|||||||
Reference in New Issue
Block a user