This commit is contained in:
Achintha Isuru
2026-02-24 11:28:45 -05:00
42 changed files with 3075 additions and 203 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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,

View File

@@ -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});
} }

View File

@@ -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 {

View File

@@ -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.

View File

@@ -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';

View File

@@ -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];
}

View File

@@ -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,
]; ];
} }

View File

@@ -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());
} }
} }

View File

@@ -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);
}
} }

View File

@@ -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);
} }

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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,
); );
} }

View File

@@ -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];
}

View File

@@ -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,
]; ];
} }

View File

@@ -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),
],
),
),
);
}
}

View File

@@ -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),
),
],
),
);
}
}

View File

@@ -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,
),
],
),
],
),
);
}
}

View File

@@ -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.

View 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),
),
], ],
), ),
); );

View File

@@ -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,
),
), ),
], ],
); );

View File

@@ -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.

View File

@@ -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();
} }

View File

@@ -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,
), ),
); );
}, },

View File

@@ -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,
]; ];
} }

View File

@@ -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,
),
),
),
],
);
}
}

View File

@@ -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),
], ],
), ),
), ),

View File

@@ -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,
), ),
], ],
); );

View File

@@ -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(),
);
} }
} }

View File

@@ -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)
# ---------------------------------------------------------- # ----------------------------------------------------------

View 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)
}

View 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
}
}
}

View File

@@ -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")

View 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
View 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
View 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.*