@@ -94,6 +94,21 @@ extension ClientNavigator on IModularNavigator {
|
|||||||
navigate(ClientPaths.billing);
|
navigate(ClientPaths.billing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Completion Review page.
|
||||||
|
void toCompletionReview({Object? arguments}) {
|
||||||
|
pushNamed(ClientPaths.completionReview, arguments: arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the full list of invoices awaiting approval.
|
||||||
|
void toAwaitingApproval({Object? arguments}) {
|
||||||
|
pushNamed(ClientPaths.awaitingApproval, arguments: arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the Invoice Ready page.
|
||||||
|
void toInvoiceReady() {
|
||||||
|
pushNamed(ClientPaths.invoiceReady);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigates to the Orders tab.
|
/// Navigates to the Orders tab.
|
||||||
///
|
///
|
||||||
/// View and manage all shift orders with filtering and sorting.
|
/// View and manage all shift orders with filtering and sorting.
|
||||||
|
|||||||
@@ -81,6 +81,15 @@ class ClientPaths {
|
|||||||
/// Access billing history, payment methods, and invoices.
|
/// Access billing history, payment methods, and invoices.
|
||||||
static const String billing = '/client-main/billing';
|
static const String billing = '/client-main/billing';
|
||||||
|
|
||||||
|
/// Completion review page - review shift completion records.
|
||||||
|
static const String completionReview = '/client-main/billing/completion-review';
|
||||||
|
|
||||||
|
/// Full list of invoices awaiting approval.
|
||||||
|
static const String awaitingApproval = '/client-main/billing/awaiting-approval';
|
||||||
|
|
||||||
|
/// Invoice ready page - view status of approved invoices.
|
||||||
|
static const String invoiceReady = '/client-main/billing/invoice-ready';
|
||||||
|
|
||||||
/// Orders tab - view and manage shift orders.
|
/// Orders tab - view and manage shift orders.
|
||||||
///
|
///
|
||||||
/// List of all orders with filtering and status tracking.
|
/// List of all orders with filtering and status tracking.
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
|
pushNamedAndRemoveUntil(StaffPaths.home, (_) => false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the benefits overview page.
|
||||||
|
void toBenefits() {
|
||||||
|
pushNamed(StaffPaths.benefits);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigates to the staff main shell.
|
/// Navigates to the staff main shell.
|
||||||
///
|
///
|
||||||
/// This is the container with bottom navigation. Navigates to home tab
|
/// This is the container with bottom navigation. Navigates to home tab
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ class StaffPaths {
|
|||||||
/// Displays shift cards, quick actions, and notifications.
|
/// Displays shift cards, quick actions, and notifications.
|
||||||
static const String home = '/worker-main/home/';
|
static const String home = '/worker-main/home/';
|
||||||
|
|
||||||
|
/// Benefits overview page.
|
||||||
|
static const String benefits = '/worker-main/home/benefits';
|
||||||
|
|
||||||
/// Shifts tab - view and manage shifts.
|
/// Shifts tab - view and manage shifts.
|
||||||
///
|
///
|
||||||
/// Browse available shifts, accepted shifts, and shift history.
|
/// Browse available shifts, accepted shifts, and shift history.
|
||||||
|
|||||||
@@ -422,14 +422,53 @@
|
|||||||
"month": "Month",
|
"month": "Month",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"hours": "$count hours",
|
"hours": "$count hours",
|
||||||
|
"export_button": "Export All Invoices",
|
||||||
"rate_optimization_title": "Rate Optimization",
|
"rate_optimization_title": "Rate Optimization",
|
||||||
"rate_optimization_body": "Save $amount/month by switching 3 shifts",
|
"rate_optimization_save": "Save ",
|
||||||
|
"rate_optimization_amount": "$amount/month",
|
||||||
|
"rate_optimization_shifts": " by switching 3 shifts",
|
||||||
"view_details": "View Details",
|
"view_details": "View Details",
|
||||||
|
"no_invoices_period": "No Invoices for the selected period",
|
||||||
|
"invoices_ready_title": "Invoices Ready",
|
||||||
|
"invoices_ready_subtitle": "You have approved items ready for payment.",
|
||||||
|
"retry": "Retry",
|
||||||
|
"error_occurred": "An error occurred",
|
||||||
"invoice_history": "Invoice History",
|
"invoice_history": "Invoice History",
|
||||||
"view_all": "View all",
|
"view_all": "View all",
|
||||||
"export_button": "Export All Invoices",
|
"approved_success": "Invoice approved and payment initiated",
|
||||||
|
"flagged_success": "Invoice flagged for review",
|
||||||
"pending_badge": "PENDING APPROVAL",
|
"pending_badge": "PENDING APPROVAL",
|
||||||
"paid_badge": "PAID",
|
"paid_badge": "PAID",
|
||||||
|
"all_caught_up": "All caught up!",
|
||||||
|
"no_pending_invoices": "No invoices awaiting approval",
|
||||||
|
"review_and_approve": "Review & Approve",
|
||||||
|
"review_and_approve_subtitle": "Review and approve for payment",
|
||||||
|
"invoice_ready": "Invoice Ready",
|
||||||
|
"total_amount_label": "Total Amount",
|
||||||
|
"hours_suffix": "hours",
|
||||||
|
"avg_rate_suffix": "/hr avg",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total",
|
||||||
|
"workers": "workers",
|
||||||
|
"hrs": "HRS"
|
||||||
|
},
|
||||||
|
"workers_tab": {
|
||||||
|
"title": "Workers ($count)",
|
||||||
|
"search_hint": "Search workers...",
|
||||||
|
"needs_review": "Needs Review ($count)",
|
||||||
|
"all": "All ($count)",
|
||||||
|
"min_break": "min break"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"approve_pay": "Approve & Process Payment",
|
||||||
|
"flag_review": "Flag for Review",
|
||||||
|
"download_pdf": "Download Invoice PDF"
|
||||||
|
},
|
||||||
|
"flag_dialog": {
|
||||||
|
"title": "Flag for Review",
|
||||||
|
"hint": "Describe the issue...",
|
||||||
|
"button": "Flag"
|
||||||
|
},
|
||||||
"timesheets": {
|
"timesheets": {
|
||||||
"title": "Timesheets",
|
"title": "Timesheets",
|
||||||
"approve_button": "Approve",
|
"approve_button": "Approve",
|
||||||
@@ -497,6 +536,21 @@
|
|||||||
"sick_days": "Sick Days",
|
"sick_days": "Sick Days",
|
||||||
"vacation": "Vacation",
|
"vacation": "Vacation",
|
||||||
"holidays": "Holidays"
|
"holidays": "Holidays"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Your Benefits Overview",
|
||||||
|
"subtitle": "Manage and track your earned benefits here",
|
||||||
|
"request_payment": "Request Payment for $benefit",
|
||||||
|
"request_submitted": "Request submitted for $benefit",
|
||||||
|
"sick_leave_subtitle": "You need at least 8 hours to request sick leave",
|
||||||
|
"vacation_subtitle": "You need 40 hours to claim vacation pay",
|
||||||
|
"holidays_subtitle": "Pay holidays: Thanksgiving, Christmas, New Year",
|
||||||
|
"sick_leave_history": "SICK LEAVE HISTORY",
|
||||||
|
"compliance_banner": "Listed certificates are mandatory for employees. If the employee does not have the complete certificates, they can't proceed with their registration.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pending",
|
||||||
|
"submitted": "Submitted"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auto_match": {
|
"auto_match": {
|
||||||
|
|||||||
@@ -422,14 +422,53 @@
|
|||||||
"month": "Mes",
|
"month": "Mes",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"hours": "$count horas",
|
"hours": "$count horas",
|
||||||
|
"export_button": "Exportar Todas las Facturas",
|
||||||
"rate_optimization_title": "Optimizaci\u00f3n de Tarifas",
|
"rate_optimization_title": "Optimizaci\u00f3n de Tarifas",
|
||||||
"rate_optimization_body": "Ahorra $amount/mes cambiando 3 turnos",
|
"rate_optimization_save": "Ahorra ",
|
||||||
|
"rate_optimization_amount": "$amount/mes",
|
||||||
|
"rate_optimization_shifts": " cambiando 3 turnos",
|
||||||
"view_details": "Ver Detalles",
|
"view_details": "Ver Detalles",
|
||||||
|
"no_invoices_period": "No hay facturas para el per\u00edodo seleccionado",
|
||||||
|
"invoices_ready_title": "Facturas Listas",
|
||||||
|
"invoices_ready_subtitle": "Tienes elementos aprobados listos para el pago.",
|
||||||
|
"retry": "Reintentar",
|
||||||
|
"error_occurred": "Ocurri\u00f3 un error",
|
||||||
"invoice_history": "Historial de Facturas",
|
"invoice_history": "Historial de Facturas",
|
||||||
"view_all": "Ver todo",
|
"view_all": "Ver todo",
|
||||||
"export_button": "Exportar Todas las Facturas",
|
"approved_success": "Factura aprobada y pago iniciado",
|
||||||
"pending_badge": "PENDIENTE APROBACI\u00d3N",
|
"flagged_success": "Factura marcada para revisi\u00f3n",
|
||||||
|
"pending_badge": "PENDIENTE",
|
||||||
"paid_badge": "PAGADO",
|
"paid_badge": "PAGADO",
|
||||||
|
"all_caught_up": "\u00a1Todo al d\u00eda!",
|
||||||
|
"no_pending_invoices": "No hay facturas esperando aprobaci\u00f3n",
|
||||||
|
"review_and_approve": "Revisar y Aprobar",
|
||||||
|
"review_and_approve_subtitle": "Revisar y aprobar para el pago",
|
||||||
|
"invoice_ready": "Factura Lista",
|
||||||
|
"total_amount_label": "Monto Total",
|
||||||
|
"hours_suffix": "horas",
|
||||||
|
"avg_rate_suffix": "/hr prom",
|
||||||
|
"stats": {
|
||||||
|
"total": "Total",
|
||||||
|
"workers": "trabajadores",
|
||||||
|
"hrs": "HRS"
|
||||||
|
},
|
||||||
|
"workers_tab": {
|
||||||
|
"title": "Trabajadores ($count)",
|
||||||
|
"search_hint": "Buscar trabajadores...",
|
||||||
|
"needs_review": "Necesita Revisi\u00f3n ($count)",
|
||||||
|
"all": "Todos ($count)",
|
||||||
|
"min_break": "min de descanso"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"approve_pay": "Aprobar y Procesar Pago",
|
||||||
|
"flag_review": "Marcar para Revisi\u00f3n",
|
||||||
|
"download_pdf": "Descargar PDF de Factura"
|
||||||
|
},
|
||||||
|
"flag_dialog": {
|
||||||
|
"title": "Marcar para Revisi\u00f3n",
|
||||||
|
"hint": "Describe el problema...",
|
||||||
|
"button": "Marcar"
|
||||||
|
},
|
||||||
"timesheets": {
|
"timesheets": {
|
||||||
"title": "Hojas de Tiempo",
|
"title": "Hojas de Tiempo",
|
||||||
"approve_button": "Aprobar",
|
"approve_button": "Aprobar",
|
||||||
@@ -497,6 +536,21 @@
|
|||||||
"sick_days": "D\u00edas de Enfermedad",
|
"sick_days": "D\u00edas de Enfermedad",
|
||||||
"vacation": "Vacaciones",
|
"vacation": "Vacaciones",
|
||||||
"holidays": "Festivos"
|
"holidays": "Festivos"
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"title": "Resumen de tus Beneficios",
|
||||||
|
"subtitle": "Gestiona y sigue tus beneficios ganados aqu\u00ed",
|
||||||
|
"request_payment": "Solicitar pago por $benefit",
|
||||||
|
"request_submitted": "Solicitud enviada para $benefit",
|
||||||
|
"sick_leave_subtitle": "Necesitas al menos 8 horas para solicitar d\u00edas de enfermedad",
|
||||||
|
"vacation_subtitle": "Necesitas 40 horas para reclamar el pago de vacaciones",
|
||||||
|
"holidays_subtitle": "D\u00edas festivos pagados: Acci\u00f3n de Gracias, Navidad, A\u00f1o Nuevo",
|
||||||
|
"sick_leave_history": "HISTORIAL DE D\u00cdAS DE ENFERMEDAD",
|
||||||
|
"compliance_banner": "Los certificados listados son obligatorios para los empleados. Si el empleado no tiene los certificados completos, no puede proceder con su registro.",
|
||||||
|
"status": {
|
||||||
|
"pending": "Pendiente",
|
||||||
|
"submitted": "Enviado"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"auto_match": {
|
"auto_match": {
|
||||||
|
|||||||
@@ -42,10 +42,13 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
return _service.run(() async {
|
return _service.run(() async {
|
||||||
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
|
final QueryResult<dc.ListInvoicesByBusinessIdData, dc.ListInvoicesByBusinessIdVariables> result = await _service.connector
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
.limit(10)
|
.limit(20)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices.map(_mapInvoice).toList();
|
return result.data.invoices
|
||||||
|
.map(_mapInvoice)
|
||||||
|
.where((Invoice i) => i.status == InvoiceStatus.paid)
|
||||||
|
.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,7 +62,7 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
return result.data.invoices
|
return result.data.invoices
|
||||||
.map(_mapInvoice)
|
.map(_mapInvoice)
|
||||||
.where((Invoice i) =>
|
.where((Invoice i) =>
|
||||||
i.status == InvoiceStatus.open || i.status == InvoiceStatus.disputed)
|
i.status != InvoiceStatus.paid)
|
||||||
.toList();
|
.toList();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -132,9 +135,61 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> approveInvoice({required String id}) async {
|
||||||
|
return _service.run(() async {
|
||||||
|
await _service.connector
|
||||||
|
.updateInvoice(id: id)
|
||||||
|
.status(dc.InvoiceStatus.APPROVED)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disputeInvoice({required String id, required String reason}) async {
|
||||||
|
return _service.run(() async {
|
||||||
|
await _service.connector
|
||||||
|
.updateInvoice(id: id)
|
||||||
|
.status(dc.InvoiceStatus.DISPUTED)
|
||||||
|
.disputeReason(reason)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- MAPPERS ---
|
// --- MAPPERS ---
|
||||||
|
|
||||||
Invoice _mapInvoice(dynamic invoice) {
|
Invoice _mapInvoice(dynamic invoice) {
|
||||||
|
final List<dynamic> rolesData = invoice.roles is List ? invoice.roles : [];
|
||||||
|
final List<InvoiceWorker> workers = rolesData.map((dynamic r) {
|
||||||
|
final Map<String, dynamic> role = r as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Handle various possible key naming conventions in the JSON data
|
||||||
|
final String name = role['name'] ?? role['staffName'] ?? role['fullName'] ?? 'Unknown';
|
||||||
|
final String roleTitle = role['role'] ?? role['roleName'] ?? role['title'] ?? 'Staff';
|
||||||
|
final double amount = (role['amount'] as num?)?.toDouble() ??
|
||||||
|
(role['totalValue'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final double hours = (role['hours'] as num?)?.toDouble() ??
|
||||||
|
(role['workHours'] as num?)?.toDouble() ??
|
||||||
|
(role['totalHours'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
final double rate = (role['rate'] as num?)?.toDouble() ??
|
||||||
|
(role['hourlyRate'] as num?)?.toDouble() ?? 0.0;
|
||||||
|
|
||||||
|
final dynamic checkInVal = role['checkInTime'] ?? role['startTime'] ?? role['check_in_time'];
|
||||||
|
final dynamic checkOutVal = role['checkOutTime'] ?? role['endTime'] ?? role['check_out_time'];
|
||||||
|
|
||||||
|
return InvoiceWorker(
|
||||||
|
name: name,
|
||||||
|
role: roleTitle,
|
||||||
|
amount: amount,
|
||||||
|
hours: hours,
|
||||||
|
rate: rate,
|
||||||
|
checkIn: _service.toDateTime(checkInVal),
|
||||||
|
checkOut: _service.toDateTime(checkOutVal),
|
||||||
|
breakMinutes: role['breakMinutes'] ?? role['break_minutes'] ?? 0,
|
||||||
|
avatarUrl: role['avatarUrl'] ?? role['photoUrl'] ?? role['staffPhoto'],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
return Invoice(
|
return Invoice(
|
||||||
id: invoice.id,
|
id: invoice.id,
|
||||||
eventId: invoice.orderId,
|
eventId: invoice.orderId,
|
||||||
@@ -145,9 +200,23 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository {
|
|||||||
addonsAmount: invoice.otherCharges ?? 0,
|
addonsAmount: invoice.otherCharges ?? 0,
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
issueDate: _service.toDateTime(invoice.issueDate)!,
|
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||||
|
title: invoice.order?.eventName,
|
||||||
|
clientName: invoice.business?.businessName,
|
||||||
|
locationAddress: invoice.order?.teamHub?.hubName ?? invoice.order?.teamHub?.address,
|
||||||
|
staffCount: invoice.staffCount ?? (workers.isNotEmpty ? workers.length : 0),
|
||||||
|
totalHours: _calculateTotalHours(rolesData),
|
||||||
|
workers: workers,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
double _calculateTotalHours(List<dynamic> roles) {
|
||||||
|
return roles.fold<double>(0.0, (sum, role) {
|
||||||
|
final hours = role['hours'] ?? role['workHours'] ?? role['totalHours'];
|
||||||
|
if (hours is num) return sum + hours.toDouble();
|
||||||
|
return sum;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
BusinessBankAccount _mapBankAccount(dynamic account) {
|
BusinessBankAccount _mapBankAccount(dynamic account) {
|
||||||
return BusinessBankAccountAdapter.fromPrimitives(
|
return BusinessBankAccountAdapter.fromPrimitives(
|
||||||
id: account.id,
|
id: account.id,
|
||||||
|
|||||||
@@ -21,4 +21,10 @@ abstract interface class BillingConnectorRepository {
|
|||||||
required String businessId,
|
required String businessId,
|
||||||
required BillingPeriod period,
|
required BillingPeriod period,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Approves an invoice.
|
||||||
|
Future<void> approveInvoice({required String id});
|
||||||
|
|
||||||
|
/// Disputes an invoice.
|
||||||
|
Future<void> disputeInvoice({required String id, required String reason});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,28 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Benefit>> getBenefits() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String staffId = await _service.getStaffId();
|
||||||
|
|
||||||
|
final QueryResult<ListBenefitsDataByStaffIdData,
|
||||||
|
ListBenefitsDataByStaffIdVariables> response =
|
||||||
|
await _service.connector
|
||||||
|
.listBenefitsDataByStaffId(staffId: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return response.data.benefitsDatas.map((data) {
|
||||||
|
final plan = data.vendorBenefitPlan;
|
||||||
|
return Benefit(
|
||||||
|
title: plan.title,
|
||||||
|
entitlementHours: plan.total?.toDouble() ?? 0.0,
|
||||||
|
usedHours: data.current.toDouble(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ abstract interface class StaffConnectorRepository {
|
|||||||
/// Throws an exception if the profile cannot be retrieved.
|
/// Throws an exception if the profile cannot be retrieved.
|
||||||
Future<Staff> getStaffProfile();
|
Future<Staff> getStaffProfile();
|
||||||
|
|
||||||
|
/// Fetches the benefits for the current authenticated user.
|
||||||
|
///
|
||||||
|
/// Returns a list of [Benefit] entities.
|
||||||
|
Future<List<Benefit>> getBenefits();
|
||||||
|
|
||||||
/// Signs out the current user.
|
/// Signs out the current user.
|
||||||
///
|
///
|
||||||
/// Clears the user's session and authentication state.
|
/// Clears the user's session and authentication state.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export 'src/entities/skills/certificate.dart';
|
|||||||
export 'src/entities/skills/skill_kit.dart';
|
export 'src/entities/skills/skill_kit.dart';
|
||||||
|
|
||||||
// Financial & Payroll
|
// Financial & Payroll
|
||||||
|
export 'src/entities/benefits/benefit.dart';
|
||||||
export 'src/entities/financial/invoice.dart';
|
export 'src/entities/financial/invoice.dart';
|
||||||
export 'src/entities/financial/time_card.dart';
|
export 'src/entities/financial/time_card.dart';
|
||||||
export 'src/entities/financial/invoice_item.dart';
|
export 'src/entities/financial/invoice_item.dart';
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Represents a staff member's benefit balance.
|
||||||
|
class Benefit extends Equatable {
|
||||||
|
/// The title of the benefit (e.g., Sick Leave, Holiday, Vacation).
|
||||||
|
final String title;
|
||||||
|
|
||||||
|
/// The total entitlement in hours.
|
||||||
|
final double entitlementHours;
|
||||||
|
|
||||||
|
/// The hours used so far.
|
||||||
|
final double usedHours;
|
||||||
|
|
||||||
|
/// The hours remaining.
|
||||||
|
double get remainingHours => entitlementHours - usedHours;
|
||||||
|
|
||||||
|
/// Creates a [Benefit].
|
||||||
|
const Benefit({
|
||||||
|
required this.title,
|
||||||
|
required this.entitlementHours,
|
||||||
|
required this.usedHours,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [title, entitlementHours, usedHours];
|
||||||
|
}
|
||||||
@@ -37,6 +37,12 @@ class Invoice extends Equatable {
|
|||||||
required this.addonsAmount,
|
required this.addonsAmount,
|
||||||
this.invoiceNumber,
|
this.invoiceNumber,
|
||||||
this.issueDate,
|
this.issueDate,
|
||||||
|
this.title,
|
||||||
|
this.clientName,
|
||||||
|
this.locationAddress,
|
||||||
|
this.staffCount,
|
||||||
|
this.totalHours,
|
||||||
|
this.workers = const [],
|
||||||
});
|
});
|
||||||
/// Unique identifier.
|
/// Unique identifier.
|
||||||
final String id;
|
final String id;
|
||||||
@@ -65,6 +71,24 @@ class Invoice extends Equatable {
|
|||||||
/// Date when the invoice was issued.
|
/// Date when the invoice was issued.
|
||||||
final DateTime? issueDate;
|
final DateTime? issueDate;
|
||||||
|
|
||||||
|
/// Human-readable title (e.g. event name).
|
||||||
|
final String? title;
|
||||||
|
|
||||||
|
/// Name of the client business.
|
||||||
|
final String? clientName;
|
||||||
|
|
||||||
|
/// Address of the event/location.
|
||||||
|
final String? locationAddress;
|
||||||
|
|
||||||
|
/// Number of staff worked.
|
||||||
|
final int? staffCount;
|
||||||
|
|
||||||
|
/// Total hours worked.
|
||||||
|
final double? totalHours;
|
||||||
|
|
||||||
|
/// List of workers associated with this invoice.
|
||||||
|
final List<InvoiceWorker> workers;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
id,
|
id,
|
||||||
@@ -76,5 +100,49 @@ class Invoice extends Equatable {
|
|||||||
addonsAmount,
|
addonsAmount,
|
||||||
invoiceNumber,
|
invoiceNumber,
|
||||||
issueDate,
|
issueDate,
|
||||||
|
title,
|
||||||
|
clientName,
|
||||||
|
locationAddress,
|
||||||
|
staffCount,
|
||||||
|
totalHours,
|
||||||
|
workers,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Represents a worker entry in an [Invoice].
|
||||||
|
class InvoiceWorker extends Equatable {
|
||||||
|
const InvoiceWorker({
|
||||||
|
required this.name,
|
||||||
|
required this.role,
|
||||||
|
required this.amount,
|
||||||
|
required this.hours,
|
||||||
|
required this.rate,
|
||||||
|
this.checkIn,
|
||||||
|
this.checkOut,
|
||||||
|
this.breakMinutes = 0,
|
||||||
|
this.avatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final String role;
|
||||||
|
final double amount;
|
||||||
|
final double hours;
|
||||||
|
final double rate;
|
||||||
|
final DateTime? checkIn;
|
||||||
|
final DateTime? checkOut;
|
||||||
|
final int breakMinutes;
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
name,
|
||||||
|
role,
|
||||||
|
amount,
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
checkIn,
|
||||||
|
checkOut,
|
||||||
|
breakMinutes,
|
||||||
|
avatarUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,14 @@ import 'domain/usecases/get_invoice_history.dart';
|
|||||||
import 'domain/usecases/get_pending_invoices.dart';
|
import 'domain/usecases/get_pending_invoices.dart';
|
||||||
import 'domain/usecases/get_savings_amount.dart';
|
import 'domain/usecases/get_savings_amount.dart';
|
||||||
import 'domain/usecases/get_spending_breakdown.dart';
|
import 'domain/usecases/get_spending_breakdown.dart';
|
||||||
|
import 'domain/usecases/approve_invoice.dart';
|
||||||
|
import 'domain/usecases/dispute_invoice.dart';
|
||||||
import 'presentation/blocs/billing_bloc.dart';
|
import 'presentation/blocs/billing_bloc.dart';
|
||||||
|
import 'presentation/models/billing_invoice_model.dart';
|
||||||
import 'presentation/pages/billing_page.dart';
|
import 'presentation/pages/billing_page.dart';
|
||||||
import 'presentation/pages/timesheets_page.dart';
|
import 'presentation/pages/completion_review_page.dart';
|
||||||
|
import 'presentation/pages/invoice_ready_page.dart';
|
||||||
|
import 'presentation/pages/pending_invoices_page.dart';
|
||||||
|
|
||||||
/// Modular module for the billing feature.
|
/// Modular module for the billing feature.
|
||||||
class BillingModule extends Module {
|
class BillingModule extends Module {
|
||||||
@@ -29,6 +34,8 @@ class BillingModule extends Module {
|
|||||||
i.addSingleton(GetPendingInvoicesUseCase.new);
|
i.addSingleton(GetPendingInvoicesUseCase.new);
|
||||||
i.addSingleton(GetInvoiceHistoryUseCase.new);
|
i.addSingleton(GetInvoiceHistoryUseCase.new);
|
||||||
i.addSingleton(GetSpendingBreakdownUseCase.new);
|
i.addSingleton(GetSpendingBreakdownUseCase.new);
|
||||||
|
i.addSingleton(ApproveInvoiceUseCase.new);
|
||||||
|
i.addSingleton(DisputeInvoiceUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.addSingleton<BillingBloc>(
|
i.addSingleton<BillingBloc>(
|
||||||
@@ -39,6 +46,8 @@ class BillingModule extends Module {
|
|||||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||||
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
getInvoiceHistory: i.get<GetInvoiceHistoryUseCase>(),
|
||||||
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
getSpendingBreakdown: i.get<GetSpendingBreakdownUseCase>(),
|
||||||
|
approveInvoice: i.get<ApproveInvoiceUseCase>(),
|
||||||
|
disputeInvoice: i.get<DisputeInvoiceUseCase>(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -46,6 +55,8 @@ class BillingModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(ClientPaths.childRoute(ClientPaths.billing, ClientPaths.billing), child: (_) => const BillingPage());
|
r.child(ClientPaths.childRoute(ClientPaths.billing, ClientPaths.billing), child: (_) => const BillingPage());
|
||||||
r.child('/timesheets', child: (_) => const ClientTimesheetsPage());
|
r.child('/completion-review', child: (_) => ShiftCompletionReviewPage(invoice: r.args.data as BillingInvoice?));
|
||||||
|
r.child('/invoice-ready', child: (_) => const InvoiceReadyPage());
|
||||||
|
r.child('/awaiting-approval', child: (_) => const PendingInvoicesPage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,5 +56,15 @@ class BillingRepositoryImpl implements BillingRepository {
|
|||||||
period: period,
|
period: period,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> approveInvoice(String id) async {
|
||||||
|
return _connectorRepository.approveInvoice(id: id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> disputeInvoice(String id, String reason) async {
|
||||||
|
return _connectorRepository.disputeInvoice(id: id, reason: reason);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,4 +23,10 @@ abstract class BillingRepository {
|
|||||||
|
|
||||||
/// Fetches invoice items for spending breakdown analysis.
|
/// Fetches invoice items for spending breakdown analysis.
|
||||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period);
|
||||||
|
|
||||||
|
/// Approves an invoice.
|
||||||
|
Future<void> approveInvoice(String id);
|
||||||
|
|
||||||
|
/// Disputes an invoice.
|
||||||
|
Future<void> disputeInvoice(String id, String reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import '../repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for approving an invoice.
|
||||||
|
class ApproveInvoiceUseCase extends UseCase<String, void> {
|
||||||
|
/// Creates an [ApproveInvoiceUseCase].
|
||||||
|
ApproveInvoiceUseCase(this._repository);
|
||||||
|
|
||||||
|
final BillingRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> call(String input) => _repository.approveInvoice(input);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import '../repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Params for [DisputeInvoiceUseCase].
|
||||||
|
class DisputeInvoiceParams {
|
||||||
|
const DisputeInvoiceParams({required this.id, required this.reason});
|
||||||
|
final String id;
|
||||||
|
final String reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Use case for disputing an invoice.
|
||||||
|
class DisputeInvoiceUseCase extends UseCase<DisputeInvoiceParams, void> {
|
||||||
|
/// Creates a [DisputeInvoiceUseCase].
|
||||||
|
DisputeInvoiceUseCase(this._repository);
|
||||||
|
|
||||||
|
final BillingRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> call(DisputeInvoiceParams input) =>
|
||||||
|
_repository.disputeInvoice(input.id, input.reason);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/usecases/get_bank_accounts.dart';
|
import '../../domain/usecases/get_bank_accounts.dart';
|
||||||
@@ -7,6 +8,8 @@ import '../../domain/usecases/get_invoice_history.dart';
|
|||||||
import '../../domain/usecases/get_pending_invoices.dart';
|
import '../../domain/usecases/get_pending_invoices.dart';
|
||||||
import '../../domain/usecases/get_savings_amount.dart';
|
import '../../domain/usecases/get_savings_amount.dart';
|
||||||
import '../../domain/usecases/get_spending_breakdown.dart';
|
import '../../domain/usecases/get_spending_breakdown.dart';
|
||||||
|
import '../../domain/usecases/approve_invoice.dart';
|
||||||
|
import '../../domain/usecases/dispute_invoice.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
import '../models/spending_breakdown_model.dart';
|
import '../models/spending_breakdown_model.dart';
|
||||||
import 'billing_event.dart';
|
import 'billing_event.dart';
|
||||||
@@ -23,15 +26,21 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||||
|
required ApproveInvoiceUseCase approveInvoice,
|
||||||
|
required DisputeInvoiceUseCase disputeInvoice,
|
||||||
}) : _getBankAccounts = getBankAccounts,
|
}) : _getBankAccounts = getBankAccounts,
|
||||||
_getCurrentBillAmount = getCurrentBillAmount,
|
_getCurrentBillAmount = getCurrentBillAmount,
|
||||||
_getSavingsAmount = getSavingsAmount,
|
_getSavingsAmount = getSavingsAmount,
|
||||||
_getPendingInvoices = getPendingInvoices,
|
_getPendingInvoices = getPendingInvoices,
|
||||||
_getInvoiceHistory = getInvoiceHistory,
|
_getInvoiceHistory = getInvoiceHistory,
|
||||||
_getSpendingBreakdown = getSpendingBreakdown,
|
_getSpendingBreakdown = getSpendingBreakdown,
|
||||||
|
_approveInvoice = approveInvoice,
|
||||||
|
_disputeInvoice = disputeInvoice,
|
||||||
super(const BillingState()) {
|
super(const BillingState()) {
|
||||||
on<BillingLoadStarted>(_onLoadStarted);
|
on<BillingLoadStarted>(_onLoadStarted);
|
||||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||||
|
on<BillingInvoiceApproved>(_onInvoiceApproved);
|
||||||
|
on<BillingInvoiceDisputed>(_onInvoiceDisputed);
|
||||||
}
|
}
|
||||||
|
|
||||||
final GetBankAccountsUseCase _getBankAccounts;
|
final GetBankAccountsUseCase _getBankAccounts;
|
||||||
@@ -40,6 +49,8 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||||
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
final GetInvoiceHistoryUseCase _getInvoiceHistory;
|
||||||
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
final GetSpendingBreakdownUseCase _getSpendingBreakdown;
|
||||||
|
final ApproveInvoiceUseCase _approveInvoice;
|
||||||
|
final DisputeInvoiceUseCase _disputeInvoice;
|
||||||
|
|
||||||
Future<void> _onLoadStarted(
|
Future<void> _onLoadStarted(
|
||||||
BillingLoadStarted event,
|
BillingLoadStarted event,
|
||||||
@@ -127,25 +138,102 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onInvoiceApproved(
|
||||||
|
BillingInvoiceApproved event,
|
||||||
|
Emitter<BillingState> emit,
|
||||||
|
) async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
await _approveInvoice.call(event.invoiceId);
|
||||||
|
add(const BillingLoadStarted());
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
status: BillingStatus.failure,
|
||||||
|
errorMessage: errorKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onInvoiceDisputed(
|
||||||
|
BillingInvoiceDisputed event,
|
||||||
|
Emitter<BillingState> emit,
|
||||||
|
) async {
|
||||||
|
await handleError(
|
||||||
|
emit: emit.call,
|
||||||
|
action: () async {
|
||||||
|
await _disputeInvoice.call(
|
||||||
|
DisputeInvoiceParams(id: event.invoiceId, reason: event.reason),
|
||||||
|
);
|
||||||
|
add(const BillingLoadStarted());
|
||||||
|
},
|
||||||
|
onError: (String errorKey) => state.copyWith(
|
||||||
|
status: BillingStatus.failure,
|
||||||
|
errorMessage: errorKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
BillingInvoice _mapInvoiceToUiModel(Invoice invoice) {
|
||||||
// In a real app, fetches related Event/Business names via ID.
|
final DateFormat formatter = DateFormat('EEEE, MMMM d');
|
||||||
// For now, mapping available fields and hardcoding missing UI placeholders.
|
|
||||||
// Preserving "Existing Behavior" means we show something.
|
|
||||||
final String dateLabel = invoice.issueDate == null
|
final String dateLabel = invoice.issueDate == null
|
||||||
? '2024-01-24'
|
? 'N/A'
|
||||||
: invoice.issueDate!.toIso8601String().split('T').first;
|
: formatter.format(invoice.issueDate!);
|
||||||
final String titleLabel = invoice.invoiceNumber ?? invoice.id;
|
|
||||||
|
final List<BillingWorkerRecord> workers = invoice.workers.map((InvoiceWorker w) {
|
||||||
|
return BillingWorkerRecord(
|
||||||
|
workerName: w.name,
|
||||||
|
roleName: w.role,
|
||||||
|
totalAmount: w.amount,
|
||||||
|
hours: w.hours,
|
||||||
|
rate: w.rate,
|
||||||
|
startTime: w.checkIn != null ? '${w.checkIn!.hour.toString().padLeft(2, '0')}:${w.checkIn!.minute.toString().padLeft(2, '0')}' : '--:--',
|
||||||
|
endTime: w.checkOut != null ? '${w.checkOut!.hour.toString().padLeft(2, '0')}:${w.checkOut!.minute.toString().padLeft(2, '0')}' : '--:--',
|
||||||
|
breakMinutes: w.breakMinutes,
|
||||||
|
workerAvatarUrl: w.avatarUrl,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
String? overallStart;
|
||||||
|
String? overallEnd;
|
||||||
|
|
||||||
|
// Find valid times from workers instead of just taking the first one
|
||||||
|
final validStartTimes = workers
|
||||||
|
.where((w) => w.startTime != '--:--')
|
||||||
|
.map((w) => w.startTime)
|
||||||
|
.toList();
|
||||||
|
final validEndTimes = workers
|
||||||
|
.where((w) => w.endTime != '--:--')
|
||||||
|
.map((w) => w.endTime)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (validStartTimes.isNotEmpty) {
|
||||||
|
validStartTimes.sort();
|
||||||
|
overallStart = validStartTimes.first;
|
||||||
|
} else if (workers.isNotEmpty) {
|
||||||
|
overallStart = workers.first.startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validEndTimes.isNotEmpty) {
|
||||||
|
validEndTimes.sort();
|
||||||
|
overallEnd = validEndTimes.last;
|
||||||
|
} else if (workers.isNotEmpty) {
|
||||||
|
overallEnd = workers.first.endTime;
|
||||||
|
}
|
||||||
|
|
||||||
return BillingInvoice(
|
return BillingInvoice(
|
||||||
id: titleLabel,
|
id: invoice.invoiceNumber ?? invoice.id,
|
||||||
title: 'Invoice #${invoice.id}', // Placeholder as Invoice lacks title
|
title: invoice.title ?? 'N/A',
|
||||||
locationAddress:
|
locationAddress: invoice.locationAddress ?? 'Remote',
|
||||||
'Location for ${invoice.eventId}', // Placeholder for address
|
clientName: invoice.clientName ?? 'N/A',
|
||||||
clientName: 'Client ${invoice.businessId}', // Placeholder for client name
|
|
||||||
date: dateLabel,
|
date: dateLabel,
|
||||||
totalAmount: invoice.totalAmount,
|
totalAmount: invoice.totalAmount,
|
||||||
workersCount: 5, // Placeholder count
|
workersCount: invoice.staffCount ?? 0,
|
||||||
totalHours: invoice.workAmount / 25.0, // Estimating hours from amount
|
totalHours: invoice.totalHours ?? 0.0,
|
||||||
status: invoice.status.name.toUpperCase(),
|
status: invoice.status.name.toUpperCase(),
|
||||||
|
workers: workers,
|
||||||
|
startTime: overallStart,
|
||||||
|
endTime: overallEnd,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,20 @@ class BillingPeriodChanged extends BillingEvent {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[period];
|
List<Object?> get props => <Object?>[period];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class BillingInvoiceApproved extends BillingEvent {
|
||||||
|
const BillingInvoiceApproved(this.invoiceId);
|
||||||
|
final String invoiceId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[invoiceId];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BillingInvoiceDisputed extends BillingEvent {
|
||||||
|
const BillingInvoiceDisputed(this.invoiceId, this.reason);
|
||||||
|
final String invoiceId;
|
||||||
|
final String reason;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[invoiceId, reason];
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class BillingInvoice extends Equatable {
|
|||||||
required this.workersCount,
|
required this.workersCount,
|
||||||
required this.totalHours,
|
required this.totalHours,
|
||||||
required this.status,
|
required this.status,
|
||||||
|
this.workers = const [],
|
||||||
|
this.startTime,
|
||||||
|
this.endTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -22,6 +25,9 @@ class BillingInvoice extends Equatable {
|
|||||||
final int workersCount;
|
final int workersCount;
|
||||||
final double totalHours;
|
final double totalHours;
|
||||||
final String status;
|
final String status;
|
||||||
|
final List<BillingWorkerRecord> workers;
|
||||||
|
final String? startTime;
|
||||||
|
final String? endTime;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
@@ -34,5 +40,45 @@ class BillingInvoice extends Equatable {
|
|||||||
workersCount,
|
workersCount,
|
||||||
totalHours,
|
totalHours,
|
||||||
status,
|
status,
|
||||||
|
workers,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
class BillingWorkerRecord extends Equatable {
|
||||||
|
const BillingWorkerRecord({
|
||||||
|
required this.workerName,
|
||||||
|
required this.roleName,
|
||||||
|
required this.totalAmount,
|
||||||
|
required this.hours,
|
||||||
|
required this.rate,
|
||||||
|
required this.startTime,
|
||||||
|
required this.endTime,
|
||||||
|
required this.breakMinutes,
|
||||||
|
this.workerAvatarUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String workerName;
|
||||||
|
final String roleName;
|
||||||
|
final double totalAmount;
|
||||||
|
final double hours;
|
||||||
|
final double rate;
|
||||||
|
final String startTime;
|
||||||
|
final String endTime;
|
||||||
|
final int breakMinutes;
|
||||||
|
final String? workerAvatarUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => [
|
||||||
|
workerName,
|
||||||
|
roleName,
|
||||||
|
totalAmount,
|
||||||
|
hours,
|
||||||
|
rate,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
breakMinutes,
|
||||||
|
workerAvatarUrl,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: UiColors.background,
|
||||||
body: BlocConsumer<BillingBloc, BillingState>(
|
body: BlocConsumer<BillingBloc, BillingState>(
|
||||||
listener: (BuildContext context, BillingState state) {
|
listener: (BuildContext context, BillingState state) {
|
||||||
if (state.status == BillingStatus.failure &&
|
if (state.status == BillingStatus.failure &&
|
||||||
@@ -89,33 +90,29 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
slivers: <Widget>[
|
slivers: <Widget>[
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
pinned: true,
|
pinned: true,
|
||||||
expandedHeight: 200.0,
|
expandedHeight: 220.0,
|
||||||
backgroundColor: UiColors.primary,
|
backgroundColor: UiColors.primary,
|
||||||
|
elevation: 0,
|
||||||
|
leadingWidth: 72,
|
||||||
leading: Center(
|
leading: Center(
|
||||||
child: UiIconButton.secondary(
|
child: UiIconButton(
|
||||||
icon: UiIcons.arrowLeft,
|
icon: UiIcons.arrowLeft,
|
||||||
|
backgroundColor: UiColors.white.withOpacity(0.15),
|
||||||
|
iconColor: UiColors.white,
|
||||||
|
useBlur: true,
|
||||||
|
size: 40,
|
||||||
onTap: () => Modular.to.toClientHome(),
|
onTap: () => Modular.to.toClientHome(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: AnimatedSwitcher(
|
title: Text(
|
||||||
duration: const Duration(milliseconds: 200),
|
t.client_billing.title,
|
||||||
child: Text(
|
style: UiTypography.headline3b.copyWith(color: UiColors.white),
|
||||||
_isScrolled
|
|
||||||
? '\$${state.currentBill.toStringAsFixed(2)}'
|
|
||||||
: t.client_billing.title,
|
|
||||||
key: ValueKey<bool>(_isScrolled),
|
|
||||||
style: UiTypography.headline4m.copyWith(
|
|
||||||
color: UiColors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
centerTitle: false,
|
||||||
flexibleSpace: FlexibleSpaceBar(
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
background: Padding(
|
background: Padding(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
top: UiConstants.space0,
|
bottom: UiConstants.space8,
|
||||||
left: UiConstants.space5,
|
|
||||||
right: UiConstants.space5,
|
|
||||||
bottom: UiConstants.space10,
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
@@ -123,21 +120,22 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
Text(
|
Text(
|
||||||
t.client_billing.current_period,
|
t.client_billing.current_period,
|
||||||
style: UiTypography.footnote2r.copyWith(
|
style: UiTypography.footnote2r.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.7),
|
color: UiColors.white.withOpacity(0.7),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||||
style: UiTypography.display1b.copyWith(
|
style: UiTypography.displayM.copyWith(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
|
fontSize: 40,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: UiConstants.space2,
|
horizontal: 12,
|
||||||
vertical: UiConstants.space1,
|
vertical: 6,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.accent,
|
color: UiColors.accent,
|
||||||
@@ -148,16 +146,16 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.trendingDown,
|
UiIcons.trendingDown,
|
||||||
size: 12,
|
size: 14,
|
||||||
color: UiColors.foreground,
|
color: UiColors.accentForeground,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.saved_amount(
|
t.client_billing.saved_amount(
|
||||||
amount: state.savings.toStringAsFixed(0),
|
amount: state.savings.toStringAsFixed(0),
|
||||||
),
|
),
|
||||||
style: UiTypography.footnote2b.copyWith(
|
style: UiTypography.footnote2b.copyWith(
|
||||||
color: UiColors.foreground,
|
color: UiColors.accentForeground,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -200,13 +198,13 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
Text(
|
Text(
|
||||||
state.errorMessage != null
|
state.errorMessage != null
|
||||||
? translateErrorKey(state.errorMessage!)
|
? translateErrorKey(state.errorMessage!)
|
||||||
: 'An error occurred',
|
: t.client_billing.error_occurred,
|
||||||
style: UiTypography.body1m.textError,
|
style: UiTypography.body1m.textError,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
UiButton.secondary(
|
UiButton.secondary(
|
||||||
text: 'Retry',
|
text: t.client_billing.retry,
|
||||||
onPressed: () => BlocProvider.of<BillingBloc>(
|
onPressed: () => BlocProvider.of<BillingBloc>(
|
||||||
context,
|
context,
|
||||||
).add(const BillingLoadStarted()),
|
).add(const BillingLoadStarted()),
|
||||||
@@ -221,24 +219,95 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
padding: const EdgeInsets.all(UiConstants.space5),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
spacing: UiConstants.space4,
|
spacing: UiConstants.space6,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
if (state.pendingInvoices.isNotEmpty) ...<Widget>[
|
||||||
PendingInvoicesSection(invoices: state.pendingInvoices),
|
PendingInvoicesSection(invoices: state.pendingInvoices),
|
||||||
],
|
],
|
||||||
const PaymentMethodCard(),
|
const PaymentMethodCard(),
|
||||||
const SpendingBreakdownCard(),
|
const SpendingBreakdownCard(),
|
||||||
if (state.invoiceHistory.isEmpty)
|
_buildSavingsCard(state.savings),
|
||||||
_buildEmptyState(context)
|
if (state.invoiceHistory.isNotEmpty)
|
||||||
else
|
|
||||||
InvoiceHistorySection(invoices: state.invoiceHistory),
|
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space32),
|
_buildExportButton(),
|
||||||
|
const SizedBox(height: UiConstants.space12),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSavingsCard(double amount) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFFBEB),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.accent.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: const Icon(UiIcons.trendingDown, size: 18, color: UiColors.accentForeground),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.client_billing.rate_optimization_title,
|
||||||
|
style: UiTypography.body2b.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
children: [
|
||||||
|
TextSpan(text: t.client_billing.rate_optimization_save),
|
||||||
|
TextSpan(
|
||||||
|
text: t.client_billing.rate_optimization_amount(amount: amount.toStringAsFixed(0)),
|
||||||
|
style: UiTypography.footnote2b.textPrimary,
|
||||||
|
),
|
||||||
|
TextSpan(text: t.client_billing.rate_optimization_shifts),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
SizedBox(
|
||||||
|
height: 32,
|
||||||
|
child: UiButton.primary(
|
||||||
|
text: t.client_billing.view_details,
|
||||||
|
onPressed: () {},
|
||||||
|
size: UiButtonSize.small,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildExportButton() {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.secondary(
|
||||||
|
text: t.client_billing.export_button,
|
||||||
|
leadingIcon: UiIcons.download,
|
||||||
|
onPressed: () {},
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -260,7 +329,7 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Text(
|
Text(
|
||||||
'No Invoices for the selected period',
|
t.client_billing.no_invoices_period,
|
||||||
style: UiTypography.body1m.textSecondary,
|
style: UiTypography.body1m.textSecondary,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -269,3 +338,42 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _InvoicesReadyBanner extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => Modular.to.toInvoiceReady(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.success.withValues(alpha: 0.1),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.success.withValues(alpha: 0.3)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(UiIcons.file, color: UiColors.success),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.client_billing.invoices_ready_title,
|
||||||
|
style: UiTypography.body1b.copyWith(color: UiColors.success),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.client_billing.invoices_ready_subtitle,
|
||||||
|
style: UiTypography.footnote2r.copyWith(color: UiColors.success),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(UiIcons.chevronRight, color: UiColors.success),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import '../blocs/billing_bloc.dart';
|
||||||
|
import '../blocs/billing_event.dart';
|
||||||
|
import '../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
class ShiftCompletionReviewPage extends StatefulWidget {
|
||||||
|
const ShiftCompletionReviewPage({this.invoice, super.key});
|
||||||
|
|
||||||
|
final BillingInvoice? invoice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ShiftCompletionReviewPage> createState() => _ShiftCompletionReviewPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ShiftCompletionReviewPageState extends State<ShiftCompletionReviewPage> {
|
||||||
|
late BillingInvoice invoice;
|
||||||
|
String searchQuery = '';
|
||||||
|
int selectedTab = 0; // 0: Needs Review (mocked as empty), 1: All
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Use widget.invoice if provided, else try to get from arguments
|
||||||
|
invoice = widget.invoice ?? Modular.args!.data as BillingInvoice;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<BillingWorkerRecord> filteredWorkers = invoice.workers.where((BillingWorkerRecord w) {
|
||||||
|
if (searchQuery.isEmpty) return true;
|
||||||
|
return w.workerName.toLowerCase().contains(searchQuery.toLowerCase()) ||
|
||||||
|
w.roleName.toLowerCase().contains(searchQuery.toLowerCase());
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8FAFC),
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
_buildHeader(context),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildInvoiceInfoCard(),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildAmountCard(),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
_buildWorkersHeader(),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildSearchAndTabs(),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
...filteredWorkers.map((BillingWorkerRecord worker) => _buildWorkerCard(worker)),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
_buildActionButtons(context),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildDownloadLink(),
|
||||||
|
const SizedBox(height: UiConstants.space8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(UiConstants.space5, UiConstants.space4, UiConstants.space5, UiConstants.space4),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(bottom: BorderSide(color: UiColors.border)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.border,
|
||||||
|
borderRadius: UiConstants.radiusFull,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: <Widget>[
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(t.client_billing.invoice_ready, style: UiTypography.headline4b.textPrimary),
|
||||||
|
Text(t.client_billing.review_and_approve_subtitle, style: UiTypography.body2r.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
UiIconButton.secondary(
|
||||||
|
icon: UiIcons.close,
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInvoiceInfoCard() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||||
|
Text(invoice.clientName, style: UiTypography.body2r.textSecondary),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
_buildInfoRow(UiIcons.calendar, invoice.date),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
_buildInfoRow(UiIcons.clock, '${invoice.startTime ?? "--"} - ${invoice.endTime ?? "--"}'),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
_buildInfoRow(UiIcons.mapPin, invoice.locationAddress),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildInfoRow(IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Icon(icon, size: 16, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Text(text, style: UiTypography.body2r.textSecondary),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAmountCard() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: const Color(0xFFDBEAFE)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.client_billing.total_amount_label,
|
||||||
|
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||||
|
style: UiTypography.headline1b.textPrimary.copyWith(fontSize: 40),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space1),
|
||||||
|
Text(
|
||||||
|
'${invoice.totalHours.toStringAsFixed(1)} ${t.client_billing.hours_suffix} • \$${(invoice.totalAmount / (invoice.totalHours > 0.1 ? invoice.totalHours : 1)).toStringAsFixed(2)}${t.client_billing.avg_rate_suffix}',
|
||||||
|
style: UiTypography.footnote2b.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWorkersHeader() {
|
||||||
|
return Row(
|
||||||
|
children: <Widget>[
|
||||||
|
const Icon(UiIcons.users, size: 18, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Text(
|
||||||
|
t.client_billing.workers_tab.title(count: invoice.workersCount),
|
||||||
|
style: UiTypography.title2b.textPrimary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSearchAndTabs() {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF1F5F9),
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
onChanged: (String val) => setState(() => searchQuery = val),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
icon: const Icon(UiIcons.search, size: 18, color: UiColors.iconSecondary),
|
||||||
|
hintText: t.client_billing.workers_tab.search_hint,
|
||||||
|
hintStyle: UiTypography.body2r.textSecondary,
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Expanded(
|
||||||
|
child: _buildTabButton(t.client_billing.workers_tab.needs_review(count: 0), 0),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: _buildTabButton(t.client_billing.workers_tab.all(count: invoice.workersCount), 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTabButton(String text, int index) {
|
||||||
|
final bool isSelected = selectedTab == index;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => setState(() => selectedTab = index),
|
||||||
|
child: Container(
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected ? const Color(0xFF2563EB) : Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: isSelected ? const Color(0xFF2563EB) : UiColors.border),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: UiTypography.body2b.copyWith(
|
||||||
|
color: isSelected ? Colors.white : UiColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWorkerCard(BillingWorkerRecord worker) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: UiConstants.space3),
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 20,
|
||||||
|
backgroundColor: UiColors.bgSecondary,
|
||||||
|
backgroundImage: worker.workerAvatarUrl != null ? NetworkImage(worker.workerAvatarUrl!) : null,
|
||||||
|
child: worker.workerAvatarUrl == null ? const Icon(UiIcons.user, size: 20, color: UiColors.iconSecondary) : null,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(worker.workerName, style: UiTypography.body1b.textPrimary),
|
||||||
|
Text(worker.roleName, style: UiTypography.footnote2r.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: <Widget>[
|
||||||
|
Text('\$${worker.totalAmount.toStringAsFixed(2)}', style: UiTypography.body1b.textPrimary),
|
||||||
|
Text('${worker.hours}h x \$${worker.rate.toStringAsFixed(2)}/hr', style: UiTypography.footnote2r.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Text('${worker.startTime} - ${worker.endTime}', style: UiTypography.footnote2b.textPrimary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(UiIcons.coffee, size: 12, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text('${worker.breakMinutes} ${t.client_billing.workers_tab.min_break}', style: UiTypography.footnote2r.textSecondary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
UiIconButton.secondary(
|
||||||
|
icon: UiIcons.edit,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
UiIconButton.secondary(
|
||||||
|
icon: UiIcons.warning,
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActionButtons(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: <Widget>[
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.primary(
|
||||||
|
text: t.client_billing.actions.approve_pay,
|
||||||
|
leadingIcon: UiIcons.checkCircle,
|
||||||
|
onPressed: () {
|
||||||
|
Modular.get<BillingBloc>().add(BillingInvoiceApproved(invoice.id));
|
||||||
|
Modular.to.pop();
|
||||||
|
UiSnackbar.show(context, message: t.client_billing.approved_success, type: UiSnackbarType.success);
|
||||||
|
},
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF22C55E),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
border: Border.all(color: Colors.orange, width: 2),
|
||||||
|
),
|
||||||
|
child: UiButton.secondary(
|
||||||
|
text: t.client_billing.actions.flag_review,
|
||||||
|
leadingIcon: UiIcons.warning,
|
||||||
|
onPressed: () => _showFlagDialog(context),
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.orange,
|
||||||
|
side: BorderSide.none,
|
||||||
|
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDownloadLink() {
|
||||||
|
return Center(
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
icon: const Icon(UiIcons.download, size: 16, color: Color(0xFF2563EB)),
|
||||||
|
label: Text(
|
||||||
|
t.client_billing.actions.download_pdf,
|
||||||
|
style: UiTypography.body2b.copyWith(color: const Color(0xFF2563EB)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFlagDialog(BuildContext context) {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: Text(t.client_billing.flag_dialog.title),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: t.client_billing.flag_dialog.hint,
|
||||||
|
),
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(dialogContext),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
if (controller.text.isNotEmpty) {
|
||||||
|
Modular.get<BillingBloc>().add(
|
||||||
|
BillingInvoiceDisputed(invoice.id, controller.text),
|
||||||
|
);
|
||||||
|
Navigator.pop(dialogContext);
|
||||||
|
Modular.to.pop();
|
||||||
|
UiSnackbar.show(context, message: t.client_billing.flagged_success, type: UiSnackbarType.warning);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(t.client_billing.flag_dialog.button),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
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 '../blocs/billing_bloc.dart';
|
||||||
|
import '../blocs/billing_event.dart';
|
||||||
|
import '../blocs/billing_state.dart';
|
||||||
|
import '../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
|
class InvoiceReadyPage extends StatelessWidget {
|
||||||
|
const InvoiceReadyPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<BillingBloc>.value(
|
||||||
|
value: Modular.get<BillingBloc>()..add(const BillingLoadStarted()),
|
||||||
|
child: const InvoiceReadyView(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvoiceReadyView extends StatelessWidget {
|
||||||
|
const InvoiceReadyView({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Invoices Ready'),
|
||||||
|
leading: UiIconButton.secondary(
|
||||||
|
icon: UiIcons.arrowLeft,
|
||||||
|
onTap: () => Modular.to.pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: BlocBuilder<BillingBloc, BillingState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == BillingStatus.loading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.invoiceHistory.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(UiIcons.file, size: 64, color: UiColors.iconSecondary),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Text(
|
||||||
|
'No invoices ready yet',
|
||||||
|
style: UiTypography.body1m.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
itemCount: state.invoiceHistory.length,
|
||||||
|
separatorBuilder: (context, index) => const SizedBox(height: 16),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final invoice = state.invoiceHistory[index];
|
||||||
|
return _InvoiceSummaryCard(invoice: invoice);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InvoiceSummaryCard extends StatelessWidget {
|
||||||
|
const _InvoiceSummaryCard({required this.invoice});
|
||||||
|
final BillingInvoice invoice;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.success.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'READY',
|
||||||
|
style: UiTypography.titleUppercase4b.copyWith(color: UiColors.success),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
invoice.date,
|
||||||
|
style: UiTypography.footnote2r.textTertiary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(invoice.title, style: UiTypography.title2b.textPrimary),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(invoice.locationAddress, style: UiTypography.body2r.textSecondary),
|
||||||
|
const Divider(height: 32),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('TOTAL AMOUNT', style: UiTypography.titleUppercase4m.textSecondary),
|
||||||
|
Text('\$${invoice.totalAmount.toStringAsFixed(2)}', style: UiTypography.title2b.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
UiButton.primary(
|
||||||
|
text: 'View Details',
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Navigate to invoice details
|
||||||
|
},
|
||||||
|
size: UiButtonSize.small,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import '../blocs/billing_bloc.dart';
|
||||||
|
import '../blocs/billing_state.dart';
|
||||||
|
import '../widgets/pending_invoices_section.dart';
|
||||||
|
|
||||||
|
class PendingInvoicesPage extends StatelessWidget {
|
||||||
|
const PendingInvoicesPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8FAFC),
|
||||||
|
body: BlocBuilder<BillingBloc, BillingState>(
|
||||||
|
bloc: Modular.get<BillingBloc>(),
|
||||||
|
builder: (context, state) {
|
||||||
|
return CustomScrollView(
|
||||||
|
slivers: [
|
||||||
|
_buildHeader(context, state.pendingInvoices.length),
|
||||||
|
if (state.status == BillingStatus.loading)
|
||||||
|
const SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
else if (state.pendingInvoices.isEmpty)
|
||||||
|
_buildEmptyState()
|
||||||
|
else
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
UiConstants.space5,
|
||||||
|
100, // Bottom padding for scroll clearance
|
||||||
|
),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildBuilderDelegate(
|
||||||
|
(context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
|
child: PendingInvoiceCard(invoice: state.pendingInvoices[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
childCount: state.pendingInvoices.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context, int count) {
|
||||||
|
return SliverAppBar(
|
||||||
|
pinned: true,
|
||||||
|
expandedHeight: 140.0,
|
||||||
|
backgroundColor: UiColors.primary,
|
||||||
|
elevation: 0,
|
||||||
|
leadingWidth: 72,
|
||||||
|
leading: Center(
|
||||||
|
child: UiIconButton(
|
||||||
|
icon: UiIcons.arrowLeft,
|
||||||
|
backgroundColor: UiColors.white.withOpacity(0.15),
|
||||||
|
iconColor: UiColors.white,
|
||||||
|
useBlur: true,
|
||||||
|
size: 40,
|
||||||
|
onTap: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
flexibleSpace: FlexibleSpaceBar(
|
||||||
|
centerTitle: true,
|
||||||
|
title: Text(
|
||||||
|
t.client_billing.awaiting_approval,
|
||||||
|
style: UiTypography.headline4b.copyWith(color: UiColors.white),
|
||||||
|
),
|
||||||
|
background: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 40),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.1,
|
||||||
|
child: Icon(UiIcons.clock, size: 100, color: UiColors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmptyState() {
|
||||||
|
return SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgPopup,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(UiIcons.checkCircle, size: 48, color: UiColors.success),
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space4),
|
||||||
|
Text(
|
||||||
|
t.client_billing.all_caught_up,
|
||||||
|
style: UiTypography.body1m.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.client_billing.no_pending_invoices,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We need to export the card widget from the section file if we want to reuse it,
|
||||||
|
// or move it to its own file. I'll move it to a shared file or just make it public in the section file.
|
||||||
@@ -22,20 +22,37 @@ class InvoiceHistorySection extends StatelessWidget {
|
|||||||
t.client_billing.invoice_history,
|
t.client_billing.invoice_history,
|
||||||
style: UiTypography.title2b.textPrimary,
|
style: UiTypography.title2b.textPrimary,
|
||||||
),
|
),
|
||||||
const SizedBox.shrink(),
|
TextButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.client_billing.view_all,
|
||||||
|
style: UiTypography.body2b.copyWith(color: UiColors.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(UiIcons.chevronRight, size: 16, color: UiColors.primary),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
boxShadow: <BoxShadow>[
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
blurRadius: 8,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -68,7 +85,10 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space4,
|
||||||
|
vertical: UiConstants.space4,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
@@ -77,14 +97,21 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
color: UiColors.bgSecondary,
|
color: UiColors.bgSecondary,
|
||||||
borderRadius: UiConstants.radiusMd,
|
borderRadius: UiConstants.radiusMd,
|
||||||
),
|
),
|
||||||
child: const Icon(UiIcons.file, color: UiColors.primary, size: 20),
|
child: Icon(
|
||||||
|
UiIcons.file,
|
||||||
|
color: UiColors.iconSecondary.withOpacity(0.6),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(invoice.id, style: UiTypography.body2b.textPrimary),
|
Text(
|
||||||
|
invoice.id,
|
||||||
|
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||||
|
),
|
||||||
Text(
|
Text(
|
||||||
invoice.date,
|
invoice.date,
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
@@ -97,12 +124,17 @@ class _InvoiceItem extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||||
style: UiTypography.body2b.textPrimary,
|
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15),
|
||||||
),
|
),
|
||||||
_StatusBadge(status: invoice.status),
|
_StatusBadge(status: invoice.status),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox.shrink(),
|
const SizedBox(width: UiConstants.space4),
|
||||||
|
Icon(
|
||||||
|
UiIcons.download,
|
||||||
|
size: 20,
|
||||||
|
color: UiColors.iconSecondary.withOpacity(0.3),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
|
|
||||||
/// Section showing invoices awaiting approval.
|
/// Section showing a banner for invoices awaiting approval.
|
||||||
class PendingInvoicesSection extends StatelessWidget {
|
class PendingInvoicesSection extends StatelessWidget {
|
||||||
/// Creates a [PendingInvoicesSection].
|
/// Creates a [PendingInvoicesSection].
|
||||||
const PendingInvoicesSection({required this.invoices, super.key});
|
const PendingInvoicesSection({required this.invoices, super.key});
|
||||||
@@ -13,55 +15,86 @@ class PendingInvoicesSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Column(
|
if (invoices.isEmpty) return const SizedBox.shrink();
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
return GestureDetector(
|
||||||
Row(
|
onTap: () => Modular.to.toAwaitingApproval(),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.04),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: UiColors.textWarning,
|
color: Colors.orange,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space3),
|
||||||
Text(
|
Expanded(
|
||||||
t.client_billing.awaiting_approval,
|
child: Column(
|
||||||
style: UiTypography.title2b.textPrimary,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.client_billing.awaiting_approval,
|
||||||
|
style: UiTypography.body1b.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space2),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: UiColors.accent,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${invoices.length}',
|
||||||
|
style: UiTypography.footnote2b.copyWith(
|
||||||
|
color: UiColors.accentForeground,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
t.client_billing.review_and_approve_subtitle,
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
Icon(
|
||||||
Container(
|
UiIcons.chevronRight,
|
||||||
width: 24,
|
size: 20,
|
||||||
height: 24,
|
color: UiColors.iconSecondary.withOpacity(0.5),
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: UiColors.accent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'${invoices.length}',
|
|
||||||
style: UiTypography.footnote2b.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space3),
|
),
|
||||||
...invoices.map(
|
|
||||||
(BillingInvoice invoice) => Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: UiConstants.space2),
|
|
||||||
child: _PendingInvoiceCard(invoice: invoice),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PendingInvoiceCard extends StatelessWidget {
|
/// Card showing a single pending invoice.
|
||||||
const _PendingInvoiceCard({required this.invoice});
|
class PendingInvoiceCard extends StatelessWidget {
|
||||||
|
/// Creates a [PendingInvoiceCard].
|
||||||
|
const PendingInvoiceCard({required this.invoice, super.key});
|
||||||
|
|
||||||
final BillingInvoice invoice;
|
final BillingInvoice invoice;
|
||||||
|
|
||||||
@@ -71,17 +104,17 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
boxShadow: <BoxShadow>[
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
blurRadius: 8,
|
blurRadius: 12,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -89,10 +122,10 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
const Icon(
|
const Icon(
|
||||||
UiIcons.mapPin,
|
UiIcons.mapPin,
|
||||||
size: 14,
|
size: 16,
|
||||||
color: UiColors.iconSecondary,
|
color: UiColors.iconSecondary,
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space1),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
invoice.locationAddress,
|
invoice.locationAddress,
|
||||||
@@ -103,8 +136,8 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Text(invoice.title, style: UiTypography.body2b.textPrimary),
|
Text(invoice.title, style: UiTypography.headline4b.textPrimary),
|
||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
@@ -125,8 +158,8 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Container(
|
Container(
|
||||||
width: 6,
|
width: 8,
|
||||||
height: 6,
|
height: 8,
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: UiColors.textWarning,
|
color: UiColors.textWarning,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
@@ -134,7 +167,7 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
t.client_billing.pending_badge,
|
t.client_billing.pending_badge.toUpperCase(),
|
||||||
style: UiTypography.titleUppercase4b.copyWith(
|
style: UiTypography.titleUppercase4b.copyWith(
|
||||||
color: UiColors.textWarning,
|
color: UiColors.textWarning,
|
||||||
),
|
),
|
||||||
@@ -142,48 +175,49 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
Container(
|
const Divider(height: 1, color: UiColors.border),
|
||||||
padding: const EdgeInsets.symmetric(vertical: UiConstants.space3),
|
Padding(
|
||||||
decoration: const BoxDecoration(
|
padding: const EdgeInsets.symmetric(vertical: UiConstants.space4),
|
||||||
border: Border.symmetric(
|
|
||||||
horizontal: BorderSide(color: UiColors.border),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatItem(
|
child: _buildStatItem(
|
||||||
UiIcons.dollar,
|
UiIcons.dollar,
|
||||||
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
'\$${invoice.totalAmount.toStringAsFixed(2)}',
|
||||||
'Total',
|
t.client_billing.stats.total,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 30, color: UiColors.border),
|
Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatItem(
|
child: _buildStatItem(
|
||||||
UiIcons.users,
|
UiIcons.users,
|
||||||
'${invoice.workersCount}',
|
'${invoice.workersCount}',
|
||||||
'Workers',
|
t.client_billing.stats.workers,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(width: 1, height: 30, color: UiColors.border),
|
Container(width: 1, height: 32, color: UiColors.border.withOpacity(0.3)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildStatItem(
|
child: _buildStatItem(
|
||||||
UiIcons.clock,
|
UiIcons.clock,
|
||||||
invoice.totalHours.toStringAsFixed(1),
|
'${invoice.totalHours.toStringAsFixed(1)}',
|
||||||
'HRS',
|
t.client_billing.stats.hrs,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const Divider(height: 1, color: UiColors.border),
|
||||||
|
const SizedBox(height: UiConstants.space5),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: UiButton.primary(
|
child: UiButton.primary(
|
||||||
text: 'Review & Approve',
|
text: t.client_billing.review_and_approve,
|
||||||
onPressed: () {},
|
leadingIcon: UiIcons.checkCircle,
|
||||||
size: UiButtonSize.small,
|
onPressed: () => Modular.to.toCompletionReview(arguments: invoice),
|
||||||
|
size: UiButtonSize.large,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
textStyle: UiTypography.body1b.copyWith(fontSize: 16),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -195,12 +229,18 @@ class _PendingInvoiceCard extends StatelessWidget {
|
|||||||
Widget _buildStatItem(IconData icon, String value, String label) {
|
Widget _buildStatItem(IconData icon, String value, String label) {
|
||||||
return Column(
|
return Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Icon(icon, size: 14, color: UiColors.iconSecondary),
|
Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 6),
|
||||||
Text(value, style: UiTypography.body2b.textPrimary),
|
|
||||||
Text(
|
Text(
|
||||||
label.toUpperCase(),
|
value,
|
||||||
style: UiTypography.titleUppercase4m.textSecondary,
|
style: UiTypography.body1b.textPrimary.copyWith(fontSize: 16),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label.toLowerCase(),
|
||||||
|
style: UiTypography.titleUppercase4m.textSecondary.copyWith(
|
||||||
|
fontSize: 10,
|
||||||
|
letterSpacing: 0,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -113,6 +113,25 @@ class HomeRepositoryImpl
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<Benefit>> getBenefits() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final staffId = await _service.getStaffId();
|
||||||
|
final response = await _service.connector
|
||||||
|
.listBenefitsDataByStaffId(staffId: staffId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return response.data.benefitsDatas.map((data) {
|
||||||
|
final plan = data.vendorBenefitPlan;
|
||||||
|
return Benefit(
|
||||||
|
title: plan.title,
|
||||||
|
entitlementHours: plan.total?.toDouble() ?? 0.0,
|
||||||
|
usedHours: data.current.toDouble(),
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Mappers specific to Home's Domain Entity 'Shift'
|
// Mappers specific to Home's Domain Entity 'Shift'
|
||||||
// Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift.
|
// Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift.
|
||||||
|
|
||||||
|
|||||||
@@ -17,4 +17,7 @@ abstract class HomeRepository {
|
|||||||
|
|
||||||
/// Retrieves the current staff member's name.
|
/// Retrieves the current staff member's name.
|
||||||
Future<String?> getStaffName();
|
Future<String?> getStaffName();
|
||||||
|
|
||||||
|
/// Retrieves the list of benefits for the staff member.
|
||||||
|
Future<List<Benefit>> getBenefits();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,18 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
|||||||
await handleError(
|
await handleError(
|
||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
// Fetch shifts, name, and profile completion status concurrently
|
// Fetch shifts, name, benefits and profile completion status concurrently
|
||||||
final shiftsAndProfile = await Future.wait([
|
final results = await Future.wait([
|
||||||
_getHomeShifts.call(),
|
_getHomeShifts.call(),
|
||||||
_getPersonalInfoCompletion.call(),
|
_getPersonalInfoCompletion.call(),
|
||||||
|
_repository.getBenefits(),
|
||||||
|
_repository.getStaffName(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final homeResult = shiftsAndProfile[0] as HomeShifts;
|
final homeResult = results[0] as HomeShifts;
|
||||||
final isProfileComplete = shiftsAndProfile[1] as bool;
|
final isProfileComplete = results[1] as bool;
|
||||||
final name = await _repository.getStaffName();
|
final benefits = results[2] as List<Benefit>;
|
||||||
|
final name = results[3] as String?;
|
||||||
|
|
||||||
if (isClosed) return;
|
if (isClosed) return;
|
||||||
emit(
|
emit(
|
||||||
@@ -53,6 +56,7 @@ class HomeCubit extends Cubit<HomeState> with BlocErrorHandler<HomeState> {
|
|||||||
recommendedShifts: homeResult.recommended,
|
recommendedShifts: homeResult.recommended,
|
||||||
staffName: name,
|
staffName: name,
|
||||||
isProfileComplete: isProfileComplete,
|
isProfileComplete: isProfileComplete,
|
||||||
|
benefits: benefits,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class HomeState extends Equatable {
|
|||||||
final bool isProfileComplete;
|
final bool isProfileComplete;
|
||||||
final String? staffName;
|
final String? staffName;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final List<Benefit> benefits;
|
||||||
|
|
||||||
const HomeState({
|
const HomeState({
|
||||||
required this.status,
|
required this.status,
|
||||||
@@ -21,6 +22,7 @@ class HomeState extends Equatable {
|
|||||||
this.isProfileComplete = false,
|
this.isProfileComplete = false,
|
||||||
this.staffName,
|
this.staffName,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.benefits = const [],
|
||||||
});
|
});
|
||||||
|
|
||||||
const HomeState.initial() : this(status: HomeStatus.initial);
|
const HomeState.initial() : this(status: HomeStatus.initial);
|
||||||
@@ -34,6 +36,7 @@ class HomeState extends Equatable {
|
|||||||
bool? isProfileComplete,
|
bool? isProfileComplete,
|
||||||
String? staffName,
|
String? staffName,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
List<Benefit>? benefits,
|
||||||
}) {
|
}) {
|
||||||
return HomeState(
|
return HomeState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -44,6 +47,7 @@ class HomeState extends Equatable {
|
|||||||
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
isProfileComplete: isProfileComplete ?? this.isProfileComplete,
|
||||||
staffName: staffName ?? this.staffName,
|
staffName: staffName ?? this.staffName,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
benefits: benefits ?? this.benefits,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,5 +61,6 @@ class HomeState extends Equatable {
|
|||||||
isProfileComplete,
|
isProfileComplete,
|
||||||
staffName,
|
staffName,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
benefits,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// Page displaying a detailed overview of the worker's benefits.
|
||||||
|
class BenefitsOverviewPage extends StatelessWidget {
|
||||||
|
/// Creates a [BenefitsOverviewPage].
|
||||||
|
const BenefitsOverviewPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider<HomeCubit>.value(
|
||||||
|
value: Modular.get<HomeCubit>(),
|
||||||
|
child: Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8FAFC),
|
||||||
|
appBar: _buildAppBar(context),
|
||||||
|
body: BlocBuilder<HomeCubit, HomeState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state.status == HomeStatus.loading ||
|
||||||
|
state.status == HomeStatus.initial) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.status == HomeStatus.error) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
child: Text(
|
||||||
|
state.errorMessage ?? t.staff.home.benefits.overview.subtitle,
|
||||||
|
style: UiTypography.body1r.textSecondary,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final benefits = state.benefits;
|
||||||
|
if (benefits.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
child: Text(
|
||||||
|
t.staff.home.benefits.overview.subtitle,
|
||||||
|
style: UiTypography.body1r.textSecondary,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: UiConstants.space4,
|
||||||
|
right: UiConstants.space4,
|
||||||
|
top: UiConstants.space6,
|
||||||
|
bottom: 120,
|
||||||
|
),
|
||||||
|
itemCount: benefits.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: UiConstants.space4),
|
||||||
|
child: _BenefitCard(benefit: benefits[index]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||||
|
return AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(UiIcons.chevronLeft, color: UiColors.iconPrimary),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
title: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.staff.home.benefits.overview.title,
|
||||||
|
style: UiTypography.title2b.textPrimary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
t.staff.home.benefits.overview.subtitle,
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(1),
|
||||||
|
child: Container(color: UiColors.border.withOpacity(0.5), height: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BenefitCard extends StatelessWidget {
|
||||||
|
final Benefit benefit;
|
||||||
|
|
||||||
|
const _BenefitCard({required this.benefit});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final bool isSickLeave = benefit.title.toLowerCase().contains('sick');
|
||||||
|
final bool isVacation = benefit.title.toLowerCase().contains('vacation');
|
||||||
|
final bool isHolidays = benefit.title.toLowerCase().contains('holiday');
|
||||||
|
|
||||||
|
final i18n = t.staff.home.benefits.overview;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: UiConstants.radiusLg,
|
||||||
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: UiColors.black.withOpacity(0.02),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildProgressCircle(),
|
||||||
|
const SizedBox(width: UiConstants.space4),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
benefit.title,
|
||||||
|
style: UiTypography.body1b.textPrimary,
|
||||||
|
),
|
||||||
|
const Icon(UiIcons.info, size: 18, color: Color(0xFFE2E8F0)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_getSubtitle(benefit.title),
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
if (isSickLeave) ...[
|
||||||
|
_AccordionHistory(label: i18n.sick_leave_history),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
],
|
||||||
|
if (isVacation || isHolidays) ...[
|
||||||
|
_buildComplianceBanner(i18n.compliance_banner),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: UiButton.primary(
|
||||||
|
text: i18n.request_payment(benefit: benefit.title),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF0038A8),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
// TODO: Implement payment request
|
||||||
|
UiSnackbar.show(context, message: i18n.request_submitted(benefit: benefit.title), type: UiSnackbarType.success);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProgressCircle() {
|
||||||
|
final double progress = benefit.entitlementHours > 0
|
||||||
|
? (benefit.remainingHours / benefit.entitlementHours)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
final bool isSickLeave = benefit.title.toLowerCase().contains('sick');
|
||||||
|
final Color circleColor = isSickLeave ? const Color(0xFF2563EB) : const Color(0xFF10B981);
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
child: CustomPaint(
|
||||||
|
painter: _CircularProgressPainter(
|
||||||
|
progress: progress,
|
||||||
|
color: circleColor,
|
||||||
|
backgroundColor: const Color(0xFFE2E8F0),
|
||||||
|
strokeWidth: 6,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}',
|
||||||
|
style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.client_billing.hours_suffix,
|
||||||
|
style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getSubtitle(String title) {
|
||||||
|
final i18n = t.staff.home.benefits.overview;
|
||||||
|
if (title.toLowerCase().contains('sick')) {
|
||||||
|
return i18n.sick_leave_subtitle;
|
||||||
|
} else if (title.toLowerCase().contains('vacation')) {
|
||||||
|
return i18n.vacation_subtitle;
|
||||||
|
} else if (title.toLowerCase().contains('holiday')) {
|
||||||
|
return i18n.holidays_subtitle;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildComplianceBanner(String text) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFECFDF5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Icon(UiIcons.checkCircle, size: 16, color: Color(0xFF10B981)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: UiTypography.footnote1r.copyWith(
|
||||||
|
color: const Color(0xFF065F46),
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CircularProgressPainter extends CustomPainter {
|
||||||
|
final double progress;
|
||||||
|
final Color color;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final double strokeWidth;
|
||||||
|
|
||||||
|
_CircularProgressPainter({
|
||||||
|
required this.progress,
|
||||||
|
required this.color,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.strokeWidth,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final center = Offset(size.width / 2, size.height / 2);
|
||||||
|
final radius = (size.width - strokeWidth) / 2;
|
||||||
|
|
||||||
|
final backgroundPaint = Paint()
|
||||||
|
..color = backgroundColor
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth;
|
||||||
|
canvas.drawCircle(center, radius, backgroundPaint);
|
||||||
|
|
||||||
|
final progressPaint = Paint()
|
||||||
|
..color = color
|
||||||
|
..style = PaintingStyle.stroke
|
||||||
|
..strokeWidth = strokeWidth
|
||||||
|
..strokeCap = StrokeCap.round;
|
||||||
|
final sweepAngle = 2 * math.pi * progress;
|
||||||
|
canvas.drawArc(
|
||||||
|
Rect.fromCircle(center: center, radius: radius),
|
||||||
|
-math.pi / 2,
|
||||||
|
sweepAngle,
|
||||||
|
false,
|
||||||
|
progressPaint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccordionHistory extends StatefulWidget {
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _AccordionHistory({required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AccordionHistory> createState() => _AccordionHistoryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AccordionHistoryState extends State<_AccordionHistory> {
|
||||||
|
bool _isExpanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Divider(height: 1, color: Color(0xFFE2E8F0)),
|
||||||
|
InkWell(
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_isExpanded = !_isExpanded;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.label,
|
||||||
|
style: UiTypography.footnote2b.textSecondary.copyWith(
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
fontSize: 11,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
_isExpanded ? UiIcons.chevronUp : UiIcons.chevronDown,
|
||||||
|
size: 16,
|
||||||
|
color: UiColors.iconSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_isExpanded) ...[
|
||||||
|
_buildHistoryItem('1 Jan, 2024', 'Pending', const Color(0xFFF1F5F9), const Color(0xFF64748B)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildHistoryItem('28 Jan, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_buildHistoryItem('5 Feb, 2024', 'Submitted', const Color(0xFFECFDF5), const Color(0xFF10B981)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
]
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHistoryItem(String date, String status, Color bgColor, Color textColor) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
date,
|
||||||
|
style: UiTypography.footnote1r.textSecondary.copyWith(
|
||||||
|
fontSize: 12,
|
||||||
|
color: const Color(0xFF64748B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bgColor,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: status == 'Pending' ? Border.all(color: const Color(0xFFE2E8F0)) : null,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
status,
|
||||||
|
style: UiTypography.footnote2m.copyWith(
|
||||||
|
color: textColor,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.
|
|||||||
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
|
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
|
||||||
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
|
||||||
|
import 'package:staff_home/src/presentation/widgets/worker/benefits_widget.dart';
|
||||||
|
|
||||||
/// The home page for the staff worker application.
|
/// The home page for the staff worker application.
|
||||||
///
|
///
|
||||||
@@ -212,6 +213,16 @@ class WorkerHomePage extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space6),
|
const SizedBox(height: UiConstants.space6),
|
||||||
|
|
||||||
|
// Benefits
|
||||||
|
BlocBuilder<HomeCubit, HomeState>(
|
||||||
|
buildWhen: (previous, current) =>
|
||||||
|
previous.benefits != current.benefits,
|
||||||
|
builder: (context, state) {
|
||||||
|
return BenefitsWidget(benefits: state.benefits);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space6),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,84 +1,90 @@
|
|||||||
import 'package:design_system/design_system.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
|
||||||
|
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
|
import 'package:design_system/design_system.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Widget for displaying staff benefits, using design system tokens.
|
/// Widget for displaying staff benefits, using design system tokens.
|
||||||
class BenefitsWidget extends StatelessWidget {
|
class BenefitsWidget extends StatelessWidget {
|
||||||
|
/// The list of benefits to display.
|
||||||
|
final List<Benefit> benefits;
|
||||||
|
|
||||||
/// Creates a [BenefitsWidget].
|
/// Creates a [BenefitsWidget].
|
||||||
const BenefitsWidget({super.key});
|
const BenefitsWidget({
|
||||||
|
required this.benefits,
|
||||||
|
super.key,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i18n = t.staff.home.benefits;
|
final i18n = t.staff.home.benefits;
|
||||||
|
|
||||||
|
if (benefits.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border.withOpacity(0.5)),
|
||||||
boxShadow: [
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.05),
|
color: UiColors.black.withOpacity(0.03),
|
||||||
blurRadius: 2,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
i18n.title,
|
i18n.title,
|
||||||
style: UiTypography.title1m.textPrimary,
|
style: UiTypography.body1b.textPrimary,
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () => Modular.to.pushNamed('/benefits'),
|
onTap: () => Modular.to.toBenefits(),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
i18n.view_all,
|
i18n.view_all,
|
||||||
style: UiTypography.buttonL.textPrimary,
|
style: UiTypography.footnote2r.copyWith(
|
||||||
|
color: const Color(0xFF2563EB),
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
const SizedBox(width: 4),
|
||||||
|
const Icon(
|
||||||
UiIcons.chevronRight,
|
UiIcons.chevronRight,
|
||||||
size: UiConstants.space4,
|
size: 14,
|
||||||
color: UiColors.primary,
|
color: Color(0xFF2563EB),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space6),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: benefits.map((Benefit benefit) {
|
||||||
_BenefitItem(
|
return Expanded(
|
||||||
label: i18n.items.sick_days,
|
child: _BenefitItem(
|
||||||
current: 10,
|
label: benefit.title,
|
||||||
total: 40,
|
remaining: benefit.remainingHours,
|
||||||
color: UiColors.primary,
|
total: benefit.entitlementHours,
|
||||||
),
|
used: benefit.usedHours,
|
||||||
_BenefitItem(
|
color: const Color(0xFF2563EB),
|
||||||
label: i18n.items.vacation,
|
),
|
||||||
current: 40,
|
);
|
||||||
total: 40,
|
}).toList(),
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
_BenefitItem(
|
|
||||||
label: i18n.items.holidays,
|
|
||||||
current: 24,
|
|
||||||
total: 24,
|
|
||||||
color: UiColors.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -88,53 +94,64 @@ class BenefitsWidget extends StatelessWidget {
|
|||||||
|
|
||||||
class _BenefitItem extends StatelessWidget {
|
class _BenefitItem extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final double current;
|
final double remaining;
|
||||||
final double total;
|
final double total;
|
||||||
|
final double used;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|
||||||
const _BenefitItem({
|
const _BenefitItem({
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.current,
|
required this.remaining,
|
||||||
required this.total,
|
required this.total,
|
||||||
|
required this.used,
|
||||||
required this.color,
|
required this.color,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final i18n = t.staff.home.benefits;
|
final double progress = total > 0 ? (remaining / total) : 0.0;
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: <Widget>[
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: UiConstants.space14,
|
width: 64,
|
||||||
height: UiConstants.space14,
|
height: 64,
|
||||||
child: CustomPaint(
|
child: CustomPaint(
|
||||||
painter: _CircularProgressPainter(
|
painter: _CircularProgressPainter(
|
||||||
progress: current / total,
|
progress: progress,
|
||||||
color: color,
|
color: color,
|
||||||
backgroundColor: UiColors.border,
|
backgroundColor: const Color(0xFFE2E8F0),
|
||||||
strokeWidth: 4,
|
strokeWidth: 5,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'${current.toInt()}/${total.toInt()}',
|
'${remaining.toInt()}/${total.toInt()}',
|
||||||
style: UiTypography.body3m.textPrimary,
|
style: UiTypography.body2b.textPrimary.copyWith(
|
||||||
|
fontSize: 12,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
i18n.hours_label,
|
'hours',
|
||||||
style: UiTypography.footnote1r.textTertiary,
|
style: UiTypography.footnote2r.textTertiary.copyWith(
|
||||||
|
fontSize: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space3),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style: UiTypography.body3m.textSecondary,
|
style: UiTypography.footnote2r.textSecondary.copyWith(
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:krow_data_connect/krow_data_connect.dart';
|
|||||||
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
import 'package:staff_home/src/data/repositories/home_repository_impl.dart';
|
||||||
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
import 'package:staff_home/src/domain/repositories/home_repository.dart';
|
||||||
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
import 'package:staff_home/src/presentation/blocs/home_cubit.dart';
|
||||||
|
import 'package:staff_home/src/presentation/pages/benefits_overview_page.dart';
|
||||||
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
import 'package:staff_home/src/presentation/pages/worker_home_page.dart';
|
||||||
|
|
||||||
/// The module for the staff home feature.
|
/// The module for the staff home feature.
|
||||||
@@ -45,5 +46,9 @@ class StaffHomeModule extends Module {
|
|||||||
StaffPaths.childRoute(StaffPaths.home, StaffPaths.home),
|
StaffPaths.childRoute(StaffPaths.home, StaffPaths.home),
|
||||||
child: (BuildContext context) => const WorkerHomePage(),
|
child: (BuildContext context) => const WorkerHomePage(),
|
||||||
);
|
);
|
||||||
|
r.child(
|
||||||
|
StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits),
|
||||||
|
child: (BuildContext context) => const BenefitsOverviewPage(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
|
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
# GET WORKER BENEFIT BALANCES (M4)
|
||||||
|
# Returns all active benefit plans with balance data for a given worker.
|
||||||
|
# Supports: Sick Leave (40h), Holidays (24h), Vacation (40h)
|
||||||
|
# Extensible: any future VendorBenefitPlan will appear automatically.
|
||||||
|
#
|
||||||
|
# Fields:
|
||||||
|
# vendorBenefitPlan.title → benefit type name
|
||||||
|
# vendorBenefitPlan.total → total entitlement (hours)
|
||||||
|
# current → used hours
|
||||||
|
# remaining = total - current → computed client-side
|
||||||
|
# ----------------------------------------------------------
|
||||||
|
query getWorkerBenefitBalances(
|
||||||
|
$staffId: UUID!
|
||||||
|
) @auth(level: USER) {
|
||||||
|
benefitsDatas(
|
||||||
|
where: {
|
||||||
|
staffId: { eq: $staffId }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
vendorBenefitPlanId
|
||||||
|
current
|
||||||
|
|
||||||
|
vendorBenefitPlan {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
description
|
||||||
|
requestLabel
|
||||||
|
total
|
||||||
|
isActive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
# LIST ALL (admin/debug)
|
# LIST ALL (admin/debug)
|
||||||
# ----------------------------------------------------------
|
# ----------------------------------------------------------
|
||||||
|
|||||||
129
backend/dataconnect/connector/shiftDayCompletion/mutations.gql
Normal file
129
backend/dataconnect/connector/shiftDayCompletion/mutations.gql
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# CREATE — called automatically at the end of each shift day
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation createShiftDayCompletion(
|
||||||
|
$shiftId: UUID!
|
||||||
|
$orderId: UUID!
|
||||||
|
$businessId: UUID!
|
||||||
|
$vendorId: UUID!
|
||||||
|
$dayDate: Timestamp!
|
||||||
|
$dayNumber: Int!
|
||||||
|
$hours: Float
|
||||||
|
$cost: Float
|
||||||
|
$staffSummary: Any
|
||||||
|
$createdBy: String
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_insert(
|
||||||
|
data: {
|
||||||
|
shiftId: $shiftId
|
||||||
|
orderId: $orderId
|
||||||
|
businessId: $businessId
|
||||||
|
vendorId: $vendorId
|
||||||
|
dayDate: $dayDate
|
||||||
|
dayNumber: $dayNumber
|
||||||
|
status: PENDING_REVIEW
|
||||||
|
hours: $hours
|
||||||
|
cost: $cost
|
||||||
|
staffSummary: $staffSummary
|
||||||
|
createdBy: $createdBy
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# APPROVE — client approves a daily completion record
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation approveShiftDayCompletion(
|
||||||
|
$id: UUID!
|
||||||
|
$reviewedBy: String!
|
||||||
|
$reviewedAt: Timestamp!
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_update(
|
||||||
|
id: $id
|
||||||
|
data: {
|
||||||
|
status: APPROVED
|
||||||
|
reviewedBy: $reviewedBy
|
||||||
|
reviewedAt: $reviewedAt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# DISPUTE — client disputes a daily completion record
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation disputeShiftDayCompletion(
|
||||||
|
$id: UUID!
|
||||||
|
$reviewedBy: String!
|
||||||
|
$reviewedAt: Timestamp!
|
||||||
|
$disputeReason: String!
|
||||||
|
$disputeDetails: String
|
||||||
|
$disputedItems: Any
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_update(
|
||||||
|
id: $id
|
||||||
|
data: {
|
||||||
|
status: DISPUTED
|
||||||
|
reviewedBy: $reviewedBy
|
||||||
|
reviewedAt: $reviewedAt
|
||||||
|
disputeReason: $disputeReason
|
||||||
|
disputeDetails: $disputeDetails
|
||||||
|
disputedItems: $disputedItems
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LINK INVOICE — set once invoice is generated after full approval
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation linkInvoiceToShiftDayCompletion(
|
||||||
|
$id: UUID!
|
||||||
|
$invoiceId: UUID!
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_update(
|
||||||
|
id: $id
|
||||||
|
data: {
|
||||||
|
invoiceId: $invoiceId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# UPDATE — general-purpose update (admin use)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation updateShiftDayCompletion(
|
||||||
|
$id: UUID!
|
||||||
|
$status: ShiftDayCompletionStatus
|
||||||
|
$hours: Float
|
||||||
|
$cost: Float
|
||||||
|
$staffSummary: Any
|
||||||
|
$disputeReason: String
|
||||||
|
$disputeDetails: String
|
||||||
|
$disputedItems: Any
|
||||||
|
$reviewedBy: String
|
||||||
|
$reviewedAt: Timestamp
|
||||||
|
$invoiceId: UUID
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_update(
|
||||||
|
id: $id
|
||||||
|
data: {
|
||||||
|
status: $status
|
||||||
|
hours: $hours
|
||||||
|
cost: $cost
|
||||||
|
staffSummary: $staffSummary
|
||||||
|
disputeReason: $disputeReason
|
||||||
|
disputeDetails: $disputeDetails
|
||||||
|
disputedItems: $disputedItems
|
||||||
|
reviewedBy: $reviewedBy
|
||||||
|
reviewedAt: $reviewedAt
|
||||||
|
invoiceId: $invoiceId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# DELETE
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
mutation deleteShiftDayCompletion($id: UUID!) @auth(level: USER) {
|
||||||
|
shiftDayCompletion_delete(id: $id)
|
||||||
|
}
|
||||||
417
backend/dataconnect/connector/shiftDayCompletion/queries.gql
Normal file
417
backend/dataconnect/connector/shiftDayCompletion/queries.gql
Normal file
@@ -0,0 +1,417 @@
|
|||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# GET BY ID
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query getShiftDayCompletionById($id: UUID!) @auth(level: USER) {
|
||||||
|
shiftDayCompletion(id: $id) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
disputeReason
|
||||||
|
disputeDetails
|
||||||
|
disputedItems
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
createdBy
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
hours
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
order {
|
||||||
|
id
|
||||||
|
eventName
|
||||||
|
orderType
|
||||||
|
poReference
|
||||||
|
teamHub {
|
||||||
|
hubName
|
||||||
|
address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
business {
|
||||||
|
id
|
||||||
|
businessName
|
||||||
|
email
|
||||||
|
contactName
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor {
|
||||||
|
id
|
||||||
|
companyName
|
||||||
|
email
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
invoiceNumber
|
||||||
|
status
|
||||||
|
issueDate
|
||||||
|
dueDate
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST ALL COMPLETION RECORDS FOR A SHIFT
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listShiftDayCompletionsByShift(
|
||||||
|
$shiftId: UUID!
|
||||||
|
$offset: Int
|
||||||
|
$limit: Int
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: { shiftId: { eq: $shiftId } }
|
||||||
|
orderBy: { dayNumber: ASC }
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
disputeReason
|
||||||
|
disputeDetails
|
||||||
|
disputedItems
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
invoiceNumber
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST ALL COMPLETION RECORDS FOR AN ORDER
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listShiftDayCompletionsByOrder(
|
||||||
|
$orderId: UUID!
|
||||||
|
$offset: Int
|
||||||
|
$limit: Int
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: { orderId: { eq: $orderId } }
|
||||||
|
orderBy: { dayDate: ASC }
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
disputeReason
|
||||||
|
disputeDetails
|
||||||
|
disputedItems
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
invoiceNumber
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST PENDING REVIEW RECORDS FOR A BUSINESS (client view)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listPendingShiftDayCompletionsByBusiness(
|
||||||
|
$businessId: UUID!
|
||||||
|
$offset: Int
|
||||||
|
$limit: Int
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: {
|
||||||
|
businessId: { eq: $businessId }
|
||||||
|
status: { eq: PENDING_REVIEW }
|
||||||
|
}
|
||||||
|
orderBy: { dayDate: ASC }
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
location
|
||||||
|
locationAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
order {
|
||||||
|
id
|
||||||
|
eventName
|
||||||
|
orderType
|
||||||
|
poReference
|
||||||
|
teamHub {
|
||||||
|
hubName
|
||||||
|
address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vendor {
|
||||||
|
id
|
||||||
|
companyName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST ALL RECORDS FOR A BUSINESS FILTERED BY STATUS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listShiftDayCompletionsByBusinessAndStatus(
|
||||||
|
$businessId: UUID!
|
||||||
|
$status: ShiftDayCompletionStatus!
|
||||||
|
$offset: Int
|
||||||
|
$limit: Int
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: {
|
||||||
|
businessId: { eq: $businessId }
|
||||||
|
status: { eq: $status }
|
||||||
|
}
|
||||||
|
orderBy: { dayDate: DESC }
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
disputeReason
|
||||||
|
disputeDetails
|
||||||
|
disputedItems
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
order {
|
||||||
|
id
|
||||||
|
eventName
|
||||||
|
orderType
|
||||||
|
poReference
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
invoiceNumber
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST ALL APPROVED RECORDS FOR A SHIFT (invoice trigger check)
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listApprovedShiftDayCompletionsByShift(
|
||||||
|
$shiftId: UUID!
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: {
|
||||||
|
shiftId: { eq: $shiftId }
|
||||||
|
status: { eq: APPROVED }
|
||||||
|
}
|
||||||
|
orderBy: { dayNumber: ASC }
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
durationDays
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
status
|
||||||
|
order {
|
||||||
|
id
|
||||||
|
eventName
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
poReference
|
||||||
|
teamHub {
|
||||||
|
hubName
|
||||||
|
address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# LIST ALL RECORDS BY VENDOR FILTERED BY STATUS
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
query listShiftDayCompletionsByVendorAndStatus(
|
||||||
|
$vendorId: UUID!
|
||||||
|
$status: ShiftDayCompletionStatus!
|
||||||
|
$offset: Int
|
||||||
|
$limit: Int
|
||||||
|
) @auth(level: USER) {
|
||||||
|
shiftDayCompletions(
|
||||||
|
where: {
|
||||||
|
vendorId: { eq: $vendorId }
|
||||||
|
status: { eq: $status }
|
||||||
|
}
|
||||||
|
orderBy: { dayDate: DESC }
|
||||||
|
offset: $offset
|
||||||
|
limit: $limit
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
shiftId
|
||||||
|
orderId
|
||||||
|
businessId
|
||||||
|
vendorId
|
||||||
|
dayDate
|
||||||
|
dayNumber
|
||||||
|
status
|
||||||
|
hours
|
||||||
|
cost
|
||||||
|
staffSummary
|
||||||
|
disputeReason
|
||||||
|
disputeDetails
|
||||||
|
reviewedBy
|
||||||
|
reviewedAt
|
||||||
|
invoiceId
|
||||||
|
createdAt
|
||||||
|
updatedAt
|
||||||
|
|
||||||
|
shift {
|
||||||
|
id
|
||||||
|
title
|
||||||
|
date
|
||||||
|
startTime
|
||||||
|
endTime
|
||||||
|
durationDays
|
||||||
|
status
|
||||||
|
}
|
||||||
|
|
||||||
|
business {
|
||||||
|
id
|
||||||
|
businessName
|
||||||
|
email
|
||||||
|
}
|
||||||
|
|
||||||
|
invoice {
|
||||||
|
id
|
||||||
|
invoiceNumber
|
||||||
|
status
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
type BenefitsData @table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) {
|
type BenefitsData
|
||||||
|
@table(name: "benefits_data", key: ["staffId", "vendorBenefitPlanId"]) {
|
||||||
id: UUID! @default(expr: "uuidV4()")
|
id: UUID! @default(expr: "uuidV4()")
|
||||||
|
|
||||||
vendorBenefitPlanId: UUID!
|
vendorBenefitPlanId: UUID!
|
||||||
vendorBenefitPlan: VendorBenefitPlan! @ref( fields: "vendorBenefitPlanId", references: "id" )
|
vendorBenefitPlan: VendorBenefitPlan!
|
||||||
|
@ref(fields: "vendorBenefitPlanId", references: "id")
|
||||||
|
|
||||||
current: Int!
|
current: Int!
|
||||||
|
|
||||||
staffId: UUID!
|
staffId: UUID!
|
||||||
staff: Staff! @ref( fields: "staffId", references: "id" )
|
staff: Staff! @ref(fields: "staffId", references: "id")
|
||||||
|
|
||||||
createdAt: Timestamp @default(expr: "request.time")
|
createdAt: Timestamp @default(expr: "request.time")
|
||||||
updatedAt: Timestamp @default(expr: "request.time")
|
updatedAt: Timestamp @default(expr: "request.time")
|
||||||
|
|||||||
45
backend/dataconnect/schema/shiftDayCompletion.gql
Normal file
45
backend/dataconnect/schema/shiftDayCompletion.gql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
enum ShiftDayCompletionStatus {
|
||||||
|
PENDING_REVIEW
|
||||||
|
APPROVED
|
||||||
|
DISPUTED
|
||||||
|
}
|
||||||
|
|
||||||
|
type ShiftDayCompletion @table(name: "shift_day_completions", key: ["id"]) {
|
||||||
|
id: UUID! @default(expr: "uuidV4()")
|
||||||
|
|
||||||
|
shiftId: UUID!
|
||||||
|
shift: Shift! @ref(fields: "shiftId", references: "id")
|
||||||
|
|
||||||
|
orderId: UUID!
|
||||||
|
order: Order! @ref(fields: "orderId", references: "id")
|
||||||
|
|
||||||
|
businessId: UUID!
|
||||||
|
business: Business! @ref(fields: "businessId", references: "id")
|
||||||
|
|
||||||
|
vendorId: UUID!
|
||||||
|
vendor: Vendor! @ref(fields: "vendorId", references: "id")
|
||||||
|
|
||||||
|
dayDate: Timestamp!
|
||||||
|
dayNumber: Int!
|
||||||
|
|
||||||
|
status: ShiftDayCompletionStatus! @default(expr: "'PENDING_REVIEW'")
|
||||||
|
|
||||||
|
hours: Float
|
||||||
|
cost: Float
|
||||||
|
|
||||||
|
staffSummary: Any @col(dataType: "jsonb")
|
||||||
|
|
||||||
|
disputeReason: String
|
||||||
|
disputeDetails: String
|
||||||
|
disputedItems: Any @col(dataType: "jsonb")
|
||||||
|
|
||||||
|
reviewedBy: String
|
||||||
|
reviewedAt: Timestamp
|
||||||
|
|
||||||
|
invoiceId: UUID
|
||||||
|
invoice: Invoice @ref(fields: "invoiceId", references: "id")
|
||||||
|
|
||||||
|
createdAt: Timestamp @default(expr: "request.time")
|
||||||
|
updatedAt: Timestamp @default(expr: "request.time")
|
||||||
|
createdBy: String
|
||||||
|
}
|
||||||
91
docs/ARCHITECTURE/web.md
Normal file
91
docs/ARCHITECTURE/web.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
# KROW Web Application Architecture
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
The KROW Web Application serves as the "Command Center" for the platform, catering to administrators, HR, finance, and client executives. It is a high-performance, scalable dashboard designed for complex data management and analytics.
|
||||||
|
|
||||||
|
## 2. Tech Stack
|
||||||
|
- **Framework**: [React 19](https://react.dev/)
|
||||||
|
- **Build Tool**: [Vite](https://vitejs.dev/)
|
||||||
|
- **Styling**: [Tailwind CSS v4](https://tailwindcss.com/)
|
||||||
|
- **State Management**: [Redux Toolkit](https://redux-toolkit.js.org/)
|
||||||
|
- **Data Fetching**: [TanStack Query (React Query)](https://tanstack.com/query/latest)
|
||||||
|
- **Backend Integration**: Firebase Data Connect + PostgreSQL
|
||||||
|
- **Language**: TypeScript
|
||||||
|
|
||||||
|
## 3. Monorepo & Project Structure
|
||||||
|
|
||||||
|
### Recommendation: Skip Nx
|
||||||
|
After evaluating `nx` for the KROW project, the recommendation is to **skip its adoption** at this stage.
|
||||||
|
|
||||||
|
**Reasoning:**
|
||||||
|
- **Existing Orchestration**: The root `Makefile` and `Melos` (for mobile) already provide a robust orchestration layer. Adding `nx` would introduce redundant complexity.
|
||||||
|
- **Heterogeneous Stack**: `nx` excels in JS/TS-heavy monorepos. Our project is a mix of Flutter (Dart) and React (TS), which reduces the native benefits of `nx`.
|
||||||
|
- **Maintainability**: The overhead of learning and maintaining `nx` configurations outweighs the current benefits for a project of this scale.
|
||||||
|
|
||||||
|
### Future Alternative: Turborepo
|
||||||
|
If caching and task orchestration become a bottleneck for the web/JS side, **Turborepo** is recommended as a lighter alternative that integrates seamlessly with our current `pnpm` setup.
|
||||||
|
|
||||||
|
### Final Project Structure (Unified)
|
||||||
|
```
|
||||||
|
/apps
|
||||||
|
/web # React Web Dashboard
|
||||||
|
/mobile # Flutter Mobile Apps (Melos monorepo)
|
||||||
|
/packages
|
||||||
|
/design-tokens # Shared Design System (TS/JSON)
|
||||||
|
/backend
|
||||||
|
/dataconnect # Shared GraphQL Schemas
|
||||||
|
/docs
|
||||||
|
/ARCHITECTURE # Architecture Documentation
|
||||||
|
/Makefile # Unified Command Orchestrator
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Shared Design System
|
||||||
|
|
||||||
|
### Package: `@krow/design-tokens`
|
||||||
|
A dedicated package at `/packages/design-tokens` serves as the single source of truth for design constants across all platforms.
|
||||||
|
|
||||||
|
**Folder Structure:**
|
||||||
|
```
|
||||||
|
/packages/design-tokens
|
||||||
|
/src
|
||||||
|
/colors.ts # Color palette definitions
|
||||||
|
/typography.ts # Typography scale and font families
|
||||||
|
/index.ts # Main export entry
|
||||||
|
/package.json
|
||||||
|
/tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Color Palette (Aligned with Mobile)
|
||||||
|
Based on `UiColors` from the mobile app:
|
||||||
|
- **Primary**: `#0A39DF` (Brand Blue)
|
||||||
|
- **Accent**: `#F9E547` (Accent Yellow)
|
||||||
|
- **Background**: `#FAFBFC`
|
||||||
|
- **Foreground**: `#121826`
|
||||||
|
- **Secondary**: `#F1F3F5`
|
||||||
|
- **Muted**: `#F1F3F5`
|
||||||
|
- **Destructive**: `#F04444` (Error Red)
|
||||||
|
- **Success**: `#10B981` (Success Green)
|
||||||
|
- **Border**: `#D1D5DB`
|
||||||
|
|
||||||
|
### Typography Scale (Aligned with Mobile)
|
||||||
|
- **Primary Font**: Instrument Sans
|
||||||
|
- **Secondary Font**: Space Grotesk
|
||||||
|
- **Scales**:
|
||||||
|
- **Display L**: 36px, Height 1.1
|
||||||
|
- **Display M**: 32px, Height 1.1
|
||||||
|
- **Title 1**: 18px, Height 1.5
|
||||||
|
- **Body 1**: 16px, Height 1.5
|
||||||
|
- **Body 2**: 14px, Height 1.5
|
||||||
|
|
||||||
|
### Implementation Strategy
|
||||||
|
1. **Definition**: Define tokens in TypeScript (or JSON) within `/packages/design-tokens`.
|
||||||
|
2. **Web Consumption**: Export tokens for use in `tailwind.config.ts` or as CSS variables.
|
||||||
|
3. **Mobile Consumption**: Use a script to generate `ui_colors.dart` and `ui_typography.dart` from the shared tokens to ensure perfect alignment.
|
||||||
|
|
||||||
|
## 5. Web Application Organization
|
||||||
|
The web application follows a **feature-based** modular architecture:
|
||||||
|
- `src/features/`: Contains feature-specific logic, components, and hooks (e.g., `billing`, `scheduling`, `admin`).
|
||||||
|
- `src/components/shared/`: Reusable UI components built with Tailwind.
|
||||||
|
- `src/hooks/`: Shared React hooks.
|
||||||
|
- `src/store/`: Redux slices for global state.
|
||||||
|
- `src/dataconnect-generated/`: SDK generated by Firebase Data Connect.
|
||||||
266
docs/api-contracts.md
Normal file
266
docs/api-contracts.md
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
# KROW Workforce API Contracts
|
||||||
|
|
||||||
|
This document captures all API contracts used by the Staff and Client mobile applications. It serves as a single reference document to understand what each endpoint does, its expected inputs, returned outputs, and any non-obvious details.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Staff Application
|
||||||
|
|
||||||
|
### Authentication / Onboarding Pages (Get Started, Intro, Phone Verification, Profile Setup, Personal Info)
|
||||||
|
#### Setup / User Validation API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getUserById` |
|
||||||
|
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is STAFF). |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `id: UUID!` (Firebase UID) |
|
||||||
|
| **Outputs** | `User { id, email, phone, role }` |
|
||||||
|
| **Notes** | Required after OTP verification to route users. |
|
||||||
|
|
||||||
|
#### Create Default User API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/createUser` |
|
||||||
|
| **Purpose** | Inserts a base user record into the system during initial signup. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `id: UUID!`, `role: UserBaseRole` |
|
||||||
|
| **Outputs** | `id` of newly created User |
|
||||||
|
| **Notes** | Used explicitly during the "Sign Up" flow if the user doesn't exist. |
|
||||||
|
|
||||||
|
#### Get Staff Profile API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getStaffByUserId` |
|
||||||
|
| **Purpose** | Finds the specific Staff record associated with the base user ID. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `userId: UUID!` |
|
||||||
|
| **Outputs** | `Staffs { id, userId, fullName, email, phone, photoUrl, status }` |
|
||||||
|
| **Notes** | Needed to verify if a complete staff profile exists before fully authenticating. |
|
||||||
|
|
||||||
|
#### Update Staff Profile API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/updateStaff` |
|
||||||
|
| **Purpose** | Saves onboarding data across Personal Info, Experience, and Preferred Locations pages. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `id: UUID!`, `fullName`, `email`, `phone`, `addres`, etc. |
|
||||||
|
| **Outputs** | `id` |
|
||||||
|
| **Notes** | Called incrementally during profile setup wizard. |
|
||||||
|
|
||||||
|
### Home Page (worker_home_page.dart) & Benefits Overview
|
||||||
|
#### Load Today/Tomorrow Shifts
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getApplicationsByStaffId` |
|
||||||
|
| **Purpose** | Retrieves applications (shifts) assigned to the current staff member within a specific date range. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `staffId: UUID!`, `dayStart: Timestamp`, `dayEnd: Timestamp` |
|
||||||
|
| **Outputs** | `Applications { shift, shiftRole, status, createdAt }` |
|
||||||
|
| **Notes** | The frontend filters the query response for `CONFIRMED` applications to display "Today's" and "Tomorrow's" shifts. |
|
||||||
|
|
||||||
|
#### List Recommended Shifts
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listShifts` |
|
||||||
|
| **Purpose** | Fetches open shifts that are available for the staff to apply to. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | None directly mapped, but filters OPEN shifts purely on the client side at the time. |
|
||||||
|
| **Outputs** | `Shifts { id, title, orderId, cost, location, startTime, endTime, status }` |
|
||||||
|
| **Notes** | Limits output to 10 on the frontend. Should ideally rely on a `$status: OPEN` parameter. |
|
||||||
|
|
||||||
|
#### Benefits Summary API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listBenefitsDataByStaffId` |
|
||||||
|
| **Purpose** | Retrieves accrued benefits (e.g., Sick time, Vacation) to display on the home screen. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `staffId: UUID!` |
|
||||||
|
| **Outputs** | `BenefitsDatas { vendorBenefitPlan { title, total }, current }` |
|
||||||
|
| **Notes** | Calculates `usedHours = total - current`. |
|
||||||
|
|
||||||
|
### Find Shifts / Shift Details Pages (shifts_page.dart)
|
||||||
|
#### List Available Shifts Filtered
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/filterShifts` |
|
||||||
|
| **Purpose** | Used to fetch Open Shifts in specific regions when the worker searches in the "Find Shifts" tab. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `$status: ShiftStatus`, `$dateFrom: Timestamp`, `$dateTo: Timestamp` |
|
||||||
|
| **Outputs** | `Shifts { id, title, location, cost, durationDays, order { business, vendor } }` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
#### Get Shift Details
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getShiftById` |
|
||||||
|
| **Purpose** | Gets deeper details for a single shift including exact uniform/managers needed. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `id: UUID!` |
|
||||||
|
| **Outputs** | `Shift { id, title, hours, cost, locationAddress, workersNeeded ... }` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
#### Apply To Shift
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/createApplication` |
|
||||||
|
| **Purpose** | Worker submits an intent to take an open shift. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `shiftId`, `staffId`, `status: APPLIED` |
|
||||||
|
| **Outputs** | `Application ID` |
|
||||||
|
| **Notes** | A shift status will switch to `CONFIRMED` via admin approval. |
|
||||||
|
|
||||||
|
### Availability Page (availability_page.dart)
|
||||||
|
#### Get Default Availability
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listStaffAvailabilitiesByStaffId` |
|
||||||
|
| **Purpose** | Fetches the standard Mon-Sun recurring availability for a staff member. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `staffId: UUID!` |
|
||||||
|
| **Outputs** | `StaffAvailabilities { dayOfWeek, isAvailable, startTime, endTime }` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
#### Update Availability
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/updateStaffAvailability` (or `createStaffAvailability`) |
|
||||||
|
| **Purpose** | Upserts availability preferences. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `staffId`, `dayOfWeek`, `isAvailable`, `startTime`, `endTime` |
|
||||||
|
| **Outputs** | `id` |
|
||||||
|
| **Notes** | Called individually per day edited. |
|
||||||
|
|
||||||
|
### Payments Page (payments_page.dart)
|
||||||
|
#### Get Recent Payments
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listRecentPaymentsByStaffId` |
|
||||||
|
| **Purpose** | Loads the history of earnings and timesheets completed by the staff. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `staffId: UUID!` |
|
||||||
|
| **Outputs** | `Payments { amount, processDate, shiftId, status }` |
|
||||||
|
| **Notes** | Displays historical metrics under Earnings tab. |
|
||||||
|
|
||||||
|
### Compliance / Profiles (Agreements, W4, I9, Documents)
|
||||||
|
#### Get Tax Forms
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getTaxFormsByStaffId` |
|
||||||
|
| **Purpose** | Check the filing status of I9 and W4 forms. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `staffId: UUID!` |
|
||||||
|
| **Outputs** | `TaxForms { formType, isCompleted, updatedDate }` |
|
||||||
|
| **Notes** | Required for staff to be eligible for shifts. |
|
||||||
|
|
||||||
|
#### Update Tax Forms
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/updateTaxForm` |
|
||||||
|
| **Purpose** | Submits state and filing for the given tax form type. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `id`, `dataPoints...` |
|
||||||
|
| **Outputs** | `id` |
|
||||||
|
| **Notes** | Updates compliance state. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Client Application
|
||||||
|
|
||||||
|
### Authentication / Intro (Sign In, Get Started)
|
||||||
|
#### Client User Validation API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getUserById` |
|
||||||
|
| **Purpose** | Retrieves the base user profile to determine authentication status and role access (e.g., if user is BUSINESS). |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `id: UUID!` (Firebase UID) |
|
||||||
|
| **Outputs** | `User { id, email, phone, userRole }` |
|
||||||
|
| **Notes** | Must check if `userRole == BUSINESS` or `BOTH`. |
|
||||||
|
|
||||||
|
#### Get Business Profile API
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getBusinessByUserId` |
|
||||||
|
| **Purpose** | Maps the authenticated user to their client business context. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `userId: UUID!` |
|
||||||
|
| **Outputs** | `Business { id, businessName, email, contactName }` |
|
||||||
|
| **Notes** | Used to set the working scopes (Business ID) across the entire app. |
|
||||||
|
|
||||||
|
### Hubs Page (client_hubs_page.dart, edit_hub.dart)
|
||||||
|
#### List Hubs
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listTeamHubsByBusinessId` |
|
||||||
|
| **Purpose** | Fetches the primary working sites (Hubs) for a client. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `businessId: UUID!` |
|
||||||
|
| **Outputs** | `TeamHubs { id, hubName, address, contact, active }` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
#### Update / Delete Hub
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/updateTeamHub` / `/deleteTeamHub` |
|
||||||
|
| **Purpose** | Edits or archives a Hub location. |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `id: UUID!`, `hubName`, `address`, etc (for Update) |
|
||||||
|
| **Outputs** | `id` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
### Orders Page (create_order, view_orders)
|
||||||
|
#### Create Order
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/createOrder` |
|
||||||
|
| **Purpose** | The client submits a new request for temporary staff (can result in multiple Shifts generated on the backend). |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `businessId`, `eventName`, `orderType`, `status` |
|
||||||
|
| **Outputs** | `id` (Order ID) |
|
||||||
|
| **Notes** | This creates an order. Shift instances are subsequently created through secondary mutations. |
|
||||||
|
|
||||||
|
#### List Orders
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getOrdersByBusinessId` |
|
||||||
|
| **Purpose** | Retrieves all ongoing and past staff requests from the client. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `businessId: UUID!` |
|
||||||
|
| **Outputs** | `Orders { id, eventName, shiftCount, status }` |
|
||||||
|
| **Notes** | - |
|
||||||
|
|
||||||
|
### Billing Pages (billing_page.dart, pending_invoices)
|
||||||
|
#### List Invoices
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/listInvoicesByBusinessId` |
|
||||||
|
| **Purpose** | Fetches "Pending", "Paid", and "Disputed" invoices for the client to review. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `businessId: UUID!` |
|
||||||
|
| **Outputs** | `Invoices { id, amountDue, issueDate, status }` |
|
||||||
|
| **Notes** | Used across all Billing view tabs. |
|
||||||
|
|
||||||
|
#### Mark Invoice
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/updateInvoice` |
|
||||||
|
| **Purpose** | Marks an invoice as disputed or pays it (changes status). |
|
||||||
|
| **Operation** | Mutation |
|
||||||
|
| **Inputs** | `id: UUID!`, `status: InvoiceStatus` |
|
||||||
|
| **Outputs** | `id` |
|
||||||
|
| **Notes** | Disputing usually involves setting a memo or flag. |
|
||||||
|
|
||||||
|
### Reports Page (reports_page.dart)
|
||||||
|
#### Get Coverage Stats
|
||||||
|
| Field | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Endpoint name** | `/getCoverageStatsByBusiness` |
|
||||||
|
| **Purpose** | Provides data on fulfillments rates vs actual requests. |
|
||||||
|
| **Operation** | Query |
|
||||||
|
| **Inputs** | `businessId: UUID!` |
|
||||||
|
| **Outputs** | `Stats { totalRequested, totalFilled, percentage }` |
|
||||||
|
| **Notes** | Driven mostly by aggregated backend views. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*This document reflects the current state of Data Connect definitions implemented across the frontend and mapped manually by reviewing Repository and UI logic.*
|
||||||
Reference in New Issue
Block a user