feat: architecture overhaul, launchpad-style reports, and uber-style locations
- Strengthened Buffer Layer architecture to decouple Data Connect from Domain - Rewired Coverage, Performance, and Forecast reports to match Launchpad logic - Implemented Uber-style Preferred Locations search using Google Places API - Added session recovery logic to prevent crashes on app restart - Synchronized backend schemas & SDK for ShiftStatus enums - Fixed various build/compilation errors and localization duplicates
This commit is contained in:
@@ -177,6 +177,13 @@ extension StaffNavigator on IModularNavigator {
|
||||
pushNamed(StaffPaths.onboardingPersonalInfo);
|
||||
}
|
||||
|
||||
/// Pushes the preferred locations editing page.
|
||||
///
|
||||
/// Allows staff to search and manage their preferred US work locations.
|
||||
void toPreferredLocations() {
|
||||
pushNamed(StaffPaths.preferredLocations);
|
||||
}
|
||||
|
||||
/// Pushes the emergency contact page.
|
||||
///
|
||||
/// Manage emergency contact details for safety purposes.
|
||||
|
||||
@@ -128,6 +128,12 @@ class StaffPaths {
|
||||
static const String languageSelection =
|
||||
'/worker-main/personal-info/language-selection/';
|
||||
|
||||
/// Preferred locations editing page.
|
||||
///
|
||||
/// Allows staff to search and select their preferred US work locations.
|
||||
static const String preferredLocations =
|
||||
'/worker-main/personal-info/preferred-locations/';
|
||||
|
||||
/// Emergency contact information.
|
||||
///
|
||||
/// Manage emergency contact details for safety purposes.
|
||||
|
||||
@@ -605,8 +605,21 @@
|
||||
"languages_hint": "English, Spanish, French...",
|
||||
"locations_label": "Preferred Locations",
|
||||
"locations_hint": "Downtown, Midtown, Brooklyn...",
|
||||
"locations_summary_none": "Not set",
|
||||
"save_button": "Save Changes",
|
||||
"save_success": "Personal info saved successfully"
|
||||
"save_success": "Personal info saved successfully",
|
||||
"preferred_locations": {
|
||||
"title": "Preferred Locations",
|
||||
"description": "Choose up to 5 locations in the US where you prefer to work. We'll prioritize shifts near these areas.",
|
||||
"search_hint": "Search a city or area...",
|
||||
"added_label": "YOUR LOCATIONS",
|
||||
"max_reached": "You've reached the maximum of 5 locations",
|
||||
"min_hint": "Add at least 1 preferred location",
|
||||
"save_button": "Save Locations",
|
||||
"save_success": "Preferred locations saved",
|
||||
"remove_tooltip": "Remove location",
|
||||
"empty_state": "No locations added yet.\nSearch above to add your preferred work areas."
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"title": "Experience & Skills",
|
||||
@@ -1304,17 +1317,31 @@
|
||||
},
|
||||
"forecast_report": {
|
||||
"title": "Forecast Report",
|
||||
"subtitle": "Projected spend & staffing",
|
||||
"subtitle": "Next 4 weeks projection",
|
||||
"metrics": {
|
||||
"projected_spend": "Projected Spend",
|
||||
"workers_needed": "Workers Needed"
|
||||
"four_week_forecast": "4-Week Forecast",
|
||||
"avg_weekly": "Avg Weekly",
|
||||
"total_shifts": "Total Shifts",
|
||||
"total_hours": "Total Hours"
|
||||
},
|
||||
"badges": {
|
||||
"total_projected": "Total projected",
|
||||
"per_week": "Per week",
|
||||
"scheduled": "Scheduled",
|
||||
"worker_hours": "Worker hours"
|
||||
},
|
||||
"chart_title": "Spending Forecast",
|
||||
"daily_projections": "DAILY PROJECTIONS",
|
||||
"empty_state": "No projections available",
|
||||
"shift_item": {
|
||||
"workers_needed": "$count workers needed"
|
||||
"weekly_breakdown": {
|
||||
"title": "WEEKLY BREAKDOWN",
|
||||
"week": "Week $index",
|
||||
"shifts": "Shifts",
|
||||
"hours": "Hours",
|
||||
"avg_shift": "Avg/Shift"
|
||||
},
|
||||
"buttons": {
|
||||
"export": "Export"
|
||||
},
|
||||
"empty_state": "No projections available",
|
||||
"placeholders": {
|
||||
"export_message": "Exporting Forecast Report (Placeholder)"
|
||||
}
|
||||
|
||||
@@ -605,8 +605,21 @@
|
||||
"languages_hint": "Inglés, Español, Francés...",
|
||||
"locations_label": "Ubicaciones Preferidas",
|
||||
"locations_hint": "Centro, Midtown, Brooklyn...",
|
||||
"locations_summary_none": "No configurado",
|
||||
"save_button": "Guardar Cambios",
|
||||
"save_success": "Información personal guardada exitosamente"
|
||||
"save_success": "Información personal guardada exitosamente",
|
||||
"preferred_locations": {
|
||||
"title": "Ubicaciones Preferidas",
|
||||
"description": "Elige hasta 5 ubicaciones en los EE.UU. donde prefieres trabajar. Priorizaremos turnos cerca de estas áreas.",
|
||||
"search_hint": "Buscar una ciudad o área...",
|
||||
"added_label": "TUS UBICACIONES",
|
||||
"max_reached": "Has alcanzado el máximo de 5 ubicaciones",
|
||||
"min_hint": "Agrega al menos 1 ubicación preferida",
|
||||
"save_button": "Guardar Ubicaciones",
|
||||
"save_success": "Ubicaciones preferidas guardadas",
|
||||
"remove_tooltip": "Eliminar ubicación",
|
||||
"empty_state": "Aún no has agregado ubicaciones.\nBusca arriba para agregar tus áreas de trabajo preferidas."
|
||||
}
|
||||
},
|
||||
"experience": {
|
||||
"title": "Experiencia y habilidades",
|
||||
@@ -1304,17 +1317,31 @@
|
||||
},
|
||||
"forecast_report": {
|
||||
"title": "Informe de Previsión",
|
||||
"subtitle": "Gastos y personal proyectados",
|
||||
"subtitle": "Proyección próximas 4 semanas",
|
||||
"metrics": {
|
||||
"projected_spend": "Gasto Proyectado",
|
||||
"workers_needed": "Trabajadores Necesarios"
|
||||
"four_week_forecast": "Previsión 4 Semanas",
|
||||
"avg_weekly": "Promedio Semanal",
|
||||
"total_shifts": "Total de Turnos",
|
||||
"total_hours": "Total de Horas"
|
||||
},
|
||||
"badges": {
|
||||
"total_projected": "Total proyectado",
|
||||
"per_week": "Por semana",
|
||||
"scheduled": "Programado",
|
||||
"worker_hours": "Horas de trabajo"
|
||||
},
|
||||
"chart_title": "Previsión de Gastos",
|
||||
"daily_projections": "PROYECCIONES DIARIAS",
|
||||
"empty_state": "No hay proyecciones disponibles",
|
||||
"shift_item": {
|
||||
"workers_needed": "$count trabajadores necesarios"
|
||||
"weekly_breakdown": {
|
||||
"title": "DESGLOSE SEMANAL",
|
||||
"week": "Semana $index",
|
||||
"shifts": "Turnos",
|
||||
"hours": "Horas",
|
||||
"avg_shift": "Prom./Turno"
|
||||
},
|
||||
"buttons": {
|
||||
"export": "Exportar"
|
||||
},
|
||||
"empty_state": "No hay proyecciones disponibles",
|
||||
"placeholders": {
|
||||
"export_message": "Exportando Informe de Previsión (Marcador de posición)"
|
||||
}
|
||||
|
||||
@@ -27,4 +27,28 @@ export 'src/connectors/staff/domain/usecases/get_experience_completion_usecase.d
|
||||
export 'src/connectors/staff/domain/usecases/get_tax_forms_completion_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/get_staff_profile_usecase.dart';
|
||||
export 'src/connectors/staff/domain/usecases/sign_out_staff_usecase.dart';
|
||||
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
export 'src/connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
|
||||
// Export Reports Connector
|
||||
export 'src/connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
export 'src/connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
|
||||
// Export Shifts Connector
|
||||
export 'src/connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
export 'src/connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
|
||||
// Export Hubs Connector
|
||||
export 'src/connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
export 'src/connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
|
||||
// Export Billing Connector
|
||||
export 'src/connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
export 'src/connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
|
||||
// Export Home Connector
|
||||
export 'src/connectors/home/domain/repositories/home_connector_repository.dart';
|
||||
export 'src/connectors/home/data/repositories/home_connector_repository_impl.dart';
|
||||
|
||||
// Export Coverage Connector
|
||||
export 'src/connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
export 'src/connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
@@ -0,0 +1,199 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/billing_connector_repository.dart';
|
||||
|
||||
/// Implementation of [BillingConnectorRepository].
|
||||
class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
||||
BillingConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.getAccountsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.accounts.map(_mapBankAccount).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<double> getCurrentBillAmount({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where((i) => i.status == InvoiceStatus.open)
|
||||
.fold<double>(0.0, (sum, item) => sum + item.totalAmount);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getInvoiceHistory({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.limit(10)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices.map(_mapInvoice).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Invoice>> getPendingInvoices({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final result = await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where((i) =>
|
||||
i.status == InvoiceStatus.open || i.status == InvoiceStatus.disputed)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown({
|
||||
required String businessId,
|
||||
required BillingPeriod period,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
|
||||
if (period == BillingPeriod.week) {
|
||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||
final DateTime monday = DateTime(now.year, now.month, now.day)
|
||||
.subtract(Duration(days: daysFromMonday));
|
||||
start = monday;
|
||||
end = monday.add(const Duration(days: 6, hours: 23, minutes: 59, seconds: 59));
|
||||
} else {
|
||||
start = DateTime(now.year, now.month, 1);
|
||||
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59);
|
||||
}
|
||||
|
||||
final result = await _service.connector
|
||||
.listShiftRolesByBusinessAndDatesSummary(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final shiftRoles = result.data.shiftRoles;
|
||||
if (shiftRoles.isEmpty) return [];
|
||||
|
||||
final Map<String, _RoleSummary> summary = {};
|
||||
for (final role in shiftRoles) {
|
||||
final roleId = role.roleId;
|
||||
final roleName = role.role.name;
|
||||
final hours = role.hours ?? 0.0;
|
||||
final totalValue = role.totalValue ?? 0.0;
|
||||
|
||||
final existing = summary[roleId];
|
||||
if (existing == null) {
|
||||
summary[roleId] = _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: hours,
|
||||
totalValue: totalValue,
|
||||
);
|
||||
} else {
|
||||
summary[roleId] = existing.copyWith(
|
||||
totalHours: existing.totalHours + hours,
|
||||
totalValue: existing.totalValue + totalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary.values
|
||||
.map((item) => InvoiceItem(
|
||||
id: item.roleId,
|
||||
invoiceId: item.roleId,
|
||||
staffId: item.roleName,
|
||||
workHours: item.totalHours,
|
||||
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
||||
amount: item.totalValue,
|
||||
))
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
// --- MAPPERS ---
|
||||
|
||||
Invoice _mapInvoice(dynamic invoice) {
|
||||
return Invoice(
|
||||
id: invoice.id,
|
||||
eventId: invoice.orderId,
|
||||
businessId: invoice.businessId,
|
||||
status: _mapInvoiceStatus(invoice.status.stringValue),
|
||||
totalAmount: invoice.amount,
|
||||
workAmount: invoice.amount,
|
||||
addonsAmount: invoice.otherCharges ?? 0,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||
);
|
||||
}
|
||||
|
||||
BusinessBankAccount _mapBankAccount(dynamic account) {
|
||||
return BusinessBankAccountAdapter.fromPrimitives(
|
||||
id: account.id,
|
||||
bank: account.bank,
|
||||
last4: account.last4,
|
||||
isPrimary: account.isPrimary ?? false,
|
||||
expiryTime: _service.toDateTime(account.expiryTime),
|
||||
);
|
||||
}
|
||||
|
||||
InvoiceStatus _mapInvoiceStatus(String status) {
|
||||
switch (status) {
|
||||
case 'PAID':
|
||||
return InvoiceStatus.paid;
|
||||
case 'OVERDUE':
|
||||
return InvoiceStatus.overdue;
|
||||
case 'DISPUTED':
|
||||
return InvoiceStatus.disputed;
|
||||
case 'APPROVED':
|
||||
return InvoiceStatus.verified;
|
||||
default:
|
||||
return InvoiceStatus.open;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSummary {
|
||||
const _RoleSummary({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.totalHours,
|
||||
required this.totalValue,
|
||||
});
|
||||
|
||||
final String roleId;
|
||||
final String roleName;
|
||||
final double totalHours;
|
||||
final double totalValue;
|
||||
|
||||
_RoleSummary copyWith({
|
||||
double? totalHours,
|
||||
double? totalValue,
|
||||
}) {
|
||||
return _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: totalHours ?? this.totalHours,
|
||||
totalValue: totalValue ?? this.totalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for billing connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class BillingConnectorRepository {
|
||||
/// Fetches bank accounts associated with the business.
|
||||
Future<List<BusinessBankAccount>> getBankAccounts({required String businessId});
|
||||
|
||||
/// Fetches the current bill amount for the period.
|
||||
Future<double> getCurrentBillAmount({required String businessId});
|
||||
|
||||
/// Fetches historically paid invoices.
|
||||
Future<List<Invoice>> getInvoiceHistory({required String businessId});
|
||||
|
||||
/// Fetches pending invoices (Open or Disputed).
|
||||
Future<List<Invoice>> getPendingInvoices({required String businessId});
|
||||
|
||||
/// Fetches the breakdown of spending.
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown({
|
||||
required String businessId,
|
||||
required BillingPeriod period,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/home_connector_repository.dart';
|
||||
|
||||
/// Implementation of [HomeConnectorRepository].
|
||||
class HomeConnectorRepositoryImpl implements HomeConnectorRepository {
|
||||
HomeConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<HomeDashboardData> getDashboardData({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final now = DateTime.now();
|
||||
final daysFromMonday = now.weekday - DateTime.monday;
|
||||
final monday = DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday));
|
||||
final weekRangeStart = monday;
|
||||
final weekRangeEnd = monday.add(const Duration(days: 13, hours: 23, minutes: 59, seconds: 59));
|
||||
|
||||
final completedResult = await _service.connector
|
||||
.getCompletedShiftsByBusinessId(
|
||||
businessId: businessId,
|
||||
dateFrom: _service.toTimestamp(weekRangeStart),
|
||||
dateTo: _service.toTimestamp(weekRangeEnd),
|
||||
)
|
||||
.execute();
|
||||
|
||||
double weeklySpending = 0.0;
|
||||
double next7DaysSpending = 0.0;
|
||||
int weeklyShifts = 0;
|
||||
int next7DaysScheduled = 0;
|
||||
|
||||
for (final shift in completedResult.data.shifts) {
|
||||
final shiftDate = _service.toDateTime(shift.date);
|
||||
if (shiftDate == null) continue;
|
||||
|
||||
final offset = shiftDate.difference(weekRangeStart).inDays;
|
||||
if (offset < 0 || offset > 13) continue;
|
||||
|
||||
final cost = shift.cost ?? 0.0;
|
||||
if (offset <= 6) {
|
||||
weeklySpending += cost;
|
||||
weeklyShifts += 1;
|
||||
} else {
|
||||
next7DaysSpending += cost;
|
||||
next7DaysScheduled += 1;
|
||||
}
|
||||
}
|
||||
|
||||
final start = DateTime(now.year, now.month, now.day);
|
||||
final end = start.add(const Duration(hours: 23, minutes: 59, seconds: 59));
|
||||
|
||||
final result = await _service.connector
|
||||
.listShiftRolesByBusinessAndDateRange(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
for (final shiftRole in result.data.shiftRoles) {
|
||||
totalNeeded += shiftRole.count;
|
||||
totalFilled += shiftRole.assigned ?? 0;
|
||||
}
|
||||
|
||||
return HomeDashboardData(
|
||||
weeklySpending: weeklySpending,
|
||||
next7DaysSpending: next7DaysSpending,
|
||||
weeklyShifts: weeklyShifts,
|
||||
next7DaysScheduled: next7DaysScheduled,
|
||||
totalNeeded: totalNeeded,
|
||||
totalFilled: totalFilled,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ReorderItem>> getRecentReorders({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final now = DateTime.now();
|
||||
final start = now.subtract(const Duration(days: 30));
|
||||
|
||||
final result = await _service.connector
|
||||
.listShiftRolesByBusinessDateRangeCompletedOrders(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(now),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return result.data.shiftRoles.map((shiftRole) {
|
||||
final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
|
||||
final String type = shiftRole.shift.order.orderType.stringValue;
|
||||
return ReorderItem(
|
||||
orderId: shiftRole.shift.order.id,
|
||||
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
|
||||
location: location,
|
||||
hourlyRate: shiftRole.role.costPerHour,
|
||||
hours: shiftRole.hours ?? 0,
|
||||
workers: shiftRole.count,
|
||||
type: type,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for home connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class HomeConnectorRepository {
|
||||
/// Fetches dashboard data for a business.
|
||||
Future<HomeDashboardData> getDashboardData({required String businessId});
|
||||
|
||||
/// Fetches recent reorder items for a business.
|
||||
Future<List<ReorderItem>> getRecentReorders({required String businessId});
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/hubs_connector_repository.dart';
|
||||
|
||||
/// Implementation of [HubsConnectorRepository].
|
||||
class HubsConnectorRepositoryImpl implements HubsConnectorRepository {
|
||||
HubsConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Hub>> getHubs({required String businessId}) async {
|
||||
return _service.run(() async {
|
||||
final String teamId = await _getOrCreateTeamId(businessId);
|
||||
final response = await _service.connector
|
||||
.getTeamHubsByTeamId(teamId: teamId)
|
||||
.execute();
|
||||
|
||||
return response.data.teamHubs.map((h) {
|
||||
return Hub(
|
||||
id: h.id,
|
||||
businessId: businessId,
|
||||
name: h.hubName,
|
||||
address: h.address,
|
||||
nfcTagId: null,
|
||||
status: h.isActive ? HubStatus.active : HubStatus.inactive,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Hub> createHub({
|
||||
required String businessId,
|
||||
required String name,
|
||||
required String address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final String teamId = await _getOrCreateTeamId(businessId);
|
||||
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
|
||||
? await _fetchPlaceAddress(placeId)
|
||||
: null;
|
||||
|
||||
final result = await _service.connector
|
||||
.createTeamHub(
|
||||
teamId: teamId,
|
||||
hubName: name,
|
||||
address: address,
|
||||
)
|
||||
.placeId(placeId)
|
||||
.latitude(latitude)
|
||||
.longitude(longitude)
|
||||
.city(city ?? placeAddress?.city ?? '')
|
||||
.state(state ?? placeAddress?.state)
|
||||
.street(street ?? placeAddress?.street)
|
||||
.country(country ?? placeAddress?.country)
|
||||
.zipCode(zipCode ?? placeAddress?.zipCode)
|
||||
.execute();
|
||||
|
||||
return Hub(
|
||||
id: result.data.teamHub_insert.id,
|
||||
businessId: businessId,
|
||||
name: name,
|
||||
address: address,
|
||||
nfcTagId: null,
|
||||
status: HubStatus.active,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Hub> updateHub({
|
||||
required String businessId,
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final _PlaceAddress? placeAddress = (placeId != null && placeId.isNotEmpty)
|
||||
? await _fetchPlaceAddress(placeId)
|
||||
: null;
|
||||
|
||||
final builder = _service.connector.updateTeamHub(id: id);
|
||||
|
||||
if (name != null) builder.hubName(name);
|
||||
if (address != null) builder.address(address);
|
||||
if (placeId != null) builder.placeId(placeId);
|
||||
if (latitude != null) builder.latitude(latitude);
|
||||
if (longitude != null) builder.longitude(longitude);
|
||||
if (city != null || placeAddress?.city != null) {
|
||||
builder.city(city ?? placeAddress?.city);
|
||||
}
|
||||
if (state != null || placeAddress?.state != null) {
|
||||
builder.state(state ?? placeAddress?.state);
|
||||
}
|
||||
if (street != null || placeAddress?.street != null) {
|
||||
builder.street(street ?? placeAddress?.street);
|
||||
}
|
||||
if (country != null || placeAddress?.country != null) {
|
||||
builder.country(country ?? placeAddress?.country);
|
||||
}
|
||||
if (zipCode != null || placeAddress?.zipCode != null) {
|
||||
builder.zipCode(zipCode ?? placeAddress?.zipCode);
|
||||
}
|
||||
|
||||
await builder.execute();
|
||||
|
||||
// Return a basic hub object reflecting changes (or we could re-fetch)
|
||||
return Hub(
|
||||
id: id,
|
||||
businessId: businessId,
|
||||
name: name ?? '',
|
||||
address: address ?? '',
|
||||
nfcTagId: null,
|
||||
status: HubStatus.active,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteHub({required String businessId, required String id}) async {
|
||||
return _service.run(() async {
|
||||
final ordersRes = await _service.connector
|
||||
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
|
||||
.execute();
|
||||
|
||||
if (ordersRes.data.orders.isNotEmpty) {
|
||||
throw HubHasOrdersException(
|
||||
technicalMessage: 'Hub $id has ${ordersRes.data.orders.length} orders',
|
||||
);
|
||||
}
|
||||
|
||||
await _service.connector.deleteTeamHub(id: id).execute();
|
||||
});
|
||||
}
|
||||
|
||||
// --- HELPERS ---
|
||||
|
||||
Future<String> _getOrCreateTeamId(String businessId) async {
|
||||
final teamsRes = await _service.connector
|
||||
.getTeamsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
|
||||
if (teamsRes.data.teams.isNotEmpty) {
|
||||
return teamsRes.data.teams.first.id;
|
||||
}
|
||||
|
||||
// Logic to fetch business details to create a team name if missing
|
||||
// For simplicity, we assume one exists or we create a generic one
|
||||
final createRes = await _service.connector
|
||||
.createTeam(
|
||||
teamName: 'Business Team',
|
||||
ownerId: businessId,
|
||||
ownerName: '',
|
||||
ownerRole: 'OWNER',
|
||||
)
|
||||
.execute();
|
||||
|
||||
return createRes.data.team_insert.id;
|
||||
}
|
||||
|
||||
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
|
||||
final Uri uri = Uri.https(
|
||||
'maps.googleapis.com',
|
||||
'/maps/api/place/details/json',
|
||||
{
|
||||
'place_id': placeId,
|
||||
'fields': 'address_component',
|
||||
'key': AppConfig.googleMapsApiKey,
|
||||
},
|
||||
);
|
||||
try {
|
||||
final response = await http.get(uri);
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
final payload = json.decode(response.body) as Map<String, dynamic>;
|
||||
if (payload['status'] != 'OK') return null;
|
||||
|
||||
final result = payload['result'] as Map<String, dynamic>?;
|
||||
final components = result?['address_components'] as List<dynamic>?;
|
||||
if (components == null || components.isEmpty) return null;
|
||||
|
||||
String? streetNumber, route, city, state, country, zipCode;
|
||||
|
||||
for (var entry in components) {
|
||||
final component = entry as Map<String, dynamic>;
|
||||
final types = component['types'] as List<dynamic>? ?? [];
|
||||
final longName = component['long_name'] as String?;
|
||||
final shortName = component['short_name'] as String?;
|
||||
|
||||
if (types.contains('street_number')) {
|
||||
streetNumber = longName;
|
||||
} else if (types.contains('route')) {
|
||||
route = longName;
|
||||
} else if (types.contains('locality')) {
|
||||
city = longName;
|
||||
} else if (types.contains('administrative_area_level_1')) {
|
||||
state = shortName ?? longName;
|
||||
} else if (types.contains('country')) {
|
||||
country = shortName ?? longName;
|
||||
} else if (types.contains('postal_code')) {
|
||||
zipCode = longName;
|
||||
}
|
||||
}
|
||||
|
||||
final street = [streetNumber, route]
|
||||
.where((v) => v != null && v.isNotEmpty)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return _PlaceAddress(
|
||||
street: street.isEmpty ? null : street,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country,
|
||||
zipCode: zipCode,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceAddress {
|
||||
const _PlaceAddress({
|
||||
this.street,
|
||||
this.city,
|
||||
this.state,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String? street;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for hubs connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class HubsConnectorRepository {
|
||||
/// Fetches the list of hubs for a business.
|
||||
Future<List<Hub>> getHubs({required String businessId});
|
||||
|
||||
/// Creates a new hub.
|
||||
Future<Hub> createHub({
|
||||
required String businessId,
|
||||
required String name,
|
||||
required String address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
});
|
||||
|
||||
/// Updates an existing hub.
|
||||
Future<Hub> updateHub({
|
||||
required String businessId,
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
String? placeId,
|
||||
double? latitude,
|
||||
double? longitude,
|
||||
String? city,
|
||||
String? state,
|
||||
String? street,
|
||||
String? country,
|
||||
String? zipCode,
|
||||
});
|
||||
|
||||
/// Deletes a hub.
|
||||
Future<void> deleteHub({required String businessId, required String id});
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/reports_connector_repository.dart';
|
||||
|
||||
/// Implementation of [ReportsConnectorRepository].
|
||||
///
|
||||
/// Fetches report-related data from the Data Connect backend.
|
||||
class ReportsConnectorRepositoryImpl implements ReportsConnectorRepository {
|
||||
/// Creates a new [ReportsConnectorRepositoryImpl].
|
||||
ReportsConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
}) async {
|
||||
return _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 _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final invoices = response.data.invoices;
|
||||
|
||||
double totalSpend = 0.0;
|
||||
int paidInvoices = 0;
|
||||
int pendingInvoices = 0;
|
||||
int overdueInvoices = 0;
|
||||
|
||||
final List<SpendInvoice> spendInvoices = [];
|
||||
final Map<DateTime, double> dailyAggregates = {};
|
||||
final Map<String, double> industryAggregates = {};
|
||||
|
||||
for (final inv in invoices) {
|
||||
final amount = (inv.amount ?? 0.0).toDouble();
|
||||
totalSpend += amount;
|
||||
|
||||
final statusStr = inv.status.stringValue;
|
||||
if (statusStr == 'PAID') {
|
||||
paidInvoices++;
|
||||
} else if (statusStr == 'PENDING') {
|
||||
pendingInvoices++;
|
||||
} else if (statusStr == 'OVERDUE') {
|
||||
overdueInvoices++;
|
||||
}
|
||||
|
||||
final industry = inv.vendor?.serviceSpecialty ?? 'Other';
|
||||
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
|
||||
|
||||
final issueDateTime = inv.issueDate.toDateTime();
|
||||
spendInvoices.add(SpendInvoice(
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber ?? '',
|
||||
issueDate: issueDateTime,
|
||||
amount: amount,
|
||||
status: statusStr,
|
||||
vendorName: inv.vendor?.companyName ?? 'Unknown',
|
||||
industry: industry,
|
||||
));
|
||||
|
||||
// Chart data aggregation
|
||||
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
|
||||
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
|
||||
}
|
||||
|
||||
// Ensure chart data covers all days in range
|
||||
final Map<DateTime, double> completeDailyAggregates = {};
|
||||
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
final normalizedDate = DateTime(date.year, date.month, date.day);
|
||||
completeDailyAggregates[normalizedDate] =
|
||||
dailyAggregates[normalizedDate] ?? 0.0;
|
||||
}
|
||||
|
||||
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
|
||||
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||
.toList()
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
|
||||
.map((e) => SpendIndustryCategory(
|
||||
name: e.key,
|
||||
amount: e.value,
|
||||
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
final daysCount = endDate.difference(startDate).inDays + 1;
|
||||
|
||||
return SpendReport(
|
||||
totalSpend: totalSpend,
|
||||
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
|
||||
paidInvoices: paidInvoices,
|
||||
pendingInvoices: pendingInvoices,
|
||||
overdueInvoices: overdueInvoices,
|
||||
invoices: spendInvoices,
|
||||
chartData: chartData,
|
||||
industryBreakdown: industryBreakdown,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _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 _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;
|
||||
double totalHours = 0.0;
|
||||
final Map<DateTime, (double, int)> dailyStats = {};
|
||||
|
||||
// Weekly stats: index -> (cost, count, hours)
|
||||
final Map<int, (double, int, double)> weeklyStats = {
|
||||
0: (0.0, 0, 0.0),
|
||||
1: (0.0, 0, 0.0),
|
||||
2: (0.0, 0, 0.0),
|
||||
3: (0.0, 0, 0.0),
|
||||
};
|
||||
|
||||
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;
|
||||
final hoursVal = (shift.hours ?? 0).toDouble();
|
||||
final shiftTotalHours = hoursVal * workers;
|
||||
|
||||
projectedSpend += cost;
|
||||
projectedWorkers += workers;
|
||||
totalHours += shiftTotalHours;
|
||||
|
||||
final current = dailyStats[date] ?? (0.0, 0);
|
||||
dailyStats[date] = (current.$1 + cost, current.$2 + workers);
|
||||
|
||||
// Weekly logic
|
||||
final diffDays = shiftDate.difference(startDate).inDays;
|
||||
if (diffDays >= 0) {
|
||||
final weekIndex = diffDays ~/ 7;
|
||||
if (weekIndex < 4) {
|
||||
final wCurrent = weeklyStats[weekIndex]!;
|
||||
weeklyStats[weekIndex] = (
|
||||
wCurrent.$1 + cost,
|
||||
wCurrent.$2 + 1,
|
||||
wCurrent.$3 + shiftTotalHours,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
final List<ForecastWeek> weeklyBreakdown = [];
|
||||
for (int i = 0; i < 4; i++) {
|
||||
final stats = weeklyStats[i]!;
|
||||
weeklyBreakdown.add(ForecastWeek(
|
||||
weekNumber: i + 1,
|
||||
totalCost: stats.$1,
|
||||
shiftsCount: stats.$2,
|
||||
hoursCount: stats.$3,
|
||||
avgCostPerShift: stats.$2 == 0 ? 0.0 : stats.$1 / stats.$2,
|
||||
));
|
||||
}
|
||||
|
||||
final weeksCount = (endDate.difference(startDate).inDays / 7).ceil();
|
||||
final avgWeeklySpend = weeksCount > 0 ? projectedSpend / weeksCount : 0.0;
|
||||
|
||||
return ForecastReport(
|
||||
projectedSpend: projectedSpend,
|
||||
projectedWorkers: projectedWorkers,
|
||||
averageLaborCost: projectedWorkers == 0 ? 0.0 : projectedSpend / projectedWorkers,
|
||||
chartData: chartData,
|
||||
totalShifts: shifts.length,
|
||||
totalHours: totalHours,
|
||||
avgWeeklySpend: avgWeeklySpend,
|
||||
weeklyBreakdown: weeklyBreakdown,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return _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 _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 _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;
|
||||
|
||||
for (final shift in forecastShifts) {
|
||||
totalHours += (shift.hours ?? 0).toDouble();
|
||||
totalNeeded += shift.workersNeeded ?? 0;
|
||||
}
|
||||
|
||||
// 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,55 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for reports connector queries.
|
||||
///
|
||||
/// This interface defines the contract for accessing report-related data
|
||||
/// from the backend via Data Connect.
|
||||
abstract interface class ReportsConnectorRepository {
|
||||
/// Fetches the daily operations report for a specific business and date.
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
String? businessId,
|
||||
required DateTime date,
|
||||
});
|
||||
|
||||
/// Fetches the spend report for a specific business and date range.
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the coverage report for a specific business and date range.
|
||||
Future<CoverageReport> getCoverageReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the forecast report for a specific business and date range.
|
||||
Future<ForecastReport> getForecastReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the performance report for a specific business and date range.
|
||||
Future<PerformanceReport> getPerformanceReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches the no-show report for a specific business and date range.
|
||||
Future<NoShowReport> getNoShowReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
|
||||
/// Fetches a summary of all reports for a specific business and date range.
|
||||
Future<ReportsSummary> getReportsSummary({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/shifts_connector_repository.dart';
|
||||
|
||||
/// Implementation of [ShiftsConnectorRepository].
|
||||
///
|
||||
/// Handles shift-related data operations by interacting with Data Connect.
|
||||
class ShiftsConnectorRepositoryImpl implements ShiftsConnectorRepository {
|
||||
/// Creates a new [ShiftsConnectorRepositoryImpl].
|
||||
ShiftsConnectorRepositoryImpl({
|
||||
dc.DataConnectService? service,
|
||||
}) : _service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required String staffId,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final query = _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(start))
|
||||
.dayEnd(_service.toTimestamp(end));
|
||||
|
||||
final response = await query.execute();
|
||||
return _mapApplicationsToShifts(response.data.applications);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts({
|
||||
required String staffId,
|
||||
String? query,
|
||||
String? type,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
// First, fetch all available shift roles for the vendor/business
|
||||
// Use the session owner ID (vendorId)
|
||||
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
if (vendorId == null || vendorId.isEmpty) return [];
|
||||
|
||||
final response = await _service.connector
|
||||
.listShiftRolesByVendorId(vendorId: vendorId)
|
||||
.execute();
|
||||
|
||||
final allShiftRoles = response.data.shiftRoles;
|
||||
|
||||
// Fetch current applications to filter out already booked shifts
|
||||
final myAppsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
final Set<String> appliedShiftIds =
|
||||
myAppsResponse.data.applications.map((a) => a.shiftId).toSet();
|
||||
|
||||
final List<Shift> mappedShifts = [];
|
||||
for (final sr in allShiftRoles) {
|
||||
if (appliedShiftIds.contains(sr.shiftId)) continue;
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
|
||||
final startDt = _service.toDateTime(sr.startTime);
|
||||
final endDt = _service.toDateTime(sr.endTime);
|
||||
final createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
mappedShifts.add(
|
||||
Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.role.name,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? '',
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
|
||||
description: sr.shift.description,
|
||||
durationDays: sr.shift.durationDays,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query != null && query.isNotEmpty) {
|
||||
final lowerQuery = query.toLowerCase();
|
||||
return mappedShifts.where((s) {
|
||||
return s.title.toLowerCase().contains(lowerQuery) ||
|
||||
s.clientName.toLowerCase().contains(lowerQuery);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
return mappedShifts;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
// Current schema doesn't have a specific "pending assignment" query that differs from confirmed
|
||||
// unless we filter by status. In the old repo it was returning an empty list.
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
String? roleId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
if (roleId != null && roleId.isNotEmpty) {
|
||||
final roleResult = await _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
|
||||
.execute();
|
||||
final sr = roleResult.data.shiftRole;
|
||||
if (sr == null) return null;
|
||||
|
||||
final DateTime? startDt = _service.toDateTime(sr.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(sr.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
bool hasApplied = false;
|
||||
String status = 'open';
|
||||
|
||||
final appsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final app = appsResponse.data.applications
|
||||
.where((a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId)
|
||||
.firstOrNull;
|
||||
|
||||
if (app != null) {
|
||||
hasApplied = true;
|
||||
final s = app.status.stringValue;
|
||||
status = _mapApplicationStatus(s);
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.shift.order.business.businessName,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: sr.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: sr.shift.description,
|
||||
durationDays: null,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
hasApplied: hasApplied,
|
||||
totalValue: sr.totalValue,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final result = await _service.connector.getShiftById(id: shiftId).execute();
|
||||
final s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
int? required;
|
||||
int? filled;
|
||||
Break? breakInfo;
|
||||
|
||||
try {
|
||||
final rolesRes = await _service.connector
|
||||
.listShiftRolesByShiftId(shiftId: shiftId)
|
||||
.execute();
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
required = 0;
|
||||
filled = 0;
|
||||
for (var r in rolesRes.data.shiftRoles) {
|
||||
required = (required ?? 0) + r.count;
|
||||
filled = (filled ?? 0) + (r.assigned ?? 0);
|
||||
}
|
||||
final firstRole = rolesRes.data.shiftRoles.first;
|
||||
breakInfo = BreakAdapter.fromData(
|
||||
isPaid: firstRole.isBreakPaid ?? false,
|
||||
breakTime: firstRole.breakType?.stringValue,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final startDt = _service.toDateTime(s.startTime);
|
||||
final endDt = _service.toDateTime(s.endTime);
|
||||
final createdDt = _service.toDateTime(s.createdAt);
|
||||
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: s.cost ?? 0.0,
|
||||
location: s.location ?? '',
|
||||
locationAddress: s.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: s.status?.stringValue ?? 'OPEN',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: required,
|
||||
filledSlots: filled,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
breakInfo: breakInfo,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> applyForShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final targetRoleId = roleId ?? '';
|
||||
if (targetRoleId.isEmpty) throw Exception('Missing role id.');
|
||||
|
||||
final roleResult = await _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
|
||||
.execute();
|
||||
final role = roleResult.data.shiftRole;
|
||||
if (role == null) throw Exception('Shift role not found');
|
||||
|
||||
final shiftResult = await _service.connector.getShiftById(id: shiftId).execute();
|
||||
final shift = shiftResult.data.shift;
|
||||
if (shift == null) throw Exception('Shift not found');
|
||||
|
||||
// Validate daily limit
|
||||
final DateTime? shiftDate = _service.toDateTime(shift.date);
|
||||
if (shiftDate != null) {
|
||||
final DateTime dayStartUtc = DateTime.utc(shiftDate.year, shiftDate.month, shiftDate.day);
|
||||
final DateTime dayEndUtc = dayStartUtc.add(const Duration(days: 1)).subtract(const Duration(microseconds: 1));
|
||||
|
||||
final validationResponse = await _service.connector
|
||||
.vaidateDayStaffApplication(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(dayStartUtc))
|
||||
.dayEnd(_service.toTimestamp(dayEndUtc))
|
||||
.execute();
|
||||
|
||||
if (validationResponse.data.applications.isNotEmpty) {
|
||||
throw Exception('The user already has a shift that day.');
|
||||
}
|
||||
}
|
||||
|
||||
// Check for existing application
|
||||
final existingAppRes = await _service.connector
|
||||
.getApplicationByStaffShiftAndRole(
|
||||
staffId: staffId,
|
||||
shiftId: shiftId,
|
||||
roleId: targetRoleId,
|
||||
)
|
||||
.execute();
|
||||
if (existingAppRes.data.applications.isNotEmpty) {
|
||||
throw Exception('Application already exists.');
|
||||
}
|
||||
|
||||
if ((role.assigned ?? 0) >= role.count) {
|
||||
throw Exception('This shift is full.');
|
||||
}
|
||||
|
||||
final int currentAssigned = role.assigned ?? 0;
|
||||
final int currentFilled = shift.filled ?? 0;
|
||||
|
||||
String? createdAppId;
|
||||
try {
|
||||
final createRes = await _service.connector.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: targetRoleId,
|
||||
status: dc.ApplicationStatus.CONFIRMED, // Matches existing logic
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
).execute();
|
||||
|
||||
createdAppId = createRes.data.application_insert.id;
|
||||
|
||||
await _service.connector
|
||||
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
|
||||
.assigned(currentAssigned + 1)
|
||||
.execute();
|
||||
|
||||
await _service.connector
|
||||
.updateShift(id: shiftId)
|
||||
.filled(currentFilled + 1)
|
||||
.execute();
|
||||
} catch (e) {
|
||||
// Simple rollback attempt (not guaranteed)
|
||||
if (createdAppId != null) {
|
||||
await _service.connector.deleteApplication(id: createdAppId).execute();
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
}) {
|
||||
return _updateApplicationStatus(shiftId, staffId, dc.ApplicationStatus.CONFIRMED);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
}) {
|
||||
return _updateApplicationStatus(shiftId, staffId, dc.ApplicationStatus.REJECTED);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getCancelledShifts({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
// Logic would go here to fetch by REJECTED status if needed
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getHistoryShifts({required String staffId}) async {
|
||||
return _service.run(() async {
|
||||
final response = await _service.connector
|
||||
.listCompletedApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final List<Shift> shifts = [];
|
||||
for (final app in response.data.applications) {
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName = (app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: 'completed', // Hardcoded as checked out implies completion
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
});
|
||||
}
|
||||
|
||||
// --- PRIVATE HELPERS ---
|
||||
|
||||
List<Shift> _mapApplicationsToShifts(List<dynamic> apps) {
|
||||
return apps.map((app) {
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName = (app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
final bool hasCheckIn = app.checkInTime != null;
|
||||
final bool hasCheckOut = app.checkOutTime != null;
|
||||
|
||||
String status;
|
||||
if (hasCheckOut) {
|
||||
status = 'completed';
|
||||
} else if (hasCheckIn) {
|
||||
status = 'checked_in';
|
||||
} else {
|
||||
status = _mapApplicationStatus(app.status.stringValue);
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
String _mapApplicationStatus(String status) {
|
||||
switch (status) {
|
||||
case 'CONFIRMED':
|
||||
return 'confirmed';
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'CHECKED_OUT':
|
||||
return 'completed';
|
||||
case 'REJECTED':
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _updateApplicationStatus(
|
||||
String shiftId,
|
||||
String staffId,
|
||||
dc.ApplicationStatus newStatus,
|
||||
) async {
|
||||
return _service.run(() async {
|
||||
// First try to find the application
|
||||
final appsResponse = await _service.connector
|
||||
.getApplicationsByStaffId(staffId: staffId)
|
||||
.execute();
|
||||
|
||||
final app = appsResponse.data.applications
|
||||
.where((a) => a.shiftId == shiftId)
|
||||
.firstOrNull;
|
||||
|
||||
if (app != null) {
|
||||
await _service.connector
|
||||
.updateApplicationStatus(id: app.id)
|
||||
.status(newStatus)
|
||||
.execute();
|
||||
} else if (newStatus == dc.ApplicationStatus.REJECTED) {
|
||||
// If declining but no app found, create a rejected application
|
||||
final rolesRes = await _service.connector
|
||||
.listShiftRolesByShiftId(shiftId: shiftId)
|
||||
.execute();
|
||||
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
final firstRole = rolesRes.data.shiftRoles.first;
|
||||
await _service.connector.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: firstRole.id,
|
||||
status: dc.ApplicationStatus.REJECTED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
).execute();
|
||||
}
|
||||
} else {
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for shifts connector operations.
|
||||
///
|
||||
/// This acts as a buffer layer between the domain repository and the Data Connect SDK.
|
||||
abstract interface class ShiftsConnectorRepository {
|
||||
/// Retrieves shifts assigned to the current staff member.
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required String staffId,
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
});
|
||||
|
||||
/// Retrieves available shifts.
|
||||
Future<List<Shift>> getAvailableShifts({
|
||||
required String staffId,
|
||||
String? query,
|
||||
String? type,
|
||||
});
|
||||
|
||||
/// Retrieves pending shift assignments for the current staff member.
|
||||
Future<List<Shift>> getPendingAssignments({required String staffId});
|
||||
|
||||
/// Retrieves detailed information for a specific shift.
|
||||
Future<Shift?> getShiftDetails({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
String? roleId,
|
||||
});
|
||||
|
||||
/// Applies for a specific open shift.
|
||||
Future<void> applyForShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
bool isInstantBook = false,
|
||||
String? roleId,
|
||||
});
|
||||
|
||||
/// Accepts a pending shift assignment.
|
||||
Future<void> acceptShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
});
|
||||
|
||||
/// Declines a pending shift assignment.
|
||||
Future<void> declineShift({
|
||||
required String shiftId,
|
||||
required String staffId,
|
||||
});
|
||||
|
||||
/// Retrieves cancelled shifts for the current staff member.
|
||||
Future<List<Shift>> getCancelledShifts({required String staffId});
|
||||
|
||||
/// Retrieves historical (completed) shifts for the current staff member.
|
||||
Future<List<Shift>> getHistoryShifts({required String staffId});
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
import 'connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
import 'connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
import 'connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
import 'connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
import 'connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
import 'connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
import 'connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
import 'connectors/home/domain/repositories/home_connector_repository.dart';
|
||||
import 'connectors/home/data/repositories/home_connector_repository_impl.dart';
|
||||
import 'connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
import 'connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
import 'services/data_connect_service.dart';
|
||||
|
||||
/// A module that provides Data Connect dependencies.
|
||||
@@ -6,5 +18,25 @@ class DataConnectModule extends Module {
|
||||
@override
|
||||
void exportedBinds(Injector i) {
|
||||
i.addInstance<DataConnectService>(DataConnectService.instance);
|
||||
|
||||
// Repositories
|
||||
i.addLazySingleton<ReportsConnectorRepository>(
|
||||
ReportsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<ShiftsConnectorRepository>(
|
||||
ShiftsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<HubsConnectorRepository>(
|
||||
HubsConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<BillingConnectorRepository>(
|
||||
BillingConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<HomeConnectorRepository>(
|
||||
HomeConnectorRepositoryImpl.new,
|
||||
);
|
||||
i.addLazySingleton<CoverageConnectorRepository>(
|
||||
CoverageConnectorRepositoryImpl.new,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
|
||||
import '../../krow_data_connect.dart' as dc;
|
||||
import '../connectors/reports/domain/repositories/reports_connector_repository.dart';
|
||||
import '../connectors/reports/data/repositories/reports_connector_repository_impl.dart';
|
||||
import '../connectors/shifts/domain/repositories/shifts_connector_repository.dart';
|
||||
import '../connectors/shifts/data/repositories/shifts_connector_repository_impl.dart';
|
||||
import '../connectors/hubs/domain/repositories/hubs_connector_repository.dart';
|
||||
import '../connectors/hubs/data/repositories/hubs_connector_repository_impl.dart';
|
||||
import '../connectors/billing/domain/repositories/billing_connector_repository.dart';
|
||||
import '../connectors/billing/data/repositories/billing_connector_repository_impl.dart';
|
||||
import '../connectors/home/domain/repositories/home_connector_repository.dart';
|
||||
import '../connectors/home/data/repositories/home_connector_repository_impl.dart';
|
||||
import '../connectors/coverage/domain/repositories/coverage_connector_repository.dart';
|
||||
import '../connectors/coverage/data/repositories/coverage_connector_repository_impl.dart';
|
||||
import '../connectors/staff/domain/repositories/staff_connector_repository.dart';
|
||||
import '../connectors/staff/data/repositories/staff_connector_repository_impl.dart';
|
||||
import 'mixins/data_error_handler.dart';
|
||||
import 'mixins/session_handler_mixin.dart';
|
||||
|
||||
@@ -22,176 +33,203 @@ class DataConnectService with DataErrorHandler, SessionHandlerMixin {
|
||||
/// The Data Connect connector used for data operations.
|
||||
final dc.ExampleConnector connector = dc.ExampleConnector.instance;
|
||||
|
||||
/// The Firebase Auth instance.
|
||||
firebase_auth.FirebaseAuth get auth => _auth;
|
||||
final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance;
|
||||
// Repositories
|
||||
ReportsConnectorRepository? _reportsRepository;
|
||||
ShiftsConnectorRepository? _shiftsRepository;
|
||||
HubsConnectorRepository? _hubsRepository;
|
||||
BillingConnectorRepository? _billingRepository;
|
||||
HomeConnectorRepository? _homeRepository;
|
||||
CoverageConnectorRepository? _coverageRepository;
|
||||
StaffConnectorRepository? _staffRepository;
|
||||
|
||||
/// Cache for the current staff ID to avoid redundant lookups.
|
||||
String? _cachedStaffId;
|
||||
/// Gets the reports connector repository.
|
||||
ReportsConnectorRepository getReportsRepository() {
|
||||
return _reportsRepository ??= ReportsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Cache for the current business ID to avoid redundant lookups.
|
||||
String? _cachedBusinessId;
|
||||
/// Gets the shifts connector repository.
|
||||
ShiftsConnectorRepository getShiftsRepository() {
|
||||
return _shiftsRepository ??= ShiftsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the current staff ID from session store or persistent storage.
|
||||
/// Gets the hubs connector repository.
|
||||
HubsConnectorRepository getHubsRepository() {
|
||||
return _hubsRepository ??= HubsConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the billing connector repository.
|
||||
BillingConnectorRepository getBillingRepository() {
|
||||
return _billingRepository ??= BillingConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the home connector repository.
|
||||
HomeConnectorRepository getHomeRepository() {
|
||||
return _homeRepository ??= HomeConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the coverage connector repository.
|
||||
CoverageConnectorRepository getCoverageRepository() {
|
||||
return _coverageRepository ??= CoverageConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Gets the staff connector repository.
|
||||
StaffConnectorRepository getStaffRepository() {
|
||||
return _staffRepository ??= StaffConnectorRepositoryImpl(service: this);
|
||||
}
|
||||
|
||||
/// Returns the current Firebase Auth instance.
|
||||
@override
|
||||
firebase.FirebaseAuth get auth => firebase.FirebaseAuth.instance;
|
||||
|
||||
/// Helper to get the current staff ID from the session.
|
||||
Future<String> getStaffId() async {
|
||||
// 1. Check Session Store
|
||||
final dc.StaffSession? session = dc.StaffSessionStore.instance.session;
|
||||
if (session?.staff?.id != null) {
|
||||
return session!.staff!.id;
|
||||
}
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedStaffId != null) return _cachedStaffId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetStaffByUserIdData,
|
||||
dc.GetStaffByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getStaffByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
_cachedStaffId = response.data.staffs.first.id;
|
||||
return _cachedStaffId!;
|
||||
String? staffId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
staffId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch staff ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (staffId == null || staffId.isEmpty) {
|
||||
throw Exception('No staff ID found in session.');
|
||||
}
|
||||
return staffId;
|
||||
}
|
||||
|
||||
/// Gets the current business ID from session store or persistent storage.
|
||||
/// Helper to get the current business ID from the session.
|
||||
Future<String> getBusinessId() async {
|
||||
// 1. Check Session Store
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
if (session?.business?.id != null) {
|
||||
return session!.business!.id;
|
||||
}
|
||||
String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
|
||||
// 2. Check Cache
|
||||
if (_cachedBusinessId != null) return _cachedBusinessId!;
|
||||
|
||||
// 3. Fetch from Data Connect using Firebase UID
|
||||
final firebase_auth.User? user = _auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User is not authenticated',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
final fdc.QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
response = await executeProtected(
|
||||
() => connector.getBusinessesByUserId(userId: user.uid).execute(),
|
||||
);
|
||||
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
_cachedBusinessId = response.data.businesses.first.id;
|
||||
return _cachedBusinessId!;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
// Attempt to recover session if user is signed in
|
||||
final user = auth.currentUser;
|
||||
if (user != null) {
|
||||
await _loadSession(user.uid);
|
||||
businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Failed to fetch business ID from Data Connect: $e');
|
||||
}
|
||||
|
||||
// 4. Fallback (should ideally not happen if DB is seeded)
|
||||
return user.uid;
|
||||
if (businessId == null || businessId.isEmpty) {
|
||||
throw Exception('No business ID found in session.');
|
||||
}
|
||||
return businessId;
|
||||
}
|
||||
|
||||
/// Converts a Data Connect timestamp/string/json to a [DateTime].
|
||||
DateTime? toDateTime(dynamic t) {
|
||||
if (t == null) return null;
|
||||
DateTime? dt;
|
||||
if (t is fdc.Timestamp) {
|
||||
dt = t.toDateTime();
|
||||
} else if (t is String) {
|
||||
dt = DateTime.tryParse(t);
|
||||
} else {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toJson() as String);
|
||||
} catch (_) {
|
||||
try {
|
||||
dt = DateTime.tryParse(t.toString());
|
||||
} catch (e) {
|
||||
dt = null;
|
||||
/// Logic to load session data from backend and populate stores.
|
||||
Future<void> _loadSession(String userId) async {
|
||||
try {
|
||||
final role = await fetchUserRole(userId);
|
||||
if (role == null) return;
|
||||
|
||||
// Load Staff Session if applicable
|
||||
if (role == 'STAFF' || role == 'BOTH') {
|
||||
final response = await connector.getStaffByUserId(userId: userId).execute();
|
||||
if (response.data.staffs.isNotEmpty) {
|
||||
final s = response.data.staffs.first;
|
||||
dc.StaffSessionStore.instance.setSession(
|
||||
dc.StaffSession(
|
||||
ownerId: s.id,
|
||||
staff: domain.Staff(
|
||||
id: s.id,
|
||||
authProviderId: s.userId,
|
||||
name: s.fullName,
|
||||
email: s.email ?? '',
|
||||
phone: s.phone,
|
||||
status: domain.StaffStatus.completedProfile,
|
||||
address: s.addres,
|
||||
avatar: s.photoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dt != null) {
|
||||
return DateTimeUtils.toDeviceTime(dt);
|
||||
// Load Client Session if applicable
|
||||
if (role == 'BUSINESS' || role == 'BOTH') {
|
||||
final response = await connector.getBusinessesByUserId(userId: userId).execute();
|
||||
if (response.data.businesses.isNotEmpty) {
|
||||
final b = response.data.businesses.first;
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: b.id,
|
||||
businessName: b.businessName,
|
||||
email: b.email,
|
||||
city: b.city,
|
||||
contactName: b.contactName,
|
||||
companyLogoUrl: b.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('DataConnectService: Error loading session for $userId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a Data Connect [Timestamp] to a Dart [DateTime].
|
||||
DateTime? toDateTime(dynamic timestamp) {
|
||||
if (timestamp == null) return null;
|
||||
if (timestamp is fdc.Timestamp) {
|
||||
return timestamp.toDateTime();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Converts a [DateTime] to a Firebase Data Connect [Timestamp].
|
||||
/// Converts a Dart [DateTime] to a Data Connect [Timestamp].
|
||||
fdc.Timestamp toTimestamp(DateTime dateTime) {
|
||||
final DateTime utc = dateTime.toUtc();
|
||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
||||
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
||||
return fdc.Timestamp(nanoseconds, seconds);
|
||||
final int millis = utc.millisecondsSinceEpoch;
|
||||
final int seconds = millis ~/ 1000;
|
||||
final int nanos = (millis % 1000) * 1000000;
|
||||
return fdc.Timestamp(nanos, seconds);
|
||||
}
|
||||
|
||||
// --- 3. Unified Execution ---
|
||||
// Repositories call this to benefit from centralized error handling/logging
|
||||
/// Converts a nullable Dart [DateTime] to a nullable Data Connect [Timestamp].
|
||||
fdc.Timestamp? tryToTimestamp(DateTime? dateTime) {
|
||||
if (dateTime == null) return null;
|
||||
return toTimestamp(dateTime);
|
||||
}
|
||||
|
||||
/// Executes an operation with centralized error handling.
|
||||
@override
|
||||
Future<T> run<T>(
|
||||
Future<T> Function() action, {
|
||||
Future<T> Function() operation, {
|
||||
bool requiresAuthentication = true,
|
||||
}) async {
|
||||
if (requiresAuthentication && auth.currentUser == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'User must be authenticated to perform this action',
|
||||
);
|
||||
}
|
||||
|
||||
return executeProtected(() async {
|
||||
// Ensure session token is valid and refresh if needed
|
||||
if (requiresAuthentication) {
|
||||
await ensureSessionValid();
|
||||
return action();
|
||||
});
|
||||
}
|
||||
|
||||
/// Clears the internal cache (e.g., on logout).
|
||||
void clearCache() {
|
||||
_cachedStaffId = null;
|
||||
_cachedBusinessId = null;
|
||||
}
|
||||
|
||||
/// Handle session sign-out by clearing caches.
|
||||
void handleSignOut() {
|
||||
clearCache();
|
||||
}
|
||||
return executeProtected(operation);
|
||||
}
|
||||
|
||||
/// Implementation for SessionHandlerMixin.
|
||||
@override
|
||||
Future<String?> fetchUserRole(String userId) async {
|
||||
try {
|
||||
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
|
||||
response = await executeProtected(
|
||||
() => connector.getUserById(id: userId).execute(),
|
||||
);
|
||||
final response = await connector.getUserById(id: userId).execute();
|
||||
return response.data.user?.userRole;
|
||||
} catch (e) {
|
||||
debugPrint('Failed to fetch user role: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispose all resources (call on app shutdown).
|
||||
Future<void> dispose() async {
|
||||
await disposeSessionHandler();
|
||||
/// Clears Cached Repositories and Session data.
|
||||
void clearCache() {
|
||||
_reportsRepository = null;
|
||||
_shiftsRepository = null;
|
||||
_hubsRepository = null;
|
||||
_billingRepository = null;
|
||||
_homeRepository = null;
|
||||
_coverageRepository = null;
|
||||
_staffRepository = null;
|
||||
|
||||
dc.StaffSessionStore.instance.clear();
|
||||
dc.ClientSessionStore.instance.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ mixin SessionHandlerMixin {
|
||||
_authStateSubscription = auth.authStateChanges().listen(
|
||||
(firebase_auth.User? user) async {
|
||||
if (user == null) {
|
||||
_handleSignOut();
|
||||
handleSignOut();
|
||||
} else {
|
||||
await _handleSignIn(user);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mixin SessionHandlerMixin {
|
||||
}
|
||||
|
||||
/// Handle user sign-out event.
|
||||
void _handleSignOut() {
|
||||
void handleSignOut() {
|
||||
_emitSessionState(SessionState.unauthenticated());
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ export 'src/entities/financial/invoice_item.dart';
|
||||
export 'src/entities/financial/invoice_decline.dart';
|
||||
export 'src/entities/financial/staff_payment.dart';
|
||||
export 'src/entities/financial/payment_summary.dart';
|
||||
export 'src/entities/financial/billing_period.dart';
|
||||
export 'src/entities/financial/bank_account/bank_account.dart';
|
||||
export 'src/entities/financial/bank_account/business_bank_account.dart';
|
||||
export 'src/entities/financial/bank_account/staff_bank_account.dart';
|
||||
@@ -111,3 +112,12 @@ export 'src/adapters/financial/payment_adapter.dart';
|
||||
|
||||
// Exceptions
|
||||
export 'src/exceptions/app_exception.dart';
|
||||
|
||||
// Reports
|
||||
export 'src/entities/reports/daily_ops_report.dart';
|
||||
export 'src/entities/reports/spend_report.dart';
|
||||
export 'src/entities/reports/coverage_report.dart';
|
||||
export 'src/entities/reports/forecast_report.dart';
|
||||
export 'src/entities/reports/no_show_report.dart';
|
||||
export 'src/entities/reports/performance_report.dart';
|
||||
export 'src/entities/reports/reports_summary.dart';
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/// Defines the period for billing calculations.
|
||||
enum BillingPeriod {
|
||||
/// Weekly billing period.
|
||||
week,
|
||||
|
||||
/// Monthly billing period.
|
||||
month,
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class ForecastReport extends Equatable {
|
||||
final double projectedSpend;
|
||||
final int projectedWorkers;
|
||||
final double averageLaborCost;
|
||||
final List<ForecastPoint> chartData;
|
||||
|
||||
// New fields for the updated design
|
||||
final int totalShifts;
|
||||
final double totalHours;
|
||||
final double avgWeeklySpend;
|
||||
final List<ForecastWeek> weeklyBreakdown;
|
||||
|
||||
const ForecastReport({
|
||||
required this.projectedSpend,
|
||||
required this.projectedWorkers,
|
||||
required this.averageLaborCost,
|
||||
required this.chartData,
|
||||
this.totalShifts = 0,
|
||||
this.totalHours = 0.0,
|
||||
this.avgWeeklySpend = 0.0,
|
||||
this.weeklyBreakdown = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
projectedSpend,
|
||||
projectedWorkers,
|
||||
averageLaborCost,
|
||||
chartData,
|
||||
totalShifts,
|
||||
totalHours,
|
||||
avgWeeklySpend,
|
||||
weeklyBreakdown,
|
||||
];
|
||||
}
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
class ForecastWeek extends Equatable {
|
||||
final int weekNumber;
|
||||
final double totalCost;
|
||||
final int shiftsCount;
|
||||
final double hoursCount;
|
||||
final double avgCostPerShift;
|
||||
|
||||
const ForecastWeek({
|
||||
required this.weekNumber,
|
||||
required this.totalCost,
|
||||
required this.shiftsCount,
|
||||
required this.hoursCount,
|
||||
required this.avgCostPerShift,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
weekNumber,
|
||||
totalCost,
|
||||
shiftsCount,
|
||||
hoursCount,
|
||||
avgCostPerShift,
|
||||
];
|
||||
}
|
||||
@@ -8,6 +8,7 @@ class SpendReport extends Equatable {
|
||||
final int overdueInvoices;
|
||||
final List<SpendInvoice> invoices;
|
||||
final List<SpendChartPoint> chartData;
|
||||
final List<SpendIndustryCategory> industryBreakdown;
|
||||
|
||||
const SpendReport({
|
||||
required this.totalSpend,
|
||||
@@ -20,8 +21,6 @@ class SpendReport extends Equatable {
|
||||
required this.industryBreakdown,
|
||||
});
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [
|
||||
totalSpend,
|
||||
@@ -57,6 +56,7 @@ class SpendInvoice extends Equatable {
|
||||
final double amount;
|
||||
final String status;
|
||||
final String vendorName;
|
||||
final String? industry;
|
||||
|
||||
const SpendInvoice({
|
||||
required this.id,
|
||||
@@ -68,8 +68,6 @@ class SpendInvoice extends Equatable {
|
||||
this.industry,
|
||||
});
|
||||
|
||||
final String? industry;
|
||||
|
||||
@override
|
||||
List<Object?> get props => [id, invoiceNumber, issueDate, amount, status, vendorName, industry];
|
||||
}
|
||||
@@ -1,261 +1,58 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as data_connect;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/models/billing_period.dart';
|
||||
import '../../domain/repositories/billing_repository.dart';
|
||||
|
||||
/// Implementation of [BillingRepository] in the Data layer.
|
||||
/// Implementation of [BillingRepository] that delegates to [dc.BillingConnectorRepository].
|
||||
///
|
||||
/// This class is responsible for retrieving billing data from the
|
||||
/// Data Connect layer and mapping it to Domain entities.
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class BillingRepositoryImpl implements BillingRepository {
|
||||
/// Creates a [BillingRepositoryImpl].
|
||||
final dc.BillingConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
BillingRepositoryImpl({
|
||||
data_connect.DataConnectService? service,
|
||||
}) : _service = service ?? data_connect.DataConnectService.instance;
|
||||
dc.BillingConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getBillingRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
final data_connect.DataConnectService _service;
|
||||
|
||||
/// Fetches bank accounts associated with the business.
|
||||
@override
|
||||
Future<List<BusinessBankAccount>> getBankAccounts() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final fdc.QueryResult<
|
||||
data_connect.GetAccountsByOwnerIdData,
|
||||
data_connect.GetAccountsByOwnerIdVariables> result =
|
||||
await _service.connector
|
||||
.getAccountsByOwnerId(ownerId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.accounts.map(_mapBankAccount).toList();
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getBankAccounts(businessId: businessId);
|
||||
}
|
||||
|
||||
/// Fetches the current bill amount by aggregating open invoices.
|
||||
@override
|
||||
Future<double> getCurrentBillAmount() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||
await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where((Invoice i) => i.status == InvoiceStatus.open)
|
||||
.fold<double>(
|
||||
0.0,
|
||||
(double sum, Invoice item) => sum + item.totalAmount,
|
||||
);
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getCurrentBillAmount(businessId: businessId);
|
||||
}
|
||||
|
||||
/// Fetches the history of paid invoices.
|
||||
@override
|
||||
Future<List<Invoice>> getInvoiceHistory() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||
await _service.connector
|
||||
.listInvoicesByBusinessId(
|
||||
businessId: businessId,
|
||||
)
|
||||
.limit(10)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices.map(_mapInvoice).toList();
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getInvoiceHistory(businessId: businessId);
|
||||
}
|
||||
|
||||
/// Fetches pending invoices (Open or Disputed).
|
||||
@override
|
||||
Future<List<Invoice>> getPendingInvoices() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||
await _service.connector
|
||||
.listInvoicesByBusinessId(businessId: businessId)
|
||||
.execute();
|
||||
|
||||
return result.data.invoices
|
||||
.map(_mapInvoice)
|
||||
.where(
|
||||
(Invoice i) =>
|
||||
i.status == InvoiceStatus.open ||
|
||||
i.status == InvoiceStatus.disputed,
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getPendingInvoices(businessId: businessId);
|
||||
}
|
||||
|
||||
/// Fetches the estimated savings amount.
|
||||
@override
|
||||
Future<double> getSavingsAmount() async {
|
||||
// Simulating savings calculation (e.g., comparing to market rates).
|
||||
await Future<void>.delayed(const Duration(milliseconds: 0));
|
||||
// Simulating savings calculation
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/// Fetches the breakdown of spending.
|
||||
@override
|
||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start;
|
||||
final DateTime end;
|
||||
if (period == BillingPeriod.week) {
|
||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||
final DateTime monday = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(Duration(days: daysFromMonday));
|
||||
start = DateTime(monday.year, monday.month, monday.day);
|
||||
end = DateTime(
|
||||
monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
|
||||
} else {
|
||||
start = DateTime(now.year, now.month, 1);
|
||||
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
|
||||
}
|
||||
|
||||
final fdc.QueryResult<
|
||||
data_connect.ListShiftRolesByBusinessAndDatesSummaryData,
|
||||
data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables>
|
||||
result = await _service.connector
|
||||
.listShiftRolesByBusinessAndDatesSummary(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
|
||||
shiftRoles = result.data.shiftRoles;
|
||||
if (shiftRoles.isEmpty) {
|
||||
return <InvoiceItem>[];
|
||||
}
|
||||
|
||||
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
||||
for (final data_connect
|
||||
.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
|
||||
in shiftRoles) {
|
||||
final String roleId = role.roleId;
|
||||
final String roleName = role.role.name;
|
||||
final double hours = role.hours ?? 0.0;
|
||||
final double totalValue = role.totalValue ?? 0.0;
|
||||
final _RoleSummary? existing = summary[roleId];
|
||||
if (existing == null) {
|
||||
summary[roleId] = _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: hours,
|
||||
totalValue: totalValue,
|
||||
);
|
||||
} else {
|
||||
summary[roleId] = existing.copyWith(
|
||||
totalHours: existing.totalHours + hours,
|
||||
totalValue: existing.totalValue + totalValue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return summary.values
|
||||
.map(
|
||||
(_RoleSummary item) => InvoiceItem(
|
||||
id: item.roleId,
|
||||
invoiceId: item.roleId,
|
||||
staffId: item.roleName,
|
||||
workHours: item.totalHours,
|
||||
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
||||
amount: item.totalValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
});
|
||||
}
|
||||
|
||||
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
|
||||
return Invoice(
|
||||
id: invoice.id,
|
||||
eventId: invoice.orderId,
|
||||
businessId: invoice.businessId,
|
||||
status: _mapInvoiceStatus(invoice.status),
|
||||
totalAmount: invoice.amount,
|
||||
workAmount: invoice.amount,
|
||||
addonsAmount: invoice.otherCharges ?? 0,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||
);
|
||||
}
|
||||
|
||||
BusinessBankAccount _mapBankAccount(
|
||||
data_connect.GetAccountsByOwnerIdAccounts account,
|
||||
) {
|
||||
return BusinessBankAccountAdapter.fromPrimitives(
|
||||
id: account.id,
|
||||
bank: account.bank,
|
||||
last4: account.last4,
|
||||
isPrimary: account.isPrimary ?? false,
|
||||
expiryTime: _service.toDateTime(account.expiryTime),
|
||||
);
|
||||
}
|
||||
|
||||
InvoiceStatus _mapInvoiceStatus(
|
||||
data_connect.EnumValue<data_connect.InvoiceStatus> status,
|
||||
) {
|
||||
if (status is data_connect.Known<data_connect.InvoiceStatus>) {
|
||||
switch (status.value) {
|
||||
case data_connect.InvoiceStatus.PAID:
|
||||
return InvoiceStatus.paid;
|
||||
case data_connect.InvoiceStatus.OVERDUE:
|
||||
return InvoiceStatus.overdue;
|
||||
case data_connect.InvoiceStatus.DISPUTED:
|
||||
return InvoiceStatus.disputed;
|
||||
case data_connect.InvoiceStatus.APPROVED:
|
||||
return InvoiceStatus.verified;
|
||||
case data_connect.InvoiceStatus.PENDING_REVIEW:
|
||||
case data_connect.InvoiceStatus.PENDING:
|
||||
case data_connect.InvoiceStatus.DRAFT:
|
||||
return InvoiceStatus.open;
|
||||
}
|
||||
}
|
||||
return InvoiceStatus.open;
|
||||
}
|
||||
}
|
||||
|
||||
class _RoleSummary {
|
||||
const _RoleSummary({
|
||||
required this.roleId,
|
||||
required this.roleName,
|
||||
required this.totalHours,
|
||||
required this.totalValue,
|
||||
});
|
||||
|
||||
final String roleId;
|
||||
final String roleName;
|
||||
final double totalHours;
|
||||
final double totalValue;
|
||||
|
||||
_RoleSummary copyWith({
|
||||
double? totalHours,
|
||||
double? totalValue,
|
||||
}) {
|
||||
return _RoleSummary(
|
||||
roleId: roleId,
|
||||
roleName: roleName,
|
||||
totalHours: totalHours ?? this.totalHours,
|
||||
totalValue: totalValue ?? this.totalValue,
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getSpendingBreakdown(
|
||||
businessId: businessId,
|
||||
period: period,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
enum BillingPeriod {
|
||||
week,
|
||||
month,
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../models/billing_period.dart';
|
||||
|
||||
/// Repository interface for billing related operations.
|
||||
///
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../models/billing_period.dart';
|
||||
import '../repositories/billing_repository.dart';
|
||||
|
||||
/// Use case for fetching the spending breakdown items.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../domain/models/billing_period.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Base class for all billing events.
|
||||
abstract class BillingEvent extends Equatable {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/models/billing_period.dart';
|
||||
import '../models/billing_invoice_model.dart';
|
||||
import '../models/spending_breakdown_model.dart';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 '../../domain/models/billing_period.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../blocs/billing_bloc.dart';
|
||||
import '../blocs/billing_state.dart';
|
||||
import '../blocs/billing_event.dart';
|
||||
|
||||
@@ -1,68 +1,35 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/coverage_repository.dart';
|
||||
|
||||
/// Implementation of [CoverageRepository] in the Data layer.
|
||||
/// Implementation of [CoverageRepository] that delegates to [dc.CoverageConnectorRepository].
|
||||
///
|
||||
/// This class provides mock data for the coverage feature.
|
||||
/// In a production environment, this would delegate to `packages/data_connect`
|
||||
/// for real data access (e.g., Firebase Data Connect, REST API).
|
||||
///
|
||||
/// It strictly adheres to the Clean Architecture data layer responsibilities:
|
||||
/// - No business logic (except necessary data transformation).
|
||||
/// - Delegates to data sources (currently mock data, will be `data_connect`).
|
||||
/// - Returns domain entities from `domain/ui_entities`.
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class CoverageRepositoryImpl implements CoverageRepository {
|
||||
/// Creates a [CoverageRepositoryImpl].
|
||||
CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service;
|
||||
|
||||
final dc.CoverageConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
/// Fetches shifts for a specific date.
|
||||
CoverageRepositoryImpl({
|
||||
dc.CoverageConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getCoverageRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
@override
|
||||
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final DateTime start = DateTime(date.year, date.month, date.day);
|
||||
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
|
||||
|
||||
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
|
||||
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
|
||||
await _service.connector
|
||||
.listShiftRolesByBusinessAndDateRange(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
|
||||
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
|
||||
await _service.connector
|
||||
.listStaffsApplicationsByBusinessForDay(
|
||||
businessId: businessId,
|
||||
dayStart: _service.toTimestamp(start),
|
||||
dayEnd: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return _mapCoverageShifts(
|
||||
shiftRolesResult.data.shiftRoles,
|
||||
applicationsResult.data.applications,
|
||||
date,
|
||||
);
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getShiftsForDate(
|
||||
businessId: businessId,
|
||||
date: date,
|
||||
);
|
||||
}
|
||||
|
||||
/// Fetches coverage statistics for a specific date.
|
||||
@override
|
||||
Future<CoverageStats> getCoverageStats({required DateTime date}) async {
|
||||
// Get shifts for the date
|
||||
final List<CoverageShift> shifts = await getShiftsForDate(date: date);
|
||||
|
||||
// Calculate statistics
|
||||
final int totalNeeded = shifts.fold<int>(
|
||||
0,
|
||||
(int sum, CoverageShift shift) => sum + shift.workersNeeded,
|
||||
@@ -90,129 +57,4 @@ class CoverageRepositoryImpl implements CoverageRepository {
|
||||
late: late,
|
||||
);
|
||||
}
|
||||
|
||||
List<CoverageShift> _mapCoverageShifts(
|
||||
List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
|
||||
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
|
||||
DateTime date,
|
||||
) {
|
||||
if (shiftRoles.isEmpty && applications.isEmpty) {
|
||||
return <CoverageShift>[];
|
||||
}
|
||||
|
||||
final Map<String, _CoverageGroup> groups = <String, _CoverageGroup>{};
|
||||
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
|
||||
in shiftRoles) {
|
||||
final String key = '${shiftRole.shiftId}:${shiftRole.roleId}';
|
||||
groups[key] = _CoverageGroup(
|
||||
shiftId: shiftRole.shiftId,
|
||||
roleId: shiftRole.roleId,
|
||||
title: shiftRole.role.name,
|
||||
location: shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '',
|
||||
startTime: _formatTime(shiftRole.startTime) ?? '00:00',
|
||||
workersNeeded: shiftRole.count,
|
||||
date: shiftRole.shift.date?.toDateTime() ?? date,
|
||||
workers: <CoverageWorker>[],
|
||||
);
|
||||
}
|
||||
|
||||
for (final dc.ListStaffsApplicationsByBusinessForDayApplications app
|
||||
in applications) {
|
||||
final String key = '${app.shiftId}:${app.roleId}';
|
||||
final _CoverageGroup existing = groups[key] ??
|
||||
_CoverageGroup(
|
||||
shiftId: app.shiftId,
|
||||
roleId: app.roleId,
|
||||
title: app.shiftRole.role.name,
|
||||
location: app.shiftRole.shift.location ??
|
||||
app.shiftRole.shift.locationAddress ??
|
||||
'',
|
||||
startTime: _formatTime(app.shiftRole.startTime) ?? '00:00',
|
||||
workersNeeded: app.shiftRole.count,
|
||||
date: app.shiftRole.shift.date?.toDateTime() ?? date,
|
||||
workers: <CoverageWorker>[],
|
||||
);
|
||||
|
||||
existing.workers.add(
|
||||
CoverageWorker(
|
||||
name: app.staff.fullName,
|
||||
status: _mapWorkerStatus(app.status),
|
||||
checkInTime: _formatTime(app.checkInTime),
|
||||
),
|
||||
);
|
||||
groups[key] = existing;
|
||||
}
|
||||
|
||||
return groups.values
|
||||
.map(
|
||||
(_CoverageGroup group) => CoverageShift(
|
||||
id: '${group.shiftId}:${group.roleId}',
|
||||
title: group.title,
|
||||
location: group.location,
|
||||
startTime: group.startTime,
|
||||
workersNeeded: group.workersNeeded,
|
||||
date: group.date,
|
||||
workers: group.workers,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
CoverageWorkerStatus _mapWorkerStatus(
|
||||
dc.EnumValue<dc.ApplicationStatus> status,
|
||||
) {
|
||||
if (status is dc.Known<dc.ApplicationStatus>) {
|
||||
switch (status.value) {
|
||||
case dc.ApplicationStatus.PENDING:
|
||||
return CoverageWorkerStatus.pending;
|
||||
case dc.ApplicationStatus.REJECTED:
|
||||
return CoverageWorkerStatus.rejected;
|
||||
case dc.ApplicationStatus.CONFIRMED:
|
||||
return CoverageWorkerStatus.confirmed;
|
||||
case dc.ApplicationStatus.CHECKED_IN:
|
||||
return CoverageWorkerStatus.checkedIn;
|
||||
case dc.ApplicationStatus.CHECKED_OUT:
|
||||
return CoverageWorkerStatus.checkedOut;
|
||||
case dc.ApplicationStatus.LATE:
|
||||
return CoverageWorkerStatus.late;
|
||||
case dc.ApplicationStatus.NO_SHOW:
|
||||
return CoverageWorkerStatus.noShow;
|
||||
case dc.ApplicationStatus.COMPLETED:
|
||||
return CoverageWorkerStatus.completed;
|
||||
}
|
||||
}
|
||||
return CoverageWorkerStatus.pending;
|
||||
}
|
||||
|
||||
String? _formatTime(fdc.Timestamp? timestamp) {
|
||||
if (timestamp == null) {
|
||||
return null;
|
||||
}
|
||||
final DateTime date = timestamp.toDateTime().toLocal();
|
||||
final String hour = date.hour.toString().padLeft(2, '0');
|
||||
final String minute = date.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageGroup {
|
||||
_CoverageGroup({
|
||||
required this.shiftId,
|
||||
required this.roleId,
|
||||
required this.title,
|
||||
required this.location,
|
||||
required this.startTime,
|
||||
required this.workersNeeded,
|
||||
required this.date,
|
||||
required this.workers,
|
||||
});
|
||||
|
||||
final String shiftId;
|
||||
final String roleId;
|
||||
final String title;
|
||||
final String location;
|
||||
final String startTime;
|
||||
final int workersNeeded;
|
||||
final DateTime date;
|
||||
final List<CoverageWorker> workers;
|
||||
}
|
||||
|
||||
@@ -1,119 +1,26 @@
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/home_repository_interface.dart';
|
||||
|
||||
/// Implementation of [HomeRepositoryInterface] that delegates to [HomeRepositoryMock].
|
||||
/// Implementation of [HomeRepositoryInterface] that delegates to [dc.HomeConnectorRepository].
|
||||
///
|
||||
/// This implementation resides in the data layer and acts as a bridge between the
|
||||
/// domain layer and the data source (in this case, a mock from data_connect).
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class HomeRepositoryImpl implements HomeRepositoryInterface {
|
||||
/// Creates a [HomeRepositoryImpl].
|
||||
HomeRepositoryImpl(this._service);
|
||||
final dc.HomeConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
HomeRepositoryImpl({
|
||||
dc.HomeConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getHomeRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
@override
|
||||
Future<HomeDashboardData> getDashboardData() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||
final DateTime monday = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
).subtract(Duration(days: daysFromMonday));
|
||||
final DateTime weekRangeStart = DateTime(
|
||||
monday.year,
|
||||
monday.month,
|
||||
monday.day,
|
||||
);
|
||||
final DateTime weekRangeEnd = DateTime(
|
||||
monday.year,
|
||||
monday.month,
|
||||
monday.day + 13,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
final fdc.QueryResult<
|
||||
dc.GetCompletedShiftsByBusinessIdData,
|
||||
dc.GetCompletedShiftsByBusinessIdVariables
|
||||
>
|
||||
completedResult = await _service.connector
|
||||
.getCompletedShiftsByBusinessId(
|
||||
businessId: businessId,
|
||||
dateFrom: _service.toTimestamp(weekRangeStart),
|
||||
dateTo: _service.toTimestamp(weekRangeEnd),
|
||||
)
|
||||
.execute();
|
||||
|
||||
double weeklySpending = 0.0;
|
||||
double next7DaysSpending = 0.0;
|
||||
int weeklyShifts = 0;
|
||||
int next7DaysScheduled = 0;
|
||||
for (final dc.GetCompletedShiftsByBusinessIdShifts shift
|
||||
in completedResult.data.shifts) {
|
||||
final DateTime? shiftDate = shift.date?.toDateTime();
|
||||
if (shiftDate == null) {
|
||||
continue;
|
||||
}
|
||||
final int offset = shiftDate.difference(weekRangeStart).inDays;
|
||||
if (offset < 0 || offset > 13) {
|
||||
continue;
|
||||
}
|
||||
final double cost = shift.cost ?? 0.0;
|
||||
if (offset <= 6) {
|
||||
weeklySpending += cost;
|
||||
weeklyShifts += 1;
|
||||
} else {
|
||||
next7DaysSpending += cost;
|
||||
next7DaysScheduled += 1;
|
||||
}
|
||||
}
|
||||
|
||||
final DateTime start = DateTime(now.year, now.month, now.day);
|
||||
final DateTime end = DateTime(
|
||||
now.year,
|
||||
now.month,
|
||||
now.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
);
|
||||
|
||||
final fdc.QueryResult<
|
||||
dc.ListShiftRolesByBusinessAndDateRangeData,
|
||||
dc.ListShiftRolesByBusinessAndDateRangeVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listShiftRolesByBusinessAndDateRange(
|
||||
businessId: businessId,
|
||||
start: _service.toTimestamp(start),
|
||||
end: _service.toTimestamp(end),
|
||||
)
|
||||
.execute();
|
||||
|
||||
int totalNeeded = 0;
|
||||
int totalFilled = 0;
|
||||
for (final dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole
|
||||
in result.data.shiftRoles) {
|
||||
totalNeeded += shiftRole.count;
|
||||
totalFilled += shiftRole.assigned ?? 0;
|
||||
}
|
||||
|
||||
return HomeDashboardData(
|
||||
weeklySpending: weeklySpending,
|
||||
next7DaysSpending: next7DaysSpending,
|
||||
weeklyShifts: weeklyShifts,
|
||||
next7DaysScheduled: next7DaysScheduled,
|
||||
totalNeeded: totalNeeded,
|
||||
totalFilled: totalFilled,
|
||||
);
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getDashboardData(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -121,7 +28,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
final dc.ClientBusinessSession? business = session?.business;
|
||||
|
||||
// If session data is available, return it immediately
|
||||
if (business != null) {
|
||||
return UserSessionData(
|
||||
businessName: business.businessName,
|
||||
@@ -130,74 +36,38 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
||||
}
|
||||
|
||||
return await _service.run(() async {
|
||||
// If session is not initialized, attempt to fetch business data to populate session
|
||||
final String businessId = await _service.getBusinessId();
|
||||
final fdc.QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
|
||||
businessResult = await _service.connector
|
||||
final businessResult = await _service.connector
|
||||
.getBusinessById(id: businessId)
|
||||
.execute();
|
||||
|
||||
if (businessResult.data.business == null) {
|
||||
final b = businessResult.data.business;
|
||||
if (b == null) {
|
||||
throw Exception('Business data not found for ID: $businessId');
|
||||
}
|
||||
|
||||
final dc.ClientSession updatedSession = dc.ClientSession(
|
||||
final updatedSession = dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: businessResult.data.business!.id,
|
||||
businessName: businessResult.data.business?.businessName ?? '',
|
||||
email: businessResult.data.business?.email ?? '',
|
||||
city: businessResult.data.business?.city ?? '',
|
||||
contactName: businessResult.data.business?.contactName ?? '',
|
||||
companyLogoUrl: businessResult.data.business?.companyLogoUrl,
|
||||
id: b.id,
|
||||
businessName: b.businessName,
|
||||
email: b.email ?? '',
|
||||
city: b.city ?? '',
|
||||
contactName: b.contactName ?? '',
|
||||
companyLogoUrl: b.companyLogoUrl,
|
||||
),
|
||||
);
|
||||
dc.ClientSessionStore.instance.setSession(updatedSession);
|
||||
|
||||
return UserSessionData(
|
||||
businessName: businessResult.data.business!.businessName,
|
||||
photoUrl: businessResult.data.business!.companyLogoUrl,
|
||||
businessName: b.businessName,
|
||||
photoUrl: b.companyLogoUrl,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<ReorderItem>> getRecentReorders() async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final DateTime now = DateTime.now();
|
||||
final DateTime start = now.subtract(const Duration(days: 30));
|
||||
final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
|
||||
final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
|
||||
|
||||
final fdc.QueryResult<
|
||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
|
||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listShiftRolesByBusinessDateRangeCompletedOrders(
|
||||
businessId: businessId,
|
||||
start: startTimestamp,
|
||||
end: endTimestamp,
|
||||
)
|
||||
.execute();
|
||||
|
||||
return result.data.shiftRoles.map((
|
||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
|
||||
) {
|
||||
final String location =
|
||||
shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
|
||||
final String type = shiftRole.shift.order.orderType.stringValue;
|
||||
return ReorderItem(
|
||||
orderId: shiftRole.shift.order.id,
|
||||
title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
|
||||
location: location,
|
||||
hourlyRate: shiftRole.role.costPerHour,
|
||||
hours: shiftRole.hours ?? 0,
|
||||
workers: shiftRole.count,
|
||||
type: type,
|
||||
);
|
||||
}).toList();
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getRecentReorders(businessId: businessId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,38 +1,30 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:krow_core/core.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||
import 'package:krow_domain/krow_domain.dart'
|
||||
show
|
||||
HubHasOrdersException,
|
||||
BusinessNotFoundException,
|
||||
NotAuthenticatedException;
|
||||
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/hub_repository_interface.dart';
|
||||
|
||||
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
|
||||
/// Implementation of [HubRepositoryInterface] that delegates to [dc.HubsConnectorRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
HubRepositoryImpl({required dc.DataConnectService service})
|
||||
: _service = service;
|
||||
|
||||
final dc.HubsConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
HubRepositoryImpl({
|
||||
dc.HubsConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getHubsRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
@override
|
||||
Future<List<domain.Hub>> getHubs() async {
|
||||
return _service.run(() async {
|
||||
final dc.GetBusinessesByUserIdBusinesses business =
|
||||
await _getBusinessForCurrentUser();
|
||||
final String teamId = await _getOrCreateTeamId(business);
|
||||
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
|
||||
});
|
||||
Future<List<Hub>> getHubs() async {
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.getHubs(businessId: businessId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.Hub> createHub({
|
||||
Future<Hub> createHub({
|
||||
required String name,
|
||||
required String address,
|
||||
String? placeId,
|
||||
@@ -44,77 +36,26 @@ class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
String? country,
|
||||
String? zipCode,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final dc.GetBusinessesByUserIdBusinesses business =
|
||||
await _getBusinessForCurrentUser();
|
||||
final String teamId = await _getOrCreateTeamId(business);
|
||||
final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
|
||||
? null
|
||||
: await _fetchPlaceAddress(placeId);
|
||||
final String? cityValue = city ?? placeAddress?.city ?? business.city;
|
||||
final String? stateValue = state ?? placeAddress?.state;
|
||||
final String? streetValue = street ?? placeAddress?.street;
|
||||
final String? countryValue = country ?? placeAddress?.country;
|
||||
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
|
||||
|
||||
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
|
||||
result = await _service.connector
|
||||
.createTeamHub(teamId: teamId, hubName: name, address: address)
|
||||
.placeId(placeId)
|
||||
.latitude(latitude)
|
||||
.longitude(longitude)
|
||||
.city(cityValue?.isNotEmpty == true ? cityValue : '')
|
||||
.state(stateValue)
|
||||
.street(streetValue)
|
||||
.country(countryValue)
|
||||
.zipCode(zipCodeValue)
|
||||
.execute();
|
||||
final String createdId = result.data.teamHub_insert.id;
|
||||
|
||||
final List<domain.Hub> hubs = await _fetchHubsForTeam(
|
||||
teamId: teamId,
|
||||
businessId: business.id,
|
||||
);
|
||||
domain.Hub? createdHub;
|
||||
for (final domain.Hub hub in hubs) {
|
||||
if (hub.id == createdId) {
|
||||
createdHub = hub;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return createdHub ??
|
||||
domain.Hub(
|
||||
id: createdId,
|
||||
businessId: business.id,
|
||||
name: name,
|
||||
address: address,
|
||||
nfcTagId: null,
|
||||
status: domain.HubStatus.active,
|
||||
);
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.createHub(
|
||||
businessId: businessId,
|
||||
name: name,
|
||||
address: address,
|
||||
placeId: placeId,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
city: city,
|
||||
state: state,
|
||||
street: street,
|
||||
country: country,
|
||||
zipCode: zipCode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteHub(String id) async {
|
||||
return _service.run(() async {
|
||||
final String businessId = await _service.getBusinessId();
|
||||
|
||||
final QueryResult<
|
||||
dc.ListOrdersByBusinessAndTeamHubData,
|
||||
dc.ListOrdersByBusinessAndTeamHubVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
|
||||
.execute();
|
||||
|
||||
if (result.data.orders.isNotEmpty) {
|
||||
throw HubHasOrdersException(
|
||||
technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
|
||||
);
|
||||
}
|
||||
|
||||
await _service.connector.deleteTeamHub(id: id).execute();
|
||||
});
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.deleteHub(businessId: businessId, id: id);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -125,7 +66,7 @@ class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<domain.Hub> updateHub({
|
||||
Future<Hub> updateHub({
|
||||
required String id,
|
||||
String? name,
|
||||
String? address,
|
||||
@@ -138,283 +79,20 @@ class HubRepositoryImpl implements HubRepositoryInterface {
|
||||
String? country,
|
||||
String? zipCode,
|
||||
}) async {
|
||||
return _service.run(() async {
|
||||
final _PlaceAddress? placeAddress =
|
||||
placeId == null || placeId.isEmpty
|
||||
? null
|
||||
: await _fetchPlaceAddress(placeId);
|
||||
|
||||
final dc.UpdateTeamHubVariablesBuilder builder = _service.connector
|
||||
.updateTeamHub(id: id);
|
||||
|
||||
if (name != null) builder.hubName(name);
|
||||
if (address != null) builder.address(address);
|
||||
if (placeId != null || placeAddress != null) {
|
||||
builder.placeId(placeId ?? placeAddress?.street);
|
||||
}
|
||||
if (latitude != null) builder.latitude(latitude);
|
||||
if (longitude != null) builder.longitude(longitude);
|
||||
if (city != null || placeAddress?.city != null) {
|
||||
builder.city(city ?? placeAddress?.city);
|
||||
}
|
||||
if (state != null || placeAddress?.state != null) {
|
||||
builder.state(state ?? placeAddress?.state);
|
||||
}
|
||||
if (street != null || placeAddress?.street != null) {
|
||||
builder.street(street ?? placeAddress?.street);
|
||||
}
|
||||
if (country != null || placeAddress?.country != null) {
|
||||
builder.country(country ?? placeAddress?.country);
|
||||
}
|
||||
if (zipCode != null || placeAddress?.zipCode != null) {
|
||||
builder.zipCode(zipCode ?? placeAddress?.zipCode);
|
||||
}
|
||||
|
||||
await builder.execute();
|
||||
|
||||
final dc.GetBusinessesByUserIdBusinesses business =
|
||||
await _getBusinessForCurrentUser();
|
||||
final String teamId = await _getOrCreateTeamId(business);
|
||||
final List<domain.Hub> hubs = await _fetchHubsForTeam(
|
||||
teamId: teamId,
|
||||
businessId: business.id,
|
||||
);
|
||||
|
||||
for (final domain.Hub hub in hubs) {
|
||||
if (hub.id == id) return hub;
|
||||
}
|
||||
|
||||
// Fallback: return a reconstructed Hub from the update inputs.
|
||||
return domain.Hub(
|
||||
id: id,
|
||||
businessId: business.id,
|
||||
name: name ?? '',
|
||||
address: address ?? '',
|
||||
nfcTagId: null,
|
||||
status: domain.HubStatus.active,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<dc.GetBusinessesByUserIdBusinesses>
|
||||
_getBusinessForCurrentUser() async {
|
||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||
final dc.ClientBusinessSession? cachedBusiness = session?.business;
|
||||
if (cachedBusiness != null) {
|
||||
return dc.GetBusinessesByUserIdBusinesses(
|
||||
id: cachedBusiness.id,
|
||||
businessName: cachedBusiness.businessName,
|
||||
userId: _service.auth.currentUser?.uid ?? '',
|
||||
rateGroup: const dc.Known<dc.BusinessRateGroup>(
|
||||
dc.BusinessRateGroup.STANDARD,
|
||||
),
|
||||
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
|
||||
contactName: cachedBusiness.contactName,
|
||||
companyLogoUrl: cachedBusiness.companyLogoUrl,
|
||||
phone: null,
|
||||
email: cachedBusiness.email,
|
||||
hubBuilding: null,
|
||||
address: null,
|
||||
city: cachedBusiness.city,
|
||||
area: null,
|
||||
sector: null,
|
||||
notes: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
);
|
||||
}
|
||||
|
||||
final firebase.User? user = _service.auth.currentUser;
|
||||
if (user == null) {
|
||||
throw const NotAuthenticatedException(
|
||||
technicalMessage: 'No Firebase user in currentUser',
|
||||
);
|
||||
}
|
||||
|
||||
final QueryResult<
|
||||
dc.GetBusinessesByUserIdData,
|
||||
dc.GetBusinessesByUserIdVariables
|
||||
>
|
||||
result = await _service.connector
|
||||
.getBusinessesByUserId(userId: user.uid)
|
||||
.execute();
|
||||
if (result.data.businesses.isEmpty) {
|
||||
await _service.auth.signOut();
|
||||
throw BusinessNotFoundException(
|
||||
technicalMessage: 'No business found for user ${user.uid}',
|
||||
);
|
||||
}
|
||||
|
||||
final dc.GetBusinessesByUserIdBusinesses business =
|
||||
result.data.businesses.first;
|
||||
if (session != null) {
|
||||
dc.ClientSessionStore.instance.setSession(
|
||||
dc.ClientSession(
|
||||
business: dc.ClientBusinessSession(
|
||||
id: business.id,
|
||||
businessName: business.businessName,
|
||||
email: business.email,
|
||||
city: business.city,
|
||||
contactName: business.contactName,
|
||||
companyLogoUrl: business.companyLogoUrl,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return business;
|
||||
}
|
||||
|
||||
Future<String> _getOrCreateTeamId(
|
||||
dc.GetBusinessesByUserIdBusinesses business,
|
||||
) async {
|
||||
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
|
||||
teamsResult = await _service.connector
|
||||
.getTeamsByOwnerId(ownerId: business.id)
|
||||
.execute();
|
||||
if (teamsResult.data.teams.isNotEmpty) {
|
||||
return teamsResult.data.teams.first.id;
|
||||
}
|
||||
|
||||
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
|
||||
.createTeam(
|
||||
teamName: '${business.businessName} Team',
|
||||
ownerId: business.id,
|
||||
ownerName: business.contactName ?? '',
|
||||
ownerRole: 'OWNER',
|
||||
);
|
||||
if (business.email != null) {
|
||||
createTeamBuilder.email(business.email);
|
||||
}
|
||||
|
||||
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
|
||||
createTeamResult = await createTeamBuilder.execute();
|
||||
final String teamId = createTeamResult.data.team_insert.id;
|
||||
|
||||
return teamId;
|
||||
}
|
||||
|
||||
Future<List<domain.Hub>> _fetchHubsForTeam({
|
||||
required String teamId,
|
||||
required String businessId,
|
||||
}) async {
|
||||
final QueryResult<
|
||||
dc.GetTeamHubsByTeamIdData,
|
||||
dc.GetTeamHubsByTeamIdVariables
|
||||
>
|
||||
hubsResult = await _service.connector
|
||||
.getTeamHubsByTeamId(teamId: teamId)
|
||||
.execute();
|
||||
|
||||
return hubsResult.data.teamHubs
|
||||
.map(
|
||||
(dc.GetTeamHubsByTeamIdTeamHubs hub) => domain.Hub(
|
||||
id: hub.id,
|
||||
businessId: businessId,
|
||||
name: hub.hubName,
|
||||
address: hub.address,
|
||||
nfcTagId: null,
|
||||
status: hub.isActive
|
||||
? domain.HubStatus.active
|
||||
: domain.HubStatus.inactive,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<_PlaceAddress?> _fetchPlaceAddress(String placeId) async {
|
||||
final Uri uri = Uri.https(
|
||||
'maps.googleapis.com',
|
||||
'/maps/api/place/details/json',
|
||||
<String, String>{
|
||||
'place_id': placeId,
|
||||
'fields': 'address_component',
|
||||
'key': AppConfig.googleMapsApiKey,
|
||||
},
|
||||
final businessId = await _service.getBusinessId();
|
||||
return _connectorRepository.updateHub(
|
||||
businessId: businessId,
|
||||
id: id,
|
||||
name: name,
|
||||
address: address,
|
||||
placeId: placeId,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
city: city,
|
||||
state: state,
|
||||
street: street,
|
||||
country: country,
|
||||
zipCode: zipCode,
|
||||
);
|
||||
try {
|
||||
final http.Response response = await http.get(uri);
|
||||
if (response.statusCode != 200) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic> payload =
|
||||
json.decode(response.body) as Map<String, dynamic>;
|
||||
if (payload['status'] != 'OK') {
|
||||
return null;
|
||||
}
|
||||
|
||||
final Map<String, dynamic>? result =
|
||||
payload['result'] as Map<String, dynamic>?;
|
||||
final List<dynamic>? components =
|
||||
result?['address_components'] as List<dynamic>?;
|
||||
if (components == null || components.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String? streetNumber;
|
||||
String? route;
|
||||
String? city;
|
||||
String? state;
|
||||
String? country;
|
||||
String? zipCode;
|
||||
|
||||
for (final dynamic entry in components) {
|
||||
final Map<String, dynamic> component = entry as Map<String, dynamic>;
|
||||
final List<dynamic> types =
|
||||
component['types'] as List<dynamic>? ?? <dynamic>[];
|
||||
final String? longName = component['long_name'] as String?;
|
||||
final String? shortName = component['short_name'] as String?;
|
||||
|
||||
if (types.contains('street_number')) {
|
||||
streetNumber = longName;
|
||||
} else if (types.contains('route')) {
|
||||
route = longName;
|
||||
} else if (types.contains('locality')) {
|
||||
city = longName;
|
||||
} else if (types.contains('postal_town')) {
|
||||
city ??= longName;
|
||||
} else if (types.contains('administrative_area_level_2')) {
|
||||
city ??= longName;
|
||||
} else if (types.contains('administrative_area_level_1')) {
|
||||
state = shortName ?? longName;
|
||||
} else if (types.contains('country')) {
|
||||
country = shortName ?? longName;
|
||||
} else if (types.contains('postal_code')) {
|
||||
zipCode = longName;
|
||||
}
|
||||
}
|
||||
|
||||
final String streetValue = <String?>[streetNumber, route]
|
||||
.where((String? value) => value != null && value.isNotEmpty)
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
return _PlaceAddress(
|
||||
street: streetValue.isEmpty == true ? null : streetValue,
|
||||
city: city,
|
||||
state: state,
|
||||
country: country,
|
||||
zipCode: zipCode,
|
||||
);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceAddress {
|
||||
const _PlaceAddress({
|
||||
this.street,
|
||||
this.city,
|
||||
this.state,
|
||||
this.country,
|
||||
this.zipCode,
|
||||
});
|
||||
|
||||
final String? street;
|
||||
final String? city;
|
||||
final String? state;
|
||||
final String? country;
|
||||
final String? zipCode;
|
||||
}
|
||||
|
||||
@@ -1,493 +1,89 @@
|
||||
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 'package:krow_domain/krow_domain.dart';
|
||||
import '../../domain/repositories/reports_repository.dart';
|
||||
|
||||
/// Implementation of [ReportsRepository] that delegates to [ReportsConnectorRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class ReportsRepositoryImpl implements ReportsRepository {
|
||||
final DataConnectService _service;
|
||||
final ReportsConnectorRepository _connectorRepository;
|
||||
|
||||
ReportsRepositoryImpl({DataConnectService? service})
|
||||
: _service = service ?? DataConnectService.instance;
|
||||
ReportsRepositoryImpl({ReportsConnectorRepository? connectorRepository})
|
||||
: _connectorRepository = connectorRepository ?? DataConnectService.instance.getReportsRepository();
|
||||
|
||||
@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,
|
||||
}) => _connectorRepository.getDailyOpsReport(
|
||||
businessId: businessId,
|
||||
date: date,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SpendReport> getSpendReport({
|
||||
String? businessId,
|
||||
required DateTime startDate,
|
||||
required DateTime endDate,
|
||||
}) async {
|
||||
return await _service.run(() async {
|
||||
final String id = businessId ?? await _service.getBusinessId();
|
||||
final response = await _service.connector
|
||||
.listInvoicesForSpendByBusiness(
|
||||
businessId: id,
|
||||
startDate: _service.toTimestamp(startDate),
|
||||
endDate: _service.toTimestamp(endDate),
|
||||
)
|
||||
.execute();
|
||||
|
||||
final invoices = response.data.invoices;
|
||||
|
||||
double totalSpend = 0.0;
|
||||
int paidInvoices = 0;
|
||||
int pendingInvoices = 0;
|
||||
int overdueInvoices = 0;
|
||||
|
||||
final List<SpendInvoice> spendInvoices = [];
|
||||
final Map<DateTime, double> dailyAggregates = {};
|
||||
final Map<String, double> industryAggregates = {};
|
||||
|
||||
for (final inv in invoices) {
|
||||
final amount = (inv.amount ?? 0.0).toDouble();
|
||||
totalSpend += amount;
|
||||
|
||||
final statusStr = inv.status.stringValue;
|
||||
if (statusStr == 'PAID') {
|
||||
paidInvoices++;
|
||||
} else if (statusStr == 'PENDING') {
|
||||
pendingInvoices++;
|
||||
} else if (statusStr == 'OVERDUE') {
|
||||
overdueInvoices++;
|
||||
}
|
||||
|
||||
final industry = inv.vendor?.serviceSpecialty ?? 'Other';
|
||||
industryAggregates[industry] = (industryAggregates[industry] ?? 0.0) + amount;
|
||||
|
||||
final issueDateTime = inv.issueDate.toDateTime();
|
||||
spendInvoices.add(SpendInvoice(
|
||||
id: inv.id,
|
||||
invoiceNumber: inv.invoiceNumber ?? '',
|
||||
issueDate: issueDateTime,
|
||||
amount: amount,
|
||||
status: statusStr,
|
||||
vendorName: inv.vendor?.companyName ?? 'Unknown',
|
||||
industry: industry,
|
||||
));
|
||||
|
||||
// Chart data aggregation
|
||||
final date = DateTime(issueDateTime.year, issueDateTime.month, issueDateTime.day);
|
||||
dailyAggregates[date] = (dailyAggregates[date] ?? 0.0) + amount;
|
||||
}
|
||||
|
||||
// Ensure chart data covers all days in range
|
||||
final Map<DateTime, double> completeDailyAggregates = {};
|
||||
for (int i = 0; i <= endDate.difference(startDate).inDays; i++) {
|
||||
final date = startDate.add(Duration(days: i));
|
||||
final normalizedDate = DateTime(date.year, date.month, date.day);
|
||||
completeDailyAggregates[normalizedDate] =
|
||||
dailyAggregates[normalizedDate] ?? 0.0;
|
||||
}
|
||||
|
||||
final List<SpendChartPoint> chartData = completeDailyAggregates.entries
|
||||
.map((e) => SpendChartPoint(date: e.key, amount: e.value))
|
||||
.toList()
|
||||
..sort((a, b) => a.date.compareTo(b.date));
|
||||
|
||||
final List<SpendIndustryCategory> industryBreakdown = industryAggregates.entries
|
||||
.map((e) => SpendIndustryCategory(
|
||||
name: e.key,
|
||||
amount: e.value,
|
||||
percentage: totalSpend > 0 ? (e.value / totalSpend * 100) : 0,
|
||||
))
|
||||
.toList()
|
||||
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||
|
||||
final daysCount = endDate.difference(startDate).inDays + 1;
|
||||
|
||||
return SpendReport(
|
||||
totalSpend: totalSpend,
|
||||
averageCost: daysCount > 0 ? totalSpend / daysCount : 0,
|
||||
paidInvoices: paidInvoices,
|
||||
pendingInvoices: pendingInvoices,
|
||||
overdueInvoices: overdueInvoices,
|
||||
invoices: spendInvoices,
|
||||
chartData: chartData,
|
||||
industryBreakdown: industryBreakdown,
|
||||
}) => _connectorRepository.getSpendReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
}) => _connectorRepository.getCoverageReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
}) => _connectorRepository.getForecastReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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),
|
||||
],
|
||||
}) => _connectorRepository.getPerformanceReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
}) => _connectorRepository.getNoShowReport(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@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,
|
||||
}) => _connectorRepository.getReportsSummary(
|
||||
businessId: businessId,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
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';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ReportsRepository {
|
||||
Future<DailyOpsReport> getDailyOpsReport({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/daily_ops_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class DailyOpsState extends Equatable {
|
||||
const DailyOpsState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/forecast_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ForecastState extends Equatable {
|
||||
const ForecastState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/no_show_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class NoShowState extends Equatable {
|
||||
const NoShowState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/performance_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class PerformanceState extends Equatable {
|
||||
const PerformanceState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/spend_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class SpendState extends Equatable {
|
||||
const SpendState();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import '../../../domain/entities/reports_summary.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
abstract class ReportsSummaryState extends Equatable {
|
||||
const ReportsSummaryState();
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
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:krow_domain/krow_domain.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> {
|
||||
final DateTime _startDate = DateTime.now();
|
||||
final DateTime _endDate = DateTime.now().add(const Duration(days: 14));
|
||||
|
||||
@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.tagInProgress],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 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: _CoverageSummaryCard(
|
||||
label: context.t.client_reports.coverage_report.metrics.avg_coverage,
|
||||
value: '${report.overallCoverage.toStringAsFixed(1)}%',
|
||||
icon: UiIcons.chart,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _CoverageSummaryCard(
|
||||
label: context.t.client_reports.coverage_report.metrics.full,
|
||||
value: '${report.totalFilled}/${report.totalNeeded}',
|
||||
icon: UiIcons.users,
|
||||
color: UiColors.success,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Daily List
|
||||
Text(
|
||||
context.t.client_reports.coverage_report.next_7_days,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (report.dailyCoverage.isEmpty)
|
||||
Center(child: Text(context.t.client_reports.coverage_report.empty_state))
|
||||
else
|
||||
...report.dailyCoverage.map((day) => _CoverageListItem(
|
||||
date: DateFormat('EEE, MMM dd').format(day.date),
|
||||
needed: day.needed,
|
||||
filled: day.filled,
|
||||
percentage: day.percentage,
|
||||
)),
|
||||
const SizedBox(height: 100),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CoverageSummaryCard extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _CoverageSummaryCard({
|
||||
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 _CoverageListItem extends StatelessWidget {
|
||||
final String date;
|
||||
final int needed;
|
||||
final int filled;
|
||||
final double percentage;
|
||||
|
||||
const _CoverageListItem({
|
||||
required this.date,
|
||||
required this.needed,
|
||||
required this.filled,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Color statusColor;
|
||||
if (percentage >= 100) {
|
||||
statusColor = UiColors.success;
|
||||
} else if (percentage >= 80) {
|
||||
statusColor = UiColors.textWarning;
|
||||
} else {
|
||||
statusColor = UiColors.destructive;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 4),
|
||||
// Progress Bar
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: percentage / 100,
|
||||
backgroundColor: UiColors.bgMenu,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(statusColor),
|
||||
minHeight: 6,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$filled/$needed',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(
|
||||
'${percentage.toStringAsFixed(0)}%',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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:krow_domain/krow_domain.dart';
|
||||
import 'package:core_localization/core_localization.dart';
|
||||
import 'package:design_system/design_system.dart';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
@@ -18,8 +18,8 @@ class ForecastReportPage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
DateTime _startDate = DateTime.now();
|
||||
DateTime _endDate = DateTime.now().add(const Duration(days: 14));
|
||||
final DateTime _startDate = DateTime.now();
|
||||
final DateTime _endDate = DateTime.now().add(const Duration(days: 28)); // 4 weeks
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -44,159 +44,48 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
child: Column(
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 32,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, UiColors.tagInProgress],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.white,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.forecast_report
|
||||
.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: UiColors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildHeader(context),
|
||||
|
||||
// Content
|
||||
Transform.translate(
|
||||
offset: const Offset(0, -16),
|
||||
offset: const Offset(0, -20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Summary Cards
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ForecastSummaryCard(
|
||||
label: context.t.client_reports.forecast_report.metrics.projected_spend,
|
||||
value: NumberFormat.currency(symbol: r'$')
|
||||
.format(report.projectedSpend),
|
||||
icon: UiIcons.dollar,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _ForecastSummaryCard(
|
||||
label: context.t.client_reports.forecast_report.metrics.workers_needed,
|
||||
value: report.projectedWorkers.toString(),
|
||||
icon: UiIcons.users,
|
||||
color: UiColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Chart
|
||||
Container(
|
||||
height: 300,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: UiColors.black.withOpacity(0.04),
|
||||
blurRadius: 10,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.chart_title,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: _ForecastChart(
|
||||
points: report.chartData,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Daily List
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.daily_projections,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: UiColors.textSecondary,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
// Metrics Grid
|
||||
_buildMetricsGrid(context, report),
|
||||
const SizedBox(height: 16),
|
||||
if (report.chartData.isEmpty)
|
||||
Center(child: Text(context.t.client_reports.forecast_report.empty_state))
|
||||
|
||||
// Chart Section
|
||||
_buildChartSection(context, report),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Weekly Breakdown Title
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.weekly_breakdown.title,
|
||||
style: UiTypography.titleUppercase2m.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Weekly Breakdown List
|
||||
if (report.weeklyBreakdown.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Text(
|
||||
context.t.client_reports.forecast_report.empty_state,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
)
|
||||
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),
|
||||
...report.weeklyBreakdown.map(
|
||||
(week) => _WeeklyBreakdownItem(week: week),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -211,25 +100,135 @@ class _ForecastReportPageState extends State<ForecastReportPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 60,
|
||||
left: 20,
|
||||
right: 20,
|
||||
bottom: 40,
|
||||
),
|
||||
decoration: const BoxDecoration(
|
||||
color: UiColors.primary,
|
||||
gradient: LinearGradient(
|
||||
colors: [UiColors.primary, Color(0xFF0020A0)], // Deep blue gradient
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white.withOpacity(0.2),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
UiIcons.arrowLeft,
|
||||
color: UiColors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.title,
|
||||
style: UiTypography.headline3m.copyWith(color: UiColors.white),
|
||||
),
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.subtitle,
|
||||
style: UiTypography.body2m.copyWith(
|
||||
color: UiColors.white.withOpacity(0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
/*
|
||||
UiButton.secondary(
|
||||
text: context.t.client_reports.forecast_report.buttons.export,
|
||||
leadingIcon: UiIcons.download,
|
||||
onPressed: () {
|
||||
// Placeholder export action
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.t.client_reports.forecast_report.placeholders.export_message),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// If button variants are limited, we might need a custom button or adjust design system usage
|
||||
// Since I can't easily see UiButton implementation details beyond exports, I'll stick to a standard usage.
|
||||
// If UiButton doesn't look right on blue bg, I count rely on it being white/transparent based on tokens.
|
||||
),
|
||||
*/
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricsGrid(BuildContext context, ForecastReport report) {
|
||||
final t = context.t.client_reports.forecast_report;
|
||||
return GridView.count(
|
||||
crossAxisCount: 2,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 12,
|
||||
crossAxisSpacing: 12,
|
||||
childAspectRatio: 1.3,
|
||||
children: [
|
||||
_MetricCard(
|
||||
icon: UiIcons.dollar,
|
||||
label: t.metrics.four_week_forecast,
|
||||
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.projectedSpend),
|
||||
badgeText: t.badges.total_projected,
|
||||
iconColor: UiColors.textWarning,
|
||||
badgeColor: UiColors.tagPending, // Yellow-ish
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.trendingUp,
|
||||
label: t.metrics.avg_weekly,
|
||||
value: NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(report.avgWeeklySpend),
|
||||
badgeText: t.badges.per_week,
|
||||
iconColor: UiColors.primary,
|
||||
badgeColor: UiColors.tagInProgress, // Blue-ish
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.calendar,
|
||||
label: t.metrics.total_shifts,
|
||||
value: report.totalShifts.toString(),
|
||||
badgeText: t.badges.scheduled,
|
||||
iconColor: const Color(0xFF9333EA), // Purple
|
||||
badgeColor: const Color(0xFFF3E8FF), // Purple light
|
||||
),
|
||||
_MetricCard(
|
||||
icon: UiIcons.users,
|
||||
label: t.metrics.total_hours,
|
||||
value: report.totalHours.toStringAsFixed(0),
|
||||
badgeText: t.badges.worker_hours,
|
||||
iconColor: UiColors.success,
|
||||
badgeColor: UiColors.tagSuccess,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection(BuildContext context, ForecastReport report) {
|
||||
return Container(
|
||||
height: 320,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
@@ -243,24 +242,178 @@ class _ForecastSummaryCard extends StatelessWidget {
|
||||
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),
|
||||
Text(
|
||||
context.t.client_reports.forecast_report.chart_title,
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
r'$15k', // Example Y-axis label placeholder or dynamic max
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Expanded(
|
||||
child: _ForecastChart(points: report.chartData),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// X Axis labels manually if chart doesn't handle them perfectly or for custom look
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
Text('W1', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
|
||||
Text('W1', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
|
||||
Text('W2', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
|
||||
Text('W2', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
|
||||
Text('W3', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
|
||||
Text('W3', style: TextStyle(color: UiColors.transparent, fontSize: 12)), // Spacer
|
||||
Text('W4', style: TextStyle(color: UiColors.textSecondary, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
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 _MetricCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String badgeText;
|
||||
final Color iconColor;
|
||||
final Color badgeColor;
|
||||
|
||||
const _MetricCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.badgeText,
|
||||
required this.iconColor,
|
||||
required this.badgeColor,
|
||||
});
|
||||
|
||||
@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: 8,
|
||||
),
|
||||
],
|
||||
),
|
||||
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: UiTypography.footnote1r.textSecondary,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
value,
|
||||
style: UiTypography.headline3m.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
badgeText,
|
||||
style: UiTypography.footnote1r.copyWith(
|
||||
color: UiColors.textPrimary, // Or specific text color
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _WeeklyBreakdownItem extends StatelessWidget {
|
||||
final ForecastWeek week;
|
||||
|
||||
const _WeeklyBreakdownItem({required this.week});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.t.client_reports.forecast_report.weekly_breakdown;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
t.week(index: week.weekNumber),
|
||||
style: UiTypography.headline4m,
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.tagPending,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.totalCost),
|
||||
style: UiTypography.body2b.copyWith(
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildStat(t.shifts, week.shiftsCount.toString()),
|
||||
_buildStat(t.hours, week.hoursCount.toStringAsFixed(0)),
|
||||
_buildStat(t.avg_shift, NumberFormat.currency(symbol: r'$', decimalDigits: 0).format(week.avgCostPerShift)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStat(String label, String value) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label, style: UiTypography.footnote1r.textSecondary),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: UiTypography.body1m),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastChart extends StatelessWidget {
|
||||
final List<ForecastPoint> points;
|
||||
|
||||
@@ -268,51 +421,51 @@ class _ForecastChart extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// If no data, show empty or default line?
|
||||
if (points.isEmpty) return const SizedBox();
|
||||
|
||||
return LineChart(
|
||||
LineChartData(
|
||||
gridData: const FlGridData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
gridData: FlGridData(
|
||||
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)),
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: 5000, // Dynamic?
|
||||
getDrawingHorizontalLine: (value) {
|
||||
return FlLine(
|
||||
color: UiColors.borderInactive,
|
||||
strokeWidth: 1,
|
||||
dashArray: [5, 5],
|
||||
);
|
||||
},
|
||||
),
|
||||
titlesData: const FlTitlesData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
minX: 0,
|
||||
maxX: points.length.toDouble() - 1,
|
||||
// minY: 0, // Let it scale automatically
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: points
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => FlSpot(e.key.toDouble(), e.value.projectedCost))
|
||||
.toList(),
|
||||
spots: points.asMap().entries.map((e) {
|
||||
return FlSpot(e.key.toDouble(), e.value.projectedCost);
|
||||
}).toList(),
|
||||
isCurved: true,
|
||||
color: UiColors.primary,
|
||||
color: UiColors.textWarning, // Orange-ish
|
||||
barWidth: 4,
|
||||
isStrokeCapRound: true,
|
||||
dotData: const FlDotData(show: false),
|
||||
dotData: FlDotData(
|
||||
show: true,
|
||||
getDotPainter: (spot, percent, barData, index) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 4,
|
||||
color: UiColors.textWarning,
|
||||
strokeWidth: 2,
|
||||
strokeColor: UiColors.white,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: UiColors.primary.withOpacity(0.1),
|
||||
color: UiColors.tagPending.withOpacity(0.5), // Light orange fill
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -320,40 +473,3 @@ class _ForecastChart extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ForecastListItem extends StatelessWidget {
|
||||
final String date;
|
||||
final String cost;
|
||||
final String workers;
|
||||
|
||||
const _ForecastListItem({
|
||||
required this.date,
|
||||
required this.cost,
|
||||
required this.workers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(date, style: const TextStyle(fontWeight: FontWeight.bold)),
|
||||
Text(context.t.client_reports.forecast_report.shift_item.workers_needed(count: workers), style: const TextStyle(fontSize: 11, color: UiColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
Text(cost, style: const TextStyle(fontWeight: FontWeight.bold, color: UiColors.primary)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'package:client_reports/src/domain/entities/no_show_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_bloc.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_event.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/no_show/no_show_state.dart';
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:client_reports/src/domain/entities/spend_report.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
class SpendReportPage extends StatefulWidget {
|
||||
const SpendReportPage({super.key});
|
||||
|
||||
@@ -50,6 +50,14 @@ class QuickReportsSection extends StatelessWidget {
|
||||
iconColor: UiColors.success,
|
||||
route: './spend',
|
||||
),
|
||||
// Coverage Report
|
||||
ReportCard(
|
||||
icon: UiIcons.users,
|
||||
name: context.t.client_reports.quick_reports.cards.coverage,
|
||||
iconBgColor: UiColors.tagInProgress,
|
||||
iconColor: UiColors.primary,
|
||||
route: './coverage',
|
||||
),
|
||||
// No-Show Rates
|
||||
ReportCard(
|
||||
icon: UiIcons.warning,
|
||||
@@ -58,6 +66,14 @@ class QuickReportsSection extends StatelessWidget {
|
||||
iconColor: UiColors.destructive,
|
||||
route: './no-show',
|
||||
),
|
||||
// Forecast Report
|
||||
ReportCard(
|
||||
icon: UiIcons.trendingUp,
|
||||
name: context.t.client_reports.quick_reports.cards.forecast,
|
||||
iconBgColor: UiColors.tagPending,
|
||||
iconColor: UiColors.textWarning,
|
||||
route: './forecast',
|
||||
),
|
||||
// Performance Reports
|
||||
ReportCard(
|
||||
icon: UiIcons.chart,
|
||||
|
||||
@@ -12,6 +12,8 @@ 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:client_reports/src/presentation/pages/coverage_report_page.dart';
|
||||
import 'package:client_reports/src/presentation/blocs/coverage/coverage_bloc.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||
|
||||
@@ -24,6 +26,7 @@ class ReportsModule extends Module {
|
||||
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);
|
||||
@@ -35,6 +38,7 @@ class ReportsModule extends Module {
|
||||
r.child('/', child: (_) => const ReportsPage());
|
||||
r.child('/daily-ops', child: (_) => const DailyOpsReportPage());
|
||||
r.child('/spend', child: (_) => const SpendReportPage());
|
||||
r.child('/coverage', child: (_) => const CoverageReportPage());
|
||||
r.child('/forecast', child: (_) => const ForecastReportPage());
|
||||
r.child('/performance', child: (_) => const PerformanceReportPage());
|
||||
r.child('/no-show', child: (_) => const NoShowReportPage());
|
||||
|
||||
@@ -29,6 +29,8 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
on<PersonalInfoFieldChanged>(_onFieldChanged);
|
||||
on<PersonalInfoAddressSelected>(_onAddressSelected);
|
||||
on<PersonalInfoFormSubmitted>(_onSubmitted);
|
||||
on<PersonalInfoLocationAdded>(_onLocationAdded);
|
||||
on<PersonalInfoLocationRemoved>(_onLocationRemoved);
|
||||
|
||||
add(const PersonalInfoLoadRequested());
|
||||
}
|
||||
@@ -133,11 +135,48 @@ class PersonalInfoBloc extends Bloc<PersonalInfoEvent, PersonalInfoState>
|
||||
PersonalInfoAddressSelected event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
// TODO: Implement Google Places logic if needed
|
||||
// Legacy address selected – no-op; use PersonalInfoLocationAdded instead.
|
||||
}
|
||||
|
||||
/// With _onPhotoUploadRequested and _onSaveRequested removed or renamed,
|
||||
/// there are no errors pointing to them here.
|
||||
/// Adds a location to the preferredLocations list (max 5, no duplicates).
|
||||
void _onLocationAdded(
|
||||
PersonalInfoLocationAdded event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
|
||||
if (current.length >= 5) return; // max guard
|
||||
if (current.contains(event.location)) return; // no duplicates
|
||||
|
||||
final List<String> updated = List<String>.from(current)..add(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
/// Removes a location from the preferredLocations list.
|
||||
void _onLocationRemoved(
|
||||
PersonalInfoLocationRemoved event,
|
||||
Emitter<PersonalInfoState> emit,
|
||||
) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
final List<String> current = _toStringList(raw);
|
||||
|
||||
final List<String> updated = List<String>.from(current)
|
||||
..remove(event.location);
|
||||
final Map<String, dynamic> updatedValues = Map<String, dynamic>.from(state.formValues)
|
||||
..['preferredLocations'] = updated;
|
||||
|
||||
emit(state.copyWith(formValues: updatedValues));
|
||||
}
|
||||
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
||||
@@ -40,3 +40,21 @@ class PersonalInfoAddressSelected extends PersonalInfoEvent {
|
||||
@override
|
||||
List<Object?> get props => <Object?>[address];
|
||||
}
|
||||
|
||||
/// Event to add a preferred location.
|
||||
class PersonalInfoLocationAdded extends PersonalInfoEvent {
|
||||
const PersonalInfoLocationAdded({required this.location});
|
||||
final String location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[location];
|
||||
}
|
||||
|
||||
/// Event to remove a preferred location.
|
||||
class PersonalInfoLocationRemoved extends PersonalInfoEvent {
|
||||
const PersonalInfoLocationRemoved({required this.location});
|
||||
final String location;
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[location];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
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:google_places_flutter/google_places_flutter.dart';
|
||||
import 'package:google_places_flutter/model/prediction.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
import '../blocs/personal_info_bloc.dart';
|
||||
import '../blocs/personal_info_event.dart';
|
||||
import '../blocs/personal_info_state.dart';
|
||||
|
||||
/// The maximum number of preferred locations a staff member can add.
|
||||
const int _kMaxLocations = 5;
|
||||
|
||||
/// Uber-style Preferred Locations editing page.
|
||||
///
|
||||
/// Allows staff to search for US locations using the Google Places API,
|
||||
/// add them as chips (max 5), and save back to their profile.
|
||||
class PreferredLocationsPage extends StatefulWidget {
|
||||
/// Creates a [PreferredLocationsPage].
|
||||
const PreferredLocationsPage({super.key});
|
||||
|
||||
@override
|
||||
State<PreferredLocationsPage> createState() => _PreferredLocationsPageState();
|
||||
}
|
||||
|
||||
class _PreferredLocationsPageState extends State<PreferredLocationsPage> {
|
||||
late final TextEditingController _searchController;
|
||||
late final FocusNode _searchFocusNode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchController = TextEditingController();
|
||||
_searchFocusNode = FocusNode();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_searchFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onLocationSelected(Prediction prediction, PersonalInfoBloc bloc) {
|
||||
final String description = prediction.description ?? '';
|
||||
if (description.isEmpty) return;
|
||||
|
||||
bloc.add(PersonalInfoLocationAdded(location: description));
|
||||
|
||||
// Clear search field after selection
|
||||
_searchController.clear();
|
||||
_searchFocusNode.unfocus();
|
||||
}
|
||||
|
||||
void _removeLocation(String location, PersonalInfoBloc bloc) {
|
||||
bloc.add(PersonalInfoLocationRemoved(location: location));
|
||||
}
|
||||
|
||||
void _save(BuildContext context, PersonalInfoBloc bloc, PersonalInfoState state) {
|
||||
bloc.add(const PersonalInfoFormSubmitted());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final i18n = t.staff.onboarding.personal_info;
|
||||
// Access the same PersonalInfoBloc singleton managed by the module.
|
||||
final PersonalInfoBloc bloc = Modular.get<PersonalInfoBloc>();
|
||||
|
||||
return BlocProvider<PersonalInfoBloc>.value(
|
||||
value: bloc,
|
||||
child: BlocConsumer<PersonalInfoBloc, PersonalInfoState>(
|
||||
listener: (BuildContext context, PersonalInfoState state) {
|
||||
if (state.status == PersonalInfoStatus.saved) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: i18n.preferred_locations.save_success,
|
||||
type: UiSnackbarType.success,
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
} else if (state.status == PersonalInfoStatus.error) {
|
||||
UiSnackbar.show(
|
||||
context,
|
||||
message: state.errorMessage != null
|
||||
? translateErrorKey(state.errorMessage!)
|
||||
: 'An error occurred',
|
||||
type: UiSnackbarType.error,
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, PersonalInfoState state) {
|
||||
final List<String> locations = _currentLocations(state);
|
||||
final bool atMax = locations.length >= _kMaxLocations;
|
||||
final bool isSaving = state.status == PersonalInfoStatus.saving;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: UiColors.bgPopup,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(UiIcons.chevronLeft, color: UiColors.textSecondary),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
),
|
||||
title: Text(
|
||||
i18n.preferred_locations.title,
|
||||
style: UiTypography.title1m.textPrimary,
|
||||
),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
// ── Description
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space5,
|
||||
UiConstants.space3,
|
||||
),
|
||||
child: Text(
|
||||
i18n.preferred_locations.description,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
// ── Search autocomplete field
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: _PlacesSearchField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
hint: i18n.preferred_locations.search_hint,
|
||||
enabled: !atMax && !isSaving,
|
||||
onSelected: (Prediction p) => _onLocationSelected(p, bloc),
|
||||
),
|
||||
),
|
||||
|
||||
// ── "Max reached" banner
|
||||
if (atMax)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
UiConstants.space5,
|
||||
UiConstants.space2,
|
||||
UiConstants.space5,
|
||||
0,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
const Icon(
|
||||
UiIcons.info,
|
||||
size: 14,
|
||||
color: UiColors.textWarning,
|
||||
),
|
||||
const SizedBox(width: UiConstants.space1),
|
||||
Text(
|
||||
i18n.preferred_locations.max_reached,
|
||||
style: UiTypography.footnote1r.textWarning,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space5),
|
||||
|
||||
// ── Section label
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space5,
|
||||
),
|
||||
child: Text(
|
||||
i18n.preferred_locations.added_label,
|
||||
style: UiTypography.titleUppercase3m.textSecondary,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: UiConstants.space3),
|
||||
|
||||
// ── Locations list / empty state
|
||||
Expanded(
|
||||
child: locations.isEmpty
|
||||
? _EmptyLocationsState(message: i18n.preferred_locations.empty_state)
|
||||
: _LocationsList(
|
||||
locations: locations,
|
||||
isSaving: isSaving,
|
||||
removeTooltip: i18n.preferred_locations.remove_tooltip,
|
||||
onRemove: (String loc) => _removeLocation(loc, bloc),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Save button
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: UiButton.primary(
|
||||
text: i18n.preferred_locations.save_button,
|
||||
fullWidth: true,
|
||||
onPressed: isSaving ? null : () => _save(context, bloc, state),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _currentLocations(PersonalInfoState state) {
|
||||
final dynamic raw = state.formValues['preferredLocations'];
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Subwidgets
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Google Places autocomplete search field, locked to US results.
|
||||
class _PlacesSearchField extends StatelessWidget {
|
||||
const _PlacesSearchField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.hint,
|
||||
required this.onSelected,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final String hint;
|
||||
final bool enabled;
|
||||
final void Function(Prediction) onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GooglePlaceAutoCompleteTextField(
|
||||
textEditingController: controller,
|
||||
focusNode: focusNode,
|
||||
googleAPIKey: AppConfig.googleMapsApiKey,
|
||||
debounceTime: 400,
|
||||
countries: const <String>['us'],
|
||||
isLatLngRequired: false,
|
||||
getPlaceDetailWithLatLng: onSelected,
|
||||
itemClick: (Prediction prediction) {
|
||||
controller.text = prediction.description ?? '';
|
||||
controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: controller.text.length),
|
||||
);
|
||||
onSelected(prediction);
|
||||
},
|
||||
inputDecoration: InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: UiTypography.body2r.textSecondary,
|
||||
prefixIcon: const Icon(UiIcons.search, color: UiColors.iconSecondary, size: 20),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(UiIcons.close, size: 18, color: UiColors.iconSecondary),
|
||||
onPressed: controller.clear,
|
||||
)
|
||||
: null,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.primary, width: 1.5),
|
||||
),
|
||||
disabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: BorderSide(color: UiColors.border.withValues(alpha: 0.5)),
|
||||
),
|
||||
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
filled: true,
|
||||
),
|
||||
textStyle: UiTypography.body2r.textPrimary,
|
||||
itemBuilder: (BuildContext context, int index, Prediction prediction) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space2,
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(UiConstants.space2),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(4.0),
|
||||
),
|
||||
child: const Icon(UiIcons.mapPin, size: 16, color: UiColors.primary),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
_mainText(prediction.description ?? ''),
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (_subText(prediction.description ?? '').isNotEmpty)
|
||||
Text(
|
||||
_subText(prediction.description ?? ''),
|
||||
style: UiTypography.footnote1r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts text before first comma as the primary line.
|
||||
String _mainText(String description) {
|
||||
final int commaIndex = description.indexOf(',');
|
||||
return commaIndex > 0 ? description.substring(0, commaIndex) : description;
|
||||
}
|
||||
|
||||
/// Extracts text after first comma as the secondary line.
|
||||
String _subText(String description) {
|
||||
final int commaIndex = description.indexOf(',');
|
||||
return commaIndex > 0 ? description.substring(commaIndex + 1).trim() : '';
|
||||
}
|
||||
}
|
||||
|
||||
/// The scrollable list of location chips.
|
||||
class _LocationsList extends StatelessWidget {
|
||||
const _LocationsList({
|
||||
required this.locations,
|
||||
required this.isSaving,
|
||||
required this.removeTooltip,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final List<String> locations;
|
||||
final bool isSaving;
|
||||
final String removeTooltip;
|
||||
final void Function(String) onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||
itemCount: locations.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: UiConstants.space2),
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
final String location = locations[index];
|
||||
return _LocationChip(
|
||||
label: location,
|
||||
index: index + 1,
|
||||
total: locations.length,
|
||||
isSaving: isSaving,
|
||||
removeTooltip: removeTooltip,
|
||||
onRemove: () => onRemove(location),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A single location row with pin icon, label, and remove button.
|
||||
class _LocationChip extends StatelessWidget {
|
||||
const _LocationChip({
|
||||
required this.label,
|
||||
required this.index,
|
||||
required this.total,
|
||||
required this.isSaving,
|
||||
required this.removeTooltip,
|
||||
required this.onRemove,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int index;
|
||||
final int total;
|
||||
final bool isSaving;
|
||||
final String removeTooltip;
|
||||
final VoidCallback onRemove;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space4,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(color: UiColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
// Index badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text(
|
||||
'$index',
|
||||
style: UiTypography.footnote1m.copyWith(color: UiColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
|
||||
// Pin icon
|
||||
const Icon(UiIcons.mapPin, size: 16, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
|
||||
// Location text
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// Remove button
|
||||
if (!isSaving)
|
||||
Tooltip(
|
||||
message: removeTooltip,
|
||||
child: GestureDetector(
|
||||
onTap: onRemove,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space1),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgSecondary,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.close, size: 14, color: UiColors.iconSecondary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows when no locations have been added yet.
|
||||
class _EmptyLocationsState extends StatelessWidget {
|
||||
const _EmptyLocationsState({required this.message});
|
||||
|
||||
final String message;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(UiConstants.space8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(UiIcons.mapPin, size: 28, color: UiColors.primary),
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: UiTypography.body2r.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -34,26 +34,22 @@ class PersonalInfoContent extends StatefulWidget {
|
||||
class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
late final TextEditingController _emailController;
|
||||
late final TextEditingController _phoneController;
|
||||
late final TextEditingController _locationsController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_emailController = TextEditingController(text: widget.staff.email);
|
||||
_phoneController = TextEditingController(text: widget.staff.phone ?? '');
|
||||
_locationsController = TextEditingController(text: widget.staff.preferredLocations?.join(', ')?? '');
|
||||
|
||||
// Listen to changes and update BLoC
|
||||
_emailController.addListener(_onEmailChanged);
|
||||
_phoneController.addListener(_onPhoneChanged);
|
||||
_locationsController.addListener(_onAddressChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
_phoneController.dispose();
|
||||
_locationsController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -76,23 +72,6 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAddressChanged() {
|
||||
// Split the comma-separated string into a list for storage
|
||||
// The backend expects List<AnyValue> (JSON/List) for preferredLocations
|
||||
final List<String> locations = _locationsController.text
|
||||
.split(',')
|
||||
.map((String e) => e.trim())
|
||||
.where((String e) => e.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
context.read<PersonalInfoBloc>().add(
|
||||
PersonalInfoFieldChanged(
|
||||
field: 'preferredLocations',
|
||||
value: locations,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleSave() {
|
||||
context.read<PersonalInfoBloc>().add(const PersonalInfoFormSubmitted());
|
||||
}
|
||||
@@ -129,7 +108,7 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
email: widget.staff.email,
|
||||
emailController: _emailController,
|
||||
phoneController: _phoneController,
|
||||
locationsController: _locationsController,
|
||||
currentLocations: _toStringList(state.formValues['preferredLocations']),
|
||||
enabled: !isSaving,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space16), // Space for bottom button
|
||||
@@ -147,4 +126,10 @@ class _PersonalInfoContentState extends State<PersonalInfoContent> {
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _toStringList(dynamic raw) {
|
||||
if (raw is List<String>) return raw;
|
||||
if (raw is List) return raw.map((dynamic e) => e.toString()).toList();
|
||||
return <String>[];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ import 'package:design_system/design_system.dart';
|
||||
import 'package:flutter_modular/flutter_modular.dart';
|
||||
import 'package:krow_core/core.dart';
|
||||
|
||||
|
||||
/// A form widget containing all personal information fields.
|
||||
///
|
||||
/// Includes read-only fields for full name and email,
|
||||
/// and editable fields for phone and address.
|
||||
/// Includes read-only fields for full name,
|
||||
/// and editable fields for email and phone.
|
||||
/// The Preferred Locations row navigates to a dedicated Uber-style page.
|
||||
/// Uses only design system tokens for colors, typography, and spacing.
|
||||
class PersonalInfoForm extends StatelessWidget {
|
||||
|
||||
@@ -19,7 +19,7 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
required this.email,
|
||||
required this.emailController,
|
||||
required this.phoneController,
|
||||
required this.locationsController,
|
||||
required this.currentLocations,
|
||||
this.enabled = true,
|
||||
});
|
||||
/// The staff member's full name (read-only).
|
||||
@@ -34,8 +34,8 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
/// Controller for the phone number field.
|
||||
final TextEditingController phoneController;
|
||||
|
||||
/// Controller for the address field.
|
||||
final TextEditingController locationsController;
|
||||
/// Current preferred locations list to show in the summary row.
|
||||
final List<String> currentLocations;
|
||||
|
||||
/// Whether the form fields are enabled for editing.
|
||||
final bool enabled;
|
||||
@@ -43,6 +43,9 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final TranslationsStaffOnboardingPersonalInfoEn i18n = t.staff.onboarding.personal_info;
|
||||
final String locationSummary = currentLocations.isEmpty
|
||||
? i18n.locations_summary_none
|
||||
: currentLocations.join(', ');
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -69,15 +72,21 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
controller: phoneController,
|
||||
hint: i18n.phone_hint,
|
||||
enabled: enabled,
|
||||
keyboardType: TextInputType.phone,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
_FieldLabel(text: i18n.locations_label),
|
||||
const SizedBox(height: UiConstants.space2),
|
||||
_EditableField(
|
||||
controller: locationsController,
|
||||
// Uber-style tappable row → navigates to PreferredLocationsPage
|
||||
_TappableRow(
|
||||
value: locationSummary,
|
||||
hint: i18n.locations_hint,
|
||||
icon: UiIcons.mapPin,
|
||||
enabled: enabled,
|
||||
onTap: enabled
|
||||
? () => Modular.to.pushNamed(StaffPaths.preferredLocations)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
|
||||
@@ -91,6 +100,68 @@ class PersonalInfoForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// An Uber-style tappable row for navigating to a sub-page editor.
|
||||
/// Displays the current value (or hint if empty) and a chevron arrow.
|
||||
class _TappableRow extends StatelessWidget {
|
||||
const _TappableRow({
|
||||
required this.value,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.onTap,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final String value;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final VoidCallback? onTap;
|
||||
final bool enabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final bool hasValue = value.isNotEmpty;
|
||||
return GestureDetector(
|
||||
onTap: enabled ? onTap : null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(
|
||||
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
Icon(icon, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space2),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasValue ? value : hint,
|
||||
style: hasValue
|
||||
? UiTypography.body2r.textPrimary
|
||||
: UiTypography.body2r.textSecondary,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (enabled)
|
||||
Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A language selector widget that displays the current language and navigates to language selection page.
|
||||
class _LanguageSelector extends StatelessWidget {
|
||||
const _LanguageSelector({
|
||||
@@ -99,46 +170,43 @@ class _LanguageSelector extends StatelessWidget {
|
||||
|
||||
final bool enabled;
|
||||
|
||||
String _getLanguageLabel(AppLocale locale) {
|
||||
switch (locale) {
|
||||
case AppLocale.en:
|
||||
return 'English';
|
||||
case AppLocale.es:
|
||||
return 'Español';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AppLocale currentLocale = LocaleSettings.currentLocale;
|
||||
final String currentLanguage = _getLanguageLabel(currentLocale);
|
||||
final String currentLocale = Localizations.localeOf(context).languageCode;
|
||||
final String languageName = currentLocale == 'es' ? 'Español' : 'English';
|
||||
|
||||
return GestureDetector(
|
||||
onTap: enabled
|
||||
? () => Modular.to.pushNamed(StaffPaths.languageSelection)
|
||||
: null,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: UiConstants.space3,
|
||||
vertical: UiConstants.space3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: UiColors.bgPopup,
|
||||
color: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
border: Border.all(color: UiColors.border),
|
||||
border: Border.all(
|
||||
color: enabled ? UiColors.border : UiColors.border.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: <Widget>[
|
||||
Text(
|
||||
currentLanguage,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
Icon(
|
||||
UiIcons.chevronRight,
|
||||
color: UiColors.textSecondary,
|
||||
const Icon(UiIcons.settings, size: 18, color: UiColors.iconSecondary),
|
||||
const SizedBox(width: UiConstants.space3),
|
||||
Expanded(
|
||||
child: Text(
|
||||
languageName,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
),
|
||||
),
|
||||
if (enabled)
|
||||
const Icon(
|
||||
UiIcons.chevronRight,
|
||||
size: 18,
|
||||
color: UiColors.iconSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -146,10 +214,7 @@ class _LanguageSelector extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// A label widget for form fields.
|
||||
/// A label widget for form fields.
|
||||
class _FieldLabel extends StatelessWidget {
|
||||
|
||||
const _FieldLabel({required this.text});
|
||||
final String text;
|
||||
|
||||
@@ -157,13 +222,11 @@ class _FieldLabel extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
text,
|
||||
style: UiTypography.body2m.textPrimary,
|
||||
style: UiTypography.titleUppercase3m.textSecondary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only field widget for displaying non-editable information.
|
||||
/// A read-only field widget for displaying non-editable information.
|
||||
class _ReadOnlyField extends StatelessWidget {
|
||||
const _ReadOnlyField({required this.value});
|
||||
final String value;
|
||||
@@ -183,14 +246,12 @@ class _ReadOnlyField extends StatelessWidget {
|
||||
),
|
||||
child: Text(
|
||||
value,
|
||||
style: UiTypography.body2r.textPrimary,
|
||||
style: UiTypography.body2r.textInactive,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An editable text field widget.
|
||||
/// An editable text field widget.
|
||||
class _EditableField extends StatelessWidget {
|
||||
const _EditableField({
|
||||
required this.controller,
|
||||
@@ -232,7 +293,7 @@ class _EditableField extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(UiConstants.radiusMdValue),
|
||||
borderSide: const BorderSide(color: UiColors.primary),
|
||||
),
|
||||
fillColor: UiColors.bgPopup,
|
||||
fillColor: enabled ? UiColors.bgPopup : UiColors.bgSecondary,
|
||||
filled: true,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'domain/usecases/update_personal_info_usecase.dart';
|
||||
import 'presentation/blocs/personal_info_bloc.dart';
|
||||
import 'presentation/pages/personal_info_page.dart';
|
||||
import 'presentation/pages/language_selection_page.dart';
|
||||
import 'presentation/pages/preferred_locations_page.dart';
|
||||
|
||||
/// The entry module for the Staff Profile Info feature.
|
||||
///
|
||||
@@ -61,5 +62,12 @@ class StaffProfileInfoModule extends Module {
|
||||
),
|
||||
child: (BuildContext context) => const LanguageSelectionPage(),
|
||||
);
|
||||
r.child(
|
||||
StaffPaths.childRoute(
|
||||
StaffPaths.onboardingPersonalInfo,
|
||||
StaffPaths.preferredLocations,
|
||||
),
|
||||
child: (BuildContext context) => const PreferredLocationsPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ dependencies:
|
||||
|
||||
firebase_auth: any
|
||||
firebase_data_connect: any
|
||||
google_places_flutter: ^2.1.1
|
||||
http: ^1.2.2
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
|
||||
@@ -1,371 +1,70 @@
|
||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||
import '../../domain/repositories/shifts_repository_interface.dart';
|
||||
|
||||
class ShiftsRepositoryImpl
|
||||
implements ShiftsRepositoryInterface {
|
||||
/// Implementation of [ShiftsRepositoryInterface] that delegates to [dc.ShiftsConnectorRepository].
|
||||
///
|
||||
/// This implementation follows the "Buffer Layer" pattern by using a dedicated
|
||||
/// connector repository from the data_connect package.
|
||||
class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
|
||||
final dc.ShiftsConnectorRepository _connectorRepository;
|
||||
final dc.DataConnectService _service;
|
||||
|
||||
ShiftsRepositoryImpl() : _service = dc.DataConnectService.instance;
|
||||
ShiftsRepositoryImpl({
|
||||
dc.ShiftsConnectorRepository? connectorRepository,
|
||||
dc.DataConnectService? service,
|
||||
}) : _connectorRepository = connectorRepository ??
|
||||
dc.DataConnectService.instance.getShiftsRepository(),
|
||||
_service = service ?? dc.DataConnectService.instance;
|
||||
|
||||
// Cache: ShiftID -> ApplicationID (For Accept/Decline)
|
||||
final Map<String, String> _shiftToAppIdMap = {};
|
||||
// Cache: ApplicationID -> RoleID (For Accept/Decline w/ Update mutation)
|
||||
final Map<String, String> _appToRoleIdMap = {};
|
||||
|
||||
// This need to be an APPLICATION
|
||||
// THERE SHOULD BE APPLICATIONSTATUS and SHIFTSTATUS enums in the domain layer to avoid this string mapping and potential bugs.
|
||||
@override
|
||||
Future<List<Shift>> getMyShifts({
|
||||
required DateTime start,
|
||||
required DateTime end,
|
||||
}) async {
|
||||
return _fetchApplications(start: start, end: end);
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getMyShifts(
|
||||
staffId: staffId,
|
||||
start: start,
|
||||
end: end,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getPendingAssignments() async {
|
||||
return <Shift>[];
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getPendingAssignments(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getCancelledShifts() async {
|
||||
return <Shift>[];
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getCancelledShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getHistoryShifts() async {
|
||||
final staffId = await _service.getStaffId();
|
||||
final fdc.QueryResult<dc.ListCompletedApplicationsByStaffIdData, dc.ListCompletedApplicationsByStaffIdVariables> response = await _service.executeProtected(() => _service.connector
|
||||
.listCompletedApplicationsByStaffId(staffId: staffId)
|
||||
.execute());
|
||||
final List<Shift> shifts = [];
|
||||
|
||||
for (final app in response.data.applications) {
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: _mapStatus(dc.ApplicationStatus.CHECKED_OUT),
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
Future<List<Shift>> _fetchApplications({
|
||||
DateTime? start,
|
||||
DateTime? end,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
var query = _service.connector.getApplicationsByStaffId(staffId: staffId);
|
||||
if (start != null && end != null) {
|
||||
query = query.dayStart(_service.toTimestamp(start)).dayEnd(_service.toTimestamp(end));
|
||||
}
|
||||
final fdc.QueryResult<dc.GetApplicationsByStaffIdData, dc.GetApplicationsByStaffIdVariables> response = await _service.executeProtected(() => query.execute());
|
||||
|
||||
final apps = response.data.applications;
|
||||
final List<Shift> shifts = [];
|
||||
|
||||
for (final app in apps) {
|
||||
_shiftToAppIdMap[app.shift.id] = app.id;
|
||||
_appToRoleIdMap[app.id] = app.shiftRole.id;
|
||||
|
||||
final String roleName = app.shiftRole.role.name;
|
||||
final String orderName =
|
||||
(app.shift.order.eventName ?? '').trim().isNotEmpty
|
||||
? app.shift.order.eventName!
|
||||
: app.shift.order.business.businessName;
|
||||
final String title = '$roleName - $orderName';
|
||||
final DateTime? shiftDate = _service.toDateTime(app.shift.date);
|
||||
final DateTime? startDt = _service.toDateTime(app.shiftRole.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(app.shiftRole.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(app.createdAt);
|
||||
|
||||
// Override status to reflect the application state (e.g., CHECKED_OUT, CONFIRMED)
|
||||
final bool hasCheckIn = app.checkInTime != null;
|
||||
final bool hasCheckOut = app.checkOutTime != null;
|
||||
dc.ApplicationStatus? appStatus;
|
||||
if (app.status is dc.Known<dc.ApplicationStatus>) {
|
||||
appStatus = (app.status as dc.Known<dc.ApplicationStatus>).value;
|
||||
}
|
||||
final String mappedStatus = hasCheckOut
|
||||
? 'completed'
|
||||
: hasCheckIn
|
||||
? 'checked_in'
|
||||
: _mapStatus(appStatus ?? dc.ApplicationStatus.CONFIRMED);
|
||||
shifts.add(
|
||||
Shift(
|
||||
id: app.shift.id,
|
||||
roleId: app.shiftRole.roleId,
|
||||
title: title,
|
||||
clientName: app.shift.order.business.businessName,
|
||||
logoUrl: app.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: app.shiftRole.role.costPerHour,
|
||||
location: app.shift.location ?? '',
|
||||
locationAddress: app.shift.order.teamHub.hubName,
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: mappedStatus,
|
||||
description: app.shift.description,
|
||||
durationDays: app.shift.durationDays,
|
||||
requiredSlots: app.shiftRole.count,
|
||||
filledSlots: app.shiftRole.assigned ?? 0,
|
||||
hasApplied: true,
|
||||
latitude: app.shift.latitude,
|
||||
longitude: app.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: app.shiftRole.isBreakPaid ?? false,
|
||||
breakTime: app.shiftRole.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return shifts;
|
||||
}
|
||||
|
||||
String _mapStatus(dc.ApplicationStatus status) {
|
||||
switch (status) {
|
||||
case dc.ApplicationStatus.CONFIRMED:
|
||||
return 'confirmed';
|
||||
case dc.ApplicationStatus.PENDING:
|
||||
return 'pending';
|
||||
case dc.ApplicationStatus.CHECKED_OUT:
|
||||
return 'completed';
|
||||
case dc.ApplicationStatus.REJECTED:
|
||||
return 'cancelled';
|
||||
default:
|
||||
return 'open';
|
||||
}
|
||||
return _connectorRepository.getHistoryShifts(staffId: staffId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Shift>> getAvailableShifts(String query, String type) async {
|
||||
final String? vendorId = dc.StaffSessionStore.instance.session?.ownerId;
|
||||
if (vendorId == null || vendorId.isEmpty) {
|
||||
return <Shift>[];
|
||||
}
|
||||
|
||||
final fdc.QueryResult<dc.ListShiftRolesByVendorIdData, dc.ListShiftRolesByVendorIdVariables> result = await _service.executeProtected(() => _service.connector
|
||||
.listShiftRolesByVendorId(vendorId: vendorId)
|
||||
.execute());
|
||||
|
||||
final allShiftRoles = result.data.shiftRoles;
|
||||
|
||||
// Fetch my applications to filter out already booked shifts
|
||||
final List<Shift> myShifts = await _fetchApplications();
|
||||
final Set<String> myShiftIds = myShifts.map((s) => s.id).toSet();
|
||||
|
||||
final List<Shift> mappedShifts = [];
|
||||
for (final sr in allShiftRoles) {
|
||||
// Skip if I have already applied/booked this shift
|
||||
if (myShiftIds.contains(sr.shiftId)) continue;
|
||||
|
||||
|
||||
final DateTime? shiftDate = _service.toDateTime(sr.shift.date);
|
||||
final startDt = _service.toDateTime(sr.startTime);
|
||||
final endDt = _service.toDateTime(sr.endTime);
|
||||
final createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
mappedShifts.add(
|
||||
Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.role.name,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? '',
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: shiftDate?.toIso8601String() ?? '',
|
||||
startTime: startDt != null
|
||||
? DateFormat('HH:mm').format(startDt)
|
||||
: '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: sr.shift.status?.stringValue.toLowerCase() ?? 'open',
|
||||
description: sr.shift.description,
|
||||
durationDays: sr.shift.durationDays,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isNotEmpty) {
|
||||
return mappedShifts
|
||||
.where(
|
||||
(s) =>
|
||||
s.title.toLowerCase().contains(query.toLowerCase()) ||
|
||||
s.clientName.toLowerCase().contains(query.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return mappedShifts;
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getAvailableShifts(
|
||||
staffId: staffId,
|
||||
query: query,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Shift?> getShiftDetails(String shiftId, {String? roleId}) async {
|
||||
return _getShiftDetails(shiftId, roleId: roleId);
|
||||
}
|
||||
|
||||
Future<Shift?> _getShiftDetails(String shiftId, {String? roleId}) async {
|
||||
if (roleId != null && roleId.isNotEmpty) {
|
||||
final roleResult = await _service.executeProtected(() => _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: roleId)
|
||||
.execute());
|
||||
final sr = roleResult.data.shiftRole;
|
||||
if (sr == null) return null;
|
||||
|
||||
final DateTime? startDt = _service.toDateTime(sr.startTime);
|
||||
final DateTime? endDt = _service.toDateTime(sr.endTime);
|
||||
final DateTime? createdDt = _service.toDateTime(sr.createdAt);
|
||||
|
||||
final String staffId = await _service.getStaffId();
|
||||
bool hasApplied = false;
|
||||
String status = 'open';
|
||||
final apps = await _service.executeProtected(() =>
|
||||
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
|
||||
final app = apps.data.applications
|
||||
.where(
|
||||
(a) => a.shiftId == shiftId && a.shiftRole.roleId == roleId,
|
||||
)
|
||||
.firstOrNull;
|
||||
if (app != null) {
|
||||
hasApplied = true;
|
||||
if (app.status is dc.Known<dc.ApplicationStatus>) {
|
||||
final dc.ApplicationStatus s =
|
||||
(app.status as dc.Known<dc.ApplicationStatus>).value;
|
||||
status = _mapStatus(s);
|
||||
}
|
||||
}
|
||||
|
||||
return Shift(
|
||||
id: sr.shiftId,
|
||||
roleId: sr.roleId,
|
||||
title: sr.shift.order.business.businessName,
|
||||
clientName: sr.shift.order.business.businessName,
|
||||
logoUrl: sr.shift.order.business.companyLogoUrl,
|
||||
hourlyRate: sr.role.costPerHour,
|
||||
location: sr.shift.location ?? sr.shift.order.teamHub.hubName,
|
||||
locationAddress: sr.shift.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: status,
|
||||
description: sr.shift.description,
|
||||
durationDays: null,
|
||||
requiredSlots: sr.count,
|
||||
filledSlots: sr.assigned ?? 0,
|
||||
hasApplied: hasApplied,
|
||||
totalValue: sr.totalValue,
|
||||
latitude: sr.shift.latitude,
|
||||
longitude: sr.shift.longitude,
|
||||
breakInfo: BreakAdapter.fromData(
|
||||
isPaid: sr.isBreakPaid ?? false,
|
||||
breakTime: sr.breakType?.stringValue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final fdc.QueryResult<dc.GetShiftByIdData, dc.GetShiftByIdVariables> result =
|
||||
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
|
||||
final s = result.data.shift;
|
||||
if (s == null) return null;
|
||||
|
||||
int? required;
|
||||
int? filled;
|
||||
Break? breakInfo;
|
||||
try {
|
||||
final rolesRes = await _service.executeProtected(() =>
|
||||
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
|
||||
if (rolesRes.data.shiftRoles.isNotEmpty) {
|
||||
required = 0;
|
||||
filled = 0;
|
||||
for (var r in rolesRes.data.shiftRoles) {
|
||||
required = (required ?? 0) + r.count;
|
||||
filled = (filled ?? 0) + (r.assigned ?? 0);
|
||||
}
|
||||
// Use the first role's break info as a representative
|
||||
final firstRole = rolesRes.data.shiftRoles.first;
|
||||
breakInfo = BreakAdapter.fromData(
|
||||
isPaid: firstRole.isBreakPaid ?? false,
|
||||
breakTime: firstRole.breakType?.stringValue,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
final startDt = _service.toDateTime(s.startTime);
|
||||
final endDt = _service.toDateTime(s.endTime);
|
||||
final createdDt = _service.toDateTime(s.createdAt);
|
||||
|
||||
return Shift(
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
clientName: s.order.business.businessName,
|
||||
logoUrl: null,
|
||||
hourlyRate: s.cost ?? 0.0,
|
||||
location: s.location ?? '',
|
||||
locationAddress: s.locationAddress ?? '',
|
||||
date: startDt?.toIso8601String() ?? '',
|
||||
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
|
||||
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
|
||||
createdDate: createdDt?.toIso8601String() ?? '',
|
||||
status: s.status?.stringValue ?? 'OPEN',
|
||||
description: s.description,
|
||||
durationDays: s.durationDays,
|
||||
requiredSlots: required,
|
||||
filledSlots: filled,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
breakInfo: breakInfo,
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.getShiftDetails(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: roleId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -376,182 +75,29 @@ class ShiftsRepositoryImpl
|
||||
String? roleId,
|
||||
}) async {
|
||||
final staffId = await _service.getStaffId();
|
||||
|
||||
String targetRoleId = roleId ?? '';
|
||||
if (targetRoleId.isEmpty) {
|
||||
throw Exception('Missing role id.');
|
||||
}
|
||||
|
||||
final roleResult = await _service.executeProtected(() => _service.connector
|
||||
.getShiftRoleById(shiftId: shiftId, roleId: targetRoleId)
|
||||
.execute());
|
||||
final role = roleResult.data.shiftRole;
|
||||
if (role == null) {
|
||||
throw Exception('Shift role not found');
|
||||
}
|
||||
final shiftResult =
|
||||
await _service.executeProtected(() => _service.connector.getShiftById(id: shiftId).execute());
|
||||
final shift = shiftResult.data.shift;
|
||||
if (shift == null) {
|
||||
throw Exception('Shift not found');
|
||||
}
|
||||
final DateTime? shiftDate = _service.toDateTime(shift.date);
|
||||
if (shiftDate != null) {
|
||||
final DateTime dayStartUtc = DateTime.utc(
|
||||
shiftDate.year,
|
||||
shiftDate.month,
|
||||
shiftDate.day,
|
||||
);
|
||||
final DateTime dayEndUtc = DateTime.utc(
|
||||
shiftDate.year,
|
||||
shiftDate.month,
|
||||
shiftDate.day,
|
||||
23,
|
||||
59,
|
||||
59,
|
||||
999,
|
||||
999,
|
||||
);
|
||||
|
||||
final dayApplications = await _service.executeProtected(() => _service.connector
|
||||
.vaidateDayStaffApplication(staffId: staffId)
|
||||
.dayStart(_service.toTimestamp(dayStartUtc))
|
||||
.dayEnd(_service.toTimestamp(dayEndUtc))
|
||||
.execute());
|
||||
if (dayApplications.data.applications.isNotEmpty) {
|
||||
throw Exception('The user already has a shift that day.');
|
||||
}
|
||||
}
|
||||
final existingApplicationResult = await _service.executeProtected(() => _service.connector
|
||||
.getApplicationByStaffShiftAndRole(
|
||||
staffId: staffId,
|
||||
shiftId: shiftId,
|
||||
roleId: targetRoleId,
|
||||
)
|
||||
.execute());
|
||||
if (existingApplicationResult.data.applications.isNotEmpty) {
|
||||
throw Exception('Application already exists.');
|
||||
}
|
||||
final int assigned = role.assigned ?? 0;
|
||||
if (assigned >= role.count) {
|
||||
throw Exception('This shift is full.');
|
||||
}
|
||||
|
||||
final int filled = shift.filled ?? 0;
|
||||
|
||||
String? appId;
|
||||
bool updatedRole = false;
|
||||
bool updatedShift = false;
|
||||
try {
|
||||
final appResult = await _service.executeProtected(() => _service.connector
|
||||
.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: targetRoleId,
|
||||
status: dc.ApplicationStatus.CONFIRMED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
// TODO: this should be PENDING so a vendor can accept it.
|
||||
.execute());
|
||||
appId = appResult.data.application_insert.id;
|
||||
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
|
||||
.assigned(assigned + 1)
|
||||
.execute());
|
||||
updatedRole = true;
|
||||
|
||||
await _service.executeProtected(
|
||||
() => _service.connector.updateShift(id: shiftId).filled(filled + 1).execute());
|
||||
updatedShift = true;
|
||||
} catch (e) {
|
||||
if (updatedShift) {
|
||||
try {
|
||||
await _service.connector.updateShift(id: shiftId).filled(filled).execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (updatedRole) {
|
||||
try {
|
||||
await _service.connector
|
||||
.updateShiftRole(shiftId: shiftId, roleId: targetRoleId)
|
||||
.assigned(assigned)
|
||||
.execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
if (appId != null) {
|
||||
try {
|
||||
await _service.connector.deleteApplication(id: appId).execute();
|
||||
} catch (_) {}
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
return _connectorRepository.applyForShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
isInstantBook: isInstantBook,
|
||||
roleId: roleId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> acceptShift(String shiftId) async {
|
||||
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED);
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.acceptShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> declineShift(String shiftId) async {
|
||||
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.REJECTED);
|
||||
}
|
||||
|
||||
Future<void> _updateApplicationStatus(
|
||||
String shiftId,
|
||||
dc.ApplicationStatus newStatus,
|
||||
) async {
|
||||
String? appId = _shiftToAppIdMap[shiftId];
|
||||
String? roleId;
|
||||
|
||||
if (appId == null) {
|
||||
// Try to find it in pending
|
||||
await getPendingAssignments();
|
||||
}
|
||||
// Re-check map
|
||||
appId = _shiftToAppIdMap[shiftId];
|
||||
if (appId != null) {
|
||||
roleId = _appToRoleIdMap[appId];
|
||||
} else {
|
||||
// Fallback fetch
|
||||
final staffId = await _service.getStaffId();
|
||||
final apps = await _service.executeProtected(() =>
|
||||
_service.connector.getApplicationsByStaffId(staffId: staffId).execute());
|
||||
final app = apps.data.applications
|
||||
.where((a) => a.shiftId == shiftId)
|
||||
.firstOrNull;
|
||||
if (app != null) {
|
||||
appId = app.id;
|
||||
roleId = app.shiftRole.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (appId == null || roleId == null) {
|
||||
// If we are rejecting and can't find an application, create one as rejected (declining an available shift)
|
||||
if (newStatus == dc.ApplicationStatus.REJECTED) {
|
||||
final rolesResult = await _service.executeProtected(() =>
|
||||
_service.connector.listShiftRolesByShiftId(shiftId: shiftId).execute());
|
||||
if (rolesResult.data.shiftRoles.isNotEmpty) {
|
||||
final role = rolesResult.data.shiftRoles.first;
|
||||
final staffId = await _service.getStaffId();
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.createApplication(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
roleId: role.id,
|
||||
status: dc.ApplicationStatus.REJECTED,
|
||||
origin: dc.ApplicationOrigin.STAFF,
|
||||
)
|
||||
.execute());
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw Exception("Application not found for shift $shiftId");
|
||||
}
|
||||
|
||||
await _service.executeProtected(() => _service.connector
|
||||
.updateApplicationStatus(id: appId!)
|
||||
.status(newStatus)
|
||||
.execute());
|
||||
final staffId = await _service.getStaffId();
|
||||
return _connectorRepository.declineShift(
|
||||
shiftId: shiftId,
|
||||
staffId: staffId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user