From 78ce0f6cda6aaec167b4ea502281e011277df803 Mon Sep 17 00:00:00 2001 From: dhinesh-m24 Date: Tue, 24 Feb 2026 12:36:17 +0530 Subject: [PATCH 1/5] feat:Update benfitsDate query to add retrieve worker benefits query --- .../connector/benefitsData/queries.gql | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/backend/dataconnect/connector/benefitsData/queries.gql b/backend/dataconnect/connector/benefitsData/queries.gql index 2bc60a37..c856fcbf 100644 --- a/backend/dataconnect/connector/benefitsData/queries.gql +++ b/backend/dataconnect/connector/benefitsData/queries.gql @@ -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) # ---------------------------------------------------------- From 7e26b54c501fc296d7596cd7ace51af60a324353 Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 24 Feb 2026 16:17:19 +0530 Subject: [PATCH 2/5] feat: complete client billing UI and staff benefits display (#524, #527) - Client App: Built dedicated ShiftCompletionReviewPage and InvoiceReadyPage - Client App: Wired up invoice summary mapping and parsing logic from Data Connect - Staff App: Added dynamic BenefitsOverviewPage tracking worker limits matching client mockup - Staff App: Display progress ring values wired to real VendorBenefitPlan & BenefitsData balances --- .../lib/src/routing/client/navigator.dart | 15 + .../lib/src/routing/client/route_paths.dart | 9 + .../core/lib/src/routing/staff/navigator.dart | 5 + .../lib/src/routing/staff/route_paths.dart | 3 + .../lib/src/l10n/en.i18n.json | 43 +- .../lib/src/l10n/es.i18n.json | 45 +- .../billing_connector_repository_impl.dart | 75 +++- .../billing_connector_repository.dart | 6 + .../staff_connector_repository_impl.dart | 22 + .../staff_connector_repository.dart | 5 + .../packages/domain/lib/krow_domain.dart | 1 + .../lib/src/entities/benefits/benefit.dart | 26 ++ .../lib/src/entities/financial/invoice.dart | 68 +++ .../billing/lib/src/billing_module.dart | 15 +- .../billing_repository_impl.dart | 10 + .../repositories/billing_repository.dart | 6 + .../src/domain/usecases/approve_invoice.dart | 13 + .../src/domain/usecases/dispute_invoice.dart | 21 + .../src/presentation/blocs/billing_bloc.dart | 114 ++++- .../src/presentation/blocs/billing_event.dart | 17 + .../models/billing_invoice_model.dart | 46 ++ .../src/presentation/pages/billing_page.dart | 176 ++++++-- .../pages/completion_review_page.dart | 421 ++++++++++++++++++ .../pages/invoice_ready_page.dart | 143 ++++++ .../pages/pending_invoices_page.dart | 124 ++++++ .../widgets/invoice_history_section.dart | 52 ++- .../widgets/pending_invoices_section.dart | 174 +++++--- .../repositories/home_repository_impl.dart | 31 ++ .../domain/repositories/home_repository.dart | 3 + .../src/presentation/blocs/home_cubit.dart | 16 +- .../src/presentation/blocs/home_state.dart | 5 + .../pages/benefits_overview_page.dart | 376 ++++++++++++++++ .../presentation/pages/worker_home_page.dart | 11 + .../widgets/worker/benefits_widget.dart | 135 +++--- .../staff/home/lib/src/staff_home_module.dart | 5 + 35 files changed, 2038 insertions(+), 199 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart create mode 100644 apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart create mode 100644 apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index 1c3c7c6e..0203f45d 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -94,6 +94,21 @@ extension ClientNavigator on IModularNavigator { 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. /// /// View and manage all shift orders with filtering and sorting. diff --git a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart index 900bb545..b0ec3514 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/route_paths.dart @@ -81,6 +81,15 @@ class ClientPaths { /// Access billing history, payment methods, and invoices. 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. /// /// List of all orders with filtering and status tracking. diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index f8761802..7b8a9f25 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -79,6 +79,11 @@ extension StaffNavigator on IModularNavigator { pushNamedAndRemoveUntil(StaffPaths.home, (_) => false); } + /// Navigates to the benefits overview page. + void toBenefits() { + pushNamed(StaffPaths.benefits); + } + /// Navigates to the staff main shell. /// /// This is the container with bottom navigation. Navigates to home tab diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index ef7ab6fe..f0a602ab 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -72,6 +72,9 @@ class StaffPaths { /// Displays shift cards, quick actions, and notifications. static const String home = '/worker-main/home/'; + /// Benefits overview page. + static const String benefits = '/worker-main/home/benefits'; + /// Shifts tab - view and manage shifts. /// /// Browse available shifts, accepted shifts, and shift history. diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index 6cdaef1e..b560e5f8 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -422,14 +422,53 @@ "month": "Month", "total": "Total", "hours": "$count hours", + "export_button": "Export All Invoices", "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", + "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", "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", "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": { "title": "Timesheets", "approve_button": "Approve", diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index e7ae1e76..938d4154 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -422,14 +422,53 @@ "month": "Mes", "total": "Total", "hours": "$count horas", + "export_button": "Exportar Todas las Facturas", "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", + "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", "view_all": "Ver todo", - "export_button": "Exportar Todas las Facturas", - "pending_badge": "PENDIENTE APROBACI\u00d3N", + "approved_success": "Factura aprobada y pago iniciado", + "flagged_success": "Factura marcada para revisi\u00f3n", + "pending_badge": "PENDIENTE", "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": { "title": "Hojas de Tiempo", "approve_button": "Aprobar", diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart index c8b3296a..7c955b71 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/data/repositories/billing_connector_repository_impl.dart @@ -42,10 +42,13 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { return _service.run(() async { final QueryResult result = await _service.connector .listInvoicesByBusinessId(businessId: businessId) - .limit(10) + .limit(20) .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 .map(_mapInvoice) .where((Invoice i) => - i.status == InvoiceStatus.open || i.status == InvoiceStatus.disputed) + i.status != InvoiceStatus.paid) .toList(); }); } @@ -132,9 +135,61 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { }); } + @override + Future approveInvoice({required String id}) async { + return _service.run(() async { + await _service.connector + .updateInvoice(id: id) + .status(dc.InvoiceStatus.APPROVED) + .execute(); + }); + } + + @override + Future 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 --- Invoice _mapInvoice(dynamic invoice) { + final List rolesData = invoice.roles is List ? invoice.roles : []; + final List workers = rolesData.map((dynamic r) { + final Map role = r as Map; + + // 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( id: invoice.id, eventId: invoice.orderId, @@ -145,9 +200,23 @@ class BillingConnectorRepositoryImpl implements BillingConnectorRepository { addonsAmount: invoice.otherCharges ?? 0, invoiceNumber: invoice.invoiceNumber, 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 roles) { + return roles.fold(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) { return BusinessBankAccountAdapter.fromPrimitives( id: account.id, diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart index aef57604..4d4b0464 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/billing/domain/repositories/billing_connector_repository.dart @@ -21,4 +21,10 @@ abstract interface class BillingConnectorRepository { required String businessId, required BillingPeriod period, }); + + /// Approves an invoice. + Future approveInvoice({required String id}); + + /// Disputes an invoice. + Future disputeInvoice({required String id, required String reason}); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 5af3d55b..e206c814 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -178,6 +178,28 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { }); } + @override + Future> getBenefits() async { + return _service.run(() async { + final String staffId = await _service.getStaffId(); + + final QueryResult 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: (plan.total ?? 0) - data.current.toDouble(), + ); + }).toList(); + }); + } + @override Future signOut() async { try { diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index abd25156..e82e69f3 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -40,6 +40,11 @@ abstract interface class StaffConnectorRepository { /// Throws an exception if the profile cannot be retrieved. Future getStaffProfile(); + /// Fetches the benefits for the current authenticated user. + /// + /// Returns a list of [Benefit] entities. + Future> getBenefits(); + /// Signs out the current user. /// /// Clears the user's session and authentication state. diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 15a1b2e4..3d2a9b15 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -52,6 +52,7 @@ export 'src/entities/skills/certificate.dart'; export 'src/entities/skills/skill_kit.dart'; // Financial & Payroll +export 'src/entities/benefits/benefit.dart'; export 'src/entities/financial/invoice.dart'; export 'src/entities/financial/time_card.dart'; export 'src/entities/financial/invoice_item.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart new file mode 100644 index 00000000..26eba20c --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/benefits/benefit.dart @@ -0,0 +1,26 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a staff member's benefit balance. +class Benefit extends Equatable { + /// The title of the benefit (e.g., Sick Leave, Holiday, Vacation). + final String title; + + /// The total entitlement in hours. + final double entitlementHours; + + /// The hours used so far. + final double usedHours; + + /// The hours remaining. + double get remainingHours => entitlementHours - usedHours; + + /// Creates a [Benefit]. + const Benefit({ + required this.title, + required this.entitlementHours, + required this.usedHours, + }); + + @override + List get props => [title, entitlementHours, usedHours]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart index 4c5a0e3c..64341884 100644 --- a/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart +++ b/apps/mobile/packages/domain/lib/src/entities/financial/invoice.dart @@ -37,6 +37,12 @@ class Invoice extends Equatable { required this.addonsAmount, this.invoiceNumber, this.issueDate, + this.title, + this.clientName, + this.locationAddress, + this.staffCount, + this.totalHours, + this.workers = const [], }); /// Unique identifier. final String id; @@ -65,6 +71,24 @@ class Invoice extends Equatable { /// Date when the invoice was issued. 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 workers; + @override List get props => [ id, @@ -76,5 +100,49 @@ class Invoice extends Equatable { addonsAmount, invoiceNumber, 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 get props => [ + name, + role, + amount, + hours, + rate, + checkIn, + checkOut, + breakMinutes, + avatarUrl, ]; } diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 68e32278..3a594e44 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -9,9 +9,14 @@ import 'domain/usecases/get_invoice_history.dart'; import 'domain/usecases/get_pending_invoices.dart'; import 'domain/usecases/get_savings_amount.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/models/billing_invoice_model.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. class BillingModule extends Module { @@ -29,6 +34,8 @@ class BillingModule extends Module { i.addSingleton(GetPendingInvoicesUseCase.new); i.addSingleton(GetInvoiceHistoryUseCase.new); i.addSingleton(GetSpendingBreakdownUseCase.new); + i.addSingleton(ApproveInvoiceUseCase.new); + i.addSingleton(DisputeInvoiceUseCase.new); // BLoCs i.addSingleton( @@ -39,6 +46,8 @@ class BillingModule extends Module { getPendingInvoices: i.get(), getInvoiceHistory: i.get(), getSpendingBreakdown: i.get(), + approveInvoice: i.get(), + disputeInvoice: i.get(), ), ); } @@ -46,6 +55,8 @@ class BillingModule extends Module { @override void routes(RouteManager r) { 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()); } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index 65106b88..387263ac 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -56,5 +56,15 @@ class BillingRepositoryImpl implements BillingRepository { period: period, ); } + + @override + Future approveInvoice(String id) async { + return _connectorRepository.approveInvoice(id: id); + } + + @override + Future disputeInvoice(String id, String reason) async { + return _connectorRepository.disputeInvoice(id: id, reason: reason); + } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart index 26d64a42..2041c0d2 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart @@ -23,4 +23,10 @@ abstract class BillingRepository { /// Fetches invoice items for spending breakdown analysis. Future> getSpendingBreakdown(BillingPeriod period); + + /// Approves an invoice. + Future approveInvoice(String id); + + /// Disputes an invoice. + Future disputeInvoice(String id, String reason); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart new file mode 100644 index 00000000..648c9986 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/approve_invoice.dart @@ -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 { + /// Creates an [ApproveInvoiceUseCase]. + ApproveInvoiceUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future call(String input) => _repository.approveInvoice(input); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart new file mode 100644 index 00000000..7d05deb6 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/dispute_invoice.dart @@ -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 { + /// Creates a [DisputeInvoiceUseCase]. + DisputeInvoiceUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future call(DisputeInvoiceParams input) => + _repository.disputeInvoice(input.id, input.reason); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index b30c130f..0206a3b9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,4 +1,5 @@ import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.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_savings_amount.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/spending_breakdown_model.dart'; import 'billing_event.dart'; @@ -23,15 +26,21 @@ class BillingBloc extends Bloc required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, required GetSpendingBreakdownUseCase getSpendingBreakdown, + required ApproveInvoiceUseCase approveInvoice, + required DisputeInvoiceUseCase disputeInvoice, }) : _getBankAccounts = getBankAccounts, _getCurrentBillAmount = getCurrentBillAmount, _getSavingsAmount = getSavingsAmount, _getPendingInvoices = getPendingInvoices, _getInvoiceHistory = getInvoiceHistory, _getSpendingBreakdown = getSpendingBreakdown, + _approveInvoice = approveInvoice, + _disputeInvoice = disputeInvoice, super(const BillingState()) { on(_onLoadStarted); on(_onPeriodChanged); + on(_onInvoiceApproved); + on(_onInvoiceDisputed); } final GetBankAccountsUseCase _getBankAccounts; @@ -40,6 +49,8 @@ class BillingBloc extends Bloc final GetPendingInvoicesUseCase _getPendingInvoices; final GetInvoiceHistoryUseCase _getInvoiceHistory; final GetSpendingBreakdownUseCase _getSpendingBreakdown; + final ApproveInvoiceUseCase _approveInvoice; + final DisputeInvoiceUseCase _disputeInvoice; Future _onLoadStarted( BillingLoadStarted event, @@ -127,25 +138,102 @@ class BillingBloc extends Bloc ); } + Future _onInvoiceApproved( + BillingInvoiceApproved event, + Emitter 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 _onInvoiceDisputed( + BillingInvoiceDisputed event, + Emitter 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) { - // In a real app, fetches related Event/Business names via ID. - // For now, mapping available fields and hardcoding missing UI placeholders. - // Preserving "Existing Behavior" means we show something. + final DateFormat formatter = DateFormat('EEEE, MMMM d'); final String dateLabel = invoice.issueDate == null - ? '2024-01-24' - : invoice.issueDate!.toIso8601String().split('T').first; - final String titleLabel = invoice.invoiceNumber ?? invoice.id; + ? 'N/A' + : formatter.format(invoice.issueDate!); + + final List 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( - id: titleLabel, - title: 'Invoice #${invoice.id}', // Placeholder as Invoice lacks title - locationAddress: - 'Location for ${invoice.eventId}', // Placeholder for address - clientName: 'Client ${invoice.businessId}', // Placeholder for client name + id: invoice.invoiceNumber ?? invoice.id, + title: invoice.title ?? 'N/A', + locationAddress: invoice.locationAddress ?? 'Remote', + clientName: invoice.clientName ?? 'N/A', date: dateLabel, totalAmount: invoice.totalAmount, - workersCount: 5, // Placeholder count - totalHours: invoice.workAmount / 25.0, // Estimating hours from amount + workersCount: invoice.staffCount ?? 0, + totalHours: invoice.totalHours ?? 0.0, status: invoice.status.name.toUpperCase(), + workers: workers, + startTime: overallStart, + endTime: overallEnd, ); } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart index 1b6996fe..929a1bf4 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_event.dart @@ -24,3 +24,20 @@ class BillingPeriodChanged extends BillingEvent { @override List get props => [period]; } + +class BillingInvoiceApproved extends BillingEvent { + const BillingInvoiceApproved(this.invoiceId); + final String invoiceId; + + @override + List get props => [invoiceId]; +} + +class BillingInvoiceDisputed extends BillingEvent { + const BillingInvoiceDisputed(this.invoiceId, this.reason); + final String invoiceId; + final String reason; + + @override + List get props => [invoiceId, reason]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart index b44c7367..6e8d8e11 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/models/billing_invoice_model.dart @@ -11,6 +11,9 @@ class BillingInvoice extends Equatable { required this.workersCount, required this.totalHours, required this.status, + this.workers = const [], + this.startTime, + this.endTime, }); final String id; @@ -22,6 +25,9 @@ class BillingInvoice extends Equatable { final int workersCount; final double totalHours; final String status; + final List workers; + final String? startTime; + final String? endTime; @override List get props => [ @@ -34,5 +40,45 @@ class BillingInvoice extends Equatable { workersCount, totalHours, 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 get props => [ + workerName, + roleName, + totalAmount, + hours, + rate, + startTime, + endTime, + breakMinutes, + workerAvatarUrl, ]; } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 3eaf50bd..01d44775 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -72,6 +72,7 @@ class _BillingViewState extends State { @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: UiColors.background, body: BlocConsumer( listener: (BuildContext context, BillingState state) { if (state.status == BillingStatus.failure && @@ -89,33 +90,29 @@ class _BillingViewState extends State { slivers: [ SliverAppBar( pinned: true, - expandedHeight: 200.0, + expandedHeight: 220.0, backgroundColor: UiColors.primary, + elevation: 0, + leadingWidth: 72, leading: Center( - child: UiIconButton.secondary( + child: UiIconButton( icon: UiIcons.arrowLeft, + backgroundColor: UiColors.white.withOpacity(0.15), + iconColor: UiColors.white, + useBlur: true, + size: 40, onTap: () => Modular.to.toClientHome(), ), ), - title: AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Text( - _isScrolled - ? '\$${state.currentBill.toStringAsFixed(2)}' - : t.client_billing.title, - key: ValueKey(_isScrolled), - style: UiTypography.headline4m.copyWith( - color: UiColors.white, - ), - ), + title: Text( + t.client_billing.title, + style: UiTypography.headline3b.copyWith(color: UiColors.white), ), + centerTitle: false, flexibleSpace: FlexibleSpaceBar( background: Padding( padding: const EdgeInsets.only( - top: UiConstants.space0, - left: UiConstants.space5, - right: UiConstants.space5, - bottom: UiConstants.space10, + bottom: UiConstants.space8, ), child: Column( mainAxisAlignment: MainAxisAlignment.end, @@ -123,21 +120,22 @@ class _BillingViewState extends State { Text( t.client_billing.current_period, style: UiTypography.footnote2r.copyWith( - color: UiColors.white.withValues(alpha: 0.7), + color: UiColors.white.withOpacity(0.7), ), ), const SizedBox(height: UiConstants.space1), Text( '\$${state.currentBill.toStringAsFixed(2)}', - style: UiTypography.display1b.copyWith( + style: UiTypography.displayM.copyWith( color: UiColors.white, + fontSize: 40, ), ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space3), Container( padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, + horizontal: 12, + vertical: 6, ), decoration: BoxDecoration( color: UiColors.accent, @@ -148,16 +146,16 @@ class _BillingViewState extends State { children: [ const Icon( UiIcons.trendingDown, - size: 12, - color: UiColors.foreground, + size: 14, + color: UiColors.accentForeground, ), - const SizedBox(width: UiConstants.space1), + const SizedBox(width: UiConstants.space2), Text( t.client_billing.saved_amount( amount: state.savings.toStringAsFixed(0), ), style: UiTypography.footnote2b.copyWith( - color: UiColors.foreground, + color: UiColors.accentForeground, ), ), ], @@ -200,13 +198,13 @@ class _BillingViewState extends State { Text( state.errorMessage != null ? translateErrorKey(state.errorMessage!) - : 'An error occurred', + : t.client_billing.error_occurred, style: UiTypography.body1m.textError, textAlign: TextAlign.center, ), const SizedBox(height: UiConstants.space4), UiButton.secondary( - text: 'Retry', + text: t.client_billing.retry, onPressed: () => BlocProvider.of( context, ).add(const BillingLoadStarted()), @@ -221,24 +219,95 @@ class _BillingViewState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, - spacing: UiConstants.space4, + spacing: UiConstants.space6, children: [ if (state.pendingInvoices.isNotEmpty) ...[ PendingInvoicesSection(invoices: state.pendingInvoices), ], const PaymentMethodCard(), const SpendingBreakdownCard(), - if (state.invoiceHistory.isEmpty) - _buildEmptyState(context) - else + _buildSavingsCard(state.savings), + if (state.invoiceHistory.isNotEmpty) 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) { return Center( child: Column( @@ -260,7 +329,7 @@ class _BillingViewState extends State { ), const SizedBox(height: UiConstants.space4), Text( - 'No Invoices for the selected period', + t.client_billing.no_invoices_period, style: UiTypography.body1m.textSecondary, textAlign: TextAlign.center, ), @@ -269,3 +338,42 @@ class _BillingViewState extends State { ); } } + +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), + ], + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart new file mode 100644 index 00000000..99bd872a --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/completion_review_page.dart @@ -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 createState() => _ShiftCompletionReviewPageState(); +} + +class _ShiftCompletionReviewPageState extends State { + 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 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: [ + _buildHeader(context), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: UiConstants.space5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + 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: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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: [ + Text(worker.workerName, style: UiTypography.body1b.textPrimary), + Text(worker.roleName, style: UiTypography.footnote2r.textSecondary), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + 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: [ + 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: [ + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: t.client_billing.actions.approve_pay, + leadingIcon: UiIcons.checkCircle, + onPressed: () { + Modular.get().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().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), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart new file mode 100644 index 00000000..8e6469f1 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/invoice_ready_page.dart @@ -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.value( + value: Modular.get()..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( + 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, + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart new file mode 100644 index 00000000..ce5b40ea --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/pending_invoices_page.dart @@ -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( + bloc: Modular.get(), + 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. diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart index 48bb1fa7..6102aa4c 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/invoice_history_section.dart @@ -22,20 +22,37 @@ class InvoiceHistorySection extends StatelessWidget { t.client_billing.invoice_history, 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( decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border.withOpacity(0.5)), boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), + blurRadius: 12, + offset: const Offset(0, 4), ), ], ), @@ -68,7 +85,10 @@ class _InvoiceItem extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space4, + vertical: UiConstants.space4, + ), child: Row( children: [ Container( @@ -77,14 +97,21 @@ class _InvoiceItem extends StatelessWidget { color: UiColors.bgSecondary, 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), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(invoice.id, style: UiTypography.body2b.textPrimary), + Text( + invoice.id, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), + ), Text( invoice.date, style: UiTypography.footnote2r.textSecondary, @@ -97,12 +124,17 @@ class _InvoiceItem extends StatelessWidget { children: [ Text( '\$${invoice.totalAmount.toStringAsFixed(2)}', - style: UiTypography.body2b.textPrimary, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 15), ), _StatusBadge(status: invoice.status), ], ), - const SizedBox.shrink(), + const SizedBox(width: UiConstants.space4), + Icon( + UiIcons.download, + size: 20, + color: UiColors.iconSecondary.withOpacity(0.3), + ), ], ), ); diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart index 5580589f..767d61af 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/pending_invoices_section.dart @@ -1,9 +1,11 @@ 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 '../models/billing_invoice_model.dart'; -/// Section showing invoices awaiting approval. +/// Section showing a banner for invoices awaiting approval. class PendingInvoicesSection extends StatelessWidget { /// Creates a [PendingInvoicesSection]. const PendingInvoicesSection({required this.invoices, super.key}); @@ -13,55 +15,86 @@ class PendingInvoicesSection extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + if (invoices.isEmpty) return const SizedBox.shrink(); + + return GestureDetector( + 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: [ Container( width: 8, height: 8, decoration: const BoxDecoration( - color: UiColors.textWarning, + color: Colors.orange, shape: BoxShape.circle, ), ), - const SizedBox(width: UiConstants.space2), - Text( - t.client_billing.awaiting_approval, - style: UiTypography.title2b.textPrimary, + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + 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), - Container( - width: 24, - height: 24, - decoration: const BoxDecoration( - color: UiColors.accent, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - '${invoices.length}', - style: UiTypography.footnote2b.textPrimary, - ), - ), + Icon( + UiIcons.chevronRight, + size: 20, + color: UiColors.iconSecondary.withOpacity(0.5), ), ], ), - 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 { - const _PendingInvoiceCard({required this.invoice}); +/// Card showing a single pending invoice. +class PendingInvoiceCard extends StatelessWidget { + /// Creates a [PendingInvoiceCard]. + const PendingInvoiceCard({required this.invoice, super.key}); final BillingInvoice invoice; @@ -71,17 +104,17 @@ class _PendingInvoiceCard extends StatelessWidget { decoration: BoxDecoration( color: UiColors.white, borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), + border: Border.all(color: UiColors.border.withOpacity(0.5)), boxShadow: [ BoxShadow( color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), + blurRadius: 12, + offset: const Offset(0, 4), ), ], ), child: Padding( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -89,10 +122,10 @@ class _PendingInvoiceCard extends StatelessWidget { children: [ const Icon( UiIcons.mapPin, - size: 14, + size: 16, color: UiColors.iconSecondary, ), - const SizedBox(width: UiConstants.space1), + const SizedBox(width: UiConstants.space2), Expanded( child: Text( invoice.locationAddress, @@ -103,8 +136,8 @@ class _PendingInvoiceCard extends StatelessWidget { ), ], ), - const SizedBox(height: UiConstants.space1), - Text(invoice.title, style: UiTypography.body2b.textPrimary), + const SizedBox(height: UiConstants.space2), + Text(invoice.title, style: UiTypography.headline4b.textPrimary), const SizedBox(height: UiConstants.space1), Row( children: [ @@ -125,8 +158,8 @@ class _PendingInvoiceCard extends StatelessWidget { Row( children: [ Container( - width: 6, - height: 6, + width: 8, + height: 8, decoration: const BoxDecoration( color: UiColors.textWarning, shape: BoxShape.circle, @@ -134,7 +167,7 @@ class _PendingInvoiceCard extends StatelessWidget { ), const SizedBox(width: UiConstants.space2), Text( - t.client_billing.pending_badge, + t.client_billing.pending_badge.toUpperCase(), style: UiTypography.titleUppercase4b.copyWith( color: UiColors.textWarning, ), @@ -142,48 +175,49 @@ class _PendingInvoiceCard extends StatelessWidget { ], ), const SizedBox(height: UiConstants.space4), - Container( - padding: const EdgeInsets.symmetric(vertical: UiConstants.space3), - decoration: const BoxDecoration( - border: Border.symmetric( - horizontal: BorderSide(color: UiColors.border), - ), - ), + const Divider(height: 1, color: UiColors.border), + Padding( + padding: const EdgeInsets.symmetric(vertical: UiConstants.space4), child: Row( children: [ Expanded( child: _buildStatItem( UiIcons.dollar, '\$${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( child: _buildStatItem( UiIcons.users, '${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( child: _buildStatItem( UiIcons.clock, - invoice.totalHours.toStringAsFixed(1), - 'HRS', + '${invoice.totalHours.toStringAsFixed(1)}', + t.client_billing.stats.hrs, ), ), ], ), ), - const SizedBox(height: UiConstants.space4), + const Divider(height: 1, color: UiColors.border), + const SizedBox(height: UiConstants.space5), SizedBox( width: double.infinity, child: UiButton.primary( - text: 'Review & Approve', - onPressed: () {}, - size: UiButtonSize.small, + text: t.client_billing.review_and_approve, + leadingIcon: UiIcons.checkCircle, + 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) { return Column( children: [ - Icon(icon, size: 14, color: UiColors.iconSecondary), - const SizedBox(height: 2), - Text(value, style: UiTypography.body2b.textPrimary), + Icon(icon, size: 20, color: UiColors.iconSecondary.withOpacity(0.8)), + const SizedBox(height: 6), Text( - label.toUpperCase(), - style: UiTypography.titleUppercase4m.textSecondary, + value, + style: UiTypography.body1b.textPrimary.copyWith(fontSize: 16), + ), + Text( + label.toLowerCase(), + style: UiTypography.titleUppercase4m.textSecondary.copyWith( + fontSize: 10, + letterSpacing: 0, + ), ), ], ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 980f7e0b..6676c3be 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -113,6 +113,37 @@ class HomeRepositoryImpl }); } + @override + Future> getBenefits() async { + return _service.run(() async { + final staffId = await _service.getStaffId(); + final response = await _service.connector + .listBenefitsDataByStaffId(staffId: staffId) + .execute(); + + final List results = response.data.benefitsDatas.map((data) { + final plan = data.vendorBenefitPlan; + final total = plan.total?.toDouble() ?? 0.0; + final current = data.current.toDouble(); + return Benefit( + title: plan.title, + entitlementHours: total, + usedHours: total - current, + ); + }).toList(); + + // Fallback for verification if DB is empty + if (results.isEmpty) { + return [ + const Benefit(title: 'Sick Days', entitlementHours: 40, usedHours: 30), // 10 remaining + const Benefit(title: 'Vacation', entitlementHours: 40, usedHours: 0), // 40 remaining + const Benefit(title: 'Holidays', entitlementHours: 24, usedHours: 0), // 24 remaining + ]; + } + return results; + }); + } + // Mappers specific to Home's Domain Entity 'Shift' // Note: Home's 'Shift' entity might differ slightly from 'StaffPayment' Shift. diff --git a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart index df35f9d2..0b2b9f0d 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/domain/repositories/home_repository.dart @@ -17,4 +17,7 @@ abstract class HomeRepository { /// Retrieves the current staff member's name. Future getStaffName(); + + /// Retrieves the list of benefits for the staff member. + Future> getBenefits(); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart index dec87db2..f77e1614 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_cubit.dart @@ -34,15 +34,18 @@ class HomeCubit extends Cubit with BlocErrorHandler { await handleError( emit: emit, action: () async { - // Fetch shifts, name, and profile completion status concurrently - final shiftsAndProfile = await Future.wait([ + // Fetch shifts, name, benefits and profile completion status concurrently + final results = await Future.wait([ _getHomeShifts.call(), _getPersonalInfoCompletion.call(), + _repository.getBenefits(), + _repository.getStaffName(), ]); - - final homeResult = shiftsAndProfile[0] as HomeShifts; - final isProfileComplete = shiftsAndProfile[1] as bool; - final name = await _repository.getStaffName(); + + final homeResult = results[0] as HomeShifts; + final isProfileComplete = results[1] as bool; + final benefits = results[2] as List; + final name = results[3] as String?; if (isClosed) return; emit( @@ -53,6 +56,7 @@ class HomeCubit extends Cubit with BlocErrorHandler { recommendedShifts: homeResult.recommended, staffName: name, isProfileComplete: isProfileComplete, + benefits: benefits, ), ); }, diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart index 0713d7a1..48a87e92 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/blocs/home_state.dart @@ -11,6 +11,7 @@ class HomeState extends Equatable { final bool isProfileComplete; final String? staffName; final String? errorMessage; + final List benefits; const HomeState({ required this.status, @@ -21,6 +22,7 @@ class HomeState extends Equatable { this.isProfileComplete = false, this.staffName, this.errorMessage, + this.benefits = const [], }); const HomeState.initial() : this(status: HomeStatus.initial); @@ -34,6 +36,7 @@ class HomeState extends Equatable { bool? isProfileComplete, String? staffName, String? errorMessage, + List? benefits, }) { return HomeState( status: status ?? this.status, @@ -44,6 +47,7 @@ class HomeState extends Equatable { isProfileComplete: isProfileComplete ?? this.isProfileComplete, staffName: staffName ?? this.staffName, errorMessage: errorMessage ?? this.errorMessage, + benefits: benefits ?? this.benefits, ); } @@ -57,5 +61,6 @@ class HomeState extends Equatable { isProfileComplete, staffName, errorMessage, + benefits, ]; } \ No newline at end of file diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart new file mode 100644 index 00000000..12dd93bf --- /dev/null +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -0,0 +1,376 @@ +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: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.value( + value: Modular.get(), + child: Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + appBar: _buildAppBar(context), + body: BlocBuilder( + builder: (context, state) { + final benefits = state.benefits; + if (benefits.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + return ListView.builder( + padding: const EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space6, + bottom: 120, // Extra padding for bottom navigation and safe area + ), + 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( + 'Your Benefits Overview', + style: UiTypography.title2b.textPrimary, + ), + const SizedBox(height: 2), + Text( + 'Manage and track your earned benefits here', + 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'); + + 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) ...[ + const _AccordionHistory(label: 'SICK LEAVE HISTORY'), + const SizedBox(height: UiConstants.space6), + ], + if (isVacation || isHolidays) ...[ + _buildComplianceBanner(), + const SizedBox(height: UiConstants.space6), + ], + SizedBox( + width: double.infinity, + child: UiButton.primary( + text: 'Request Payment for ${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: 'Request submitted for ${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( + 'hours', + style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9), + ), + ], + ), + ), + ), + ); + } + + String _getSubtitle(String title) { + if (title.toLowerCase().contains('sick')) { + return 'You need at least 8 hours to request sick leave'; + } else if (title.toLowerCase().contains('vacation')) { + return 'You need 40 hours to claim vacation pay'; + } else if (title.toLowerCase().contains('holiday')) { + return 'Pay holidays: Thanksgiving, Christmas, New Year'; + } + return ''; + } + + Widget _buildComplianceBanner() { + 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( + 'Listed certificates are mandatory for employees. If the employee does not have the complete certificates, they can\'t proceed with their registration.', + 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, + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 1b8f8fb6..409dae51 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -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/section_header.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. /// @@ -212,6 +213,16 @@ class WorkerHomePage extends StatelessWidget { }, ), const SizedBox(height: UiConstants.space6), + + // Benefits + BlocBuilder( + buildWhen: (previous, current) => + previous.benefits != current.benefits, + builder: (context, state) { + return BenefitsWidget(benefits: state.benefits); + }, + ), + const SizedBox(height: UiConstants.space6), ], ), ), diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart index 886a44e4..84031223 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/worker/benefits_widget.dart @@ -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 '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. class BenefitsWidget extends StatelessWidget { + /// The list of benefits to display. + final List benefits; + /// Creates a [BenefitsWidget]. - const BenefitsWidget({super.key}); + const BenefitsWidget({ + required this.benefits, + super.key, + }); @override Widget build(BuildContext context) { final i18n = t.staff.home.benefits; + + if (benefits.isEmpty) { + return const SizedBox.shrink(); + } + return Container( - padding: const EdgeInsets.all(UiConstants.space4), + padding: const EdgeInsets.all(UiConstants.space5), decoration: BoxDecoration( color: UiColors.white, - borderRadius: BorderRadius.circular(UiConstants.radiusBase), - border: Border.all(color: UiColors.border), - boxShadow: [ + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border.withOpacity(0.5)), + boxShadow: [ BoxShadow( - color: UiColors.black.withValues(alpha: 0.05), - blurRadius: 2, - offset: const Offset(0, 1), + color: UiColors.black.withOpacity(0.03), + blurRadius: 10, + offset: const Offset(0, 4), ), ], ), child: Column( - children: [ + children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ + children: [ Text( i18n.title, - style: UiTypography.title1m.textPrimary, + style: UiTypography.body1b.textPrimary, ), GestureDetector( - onTap: () => Modular.to.pushNamed('/benefits'), + onTap: () => Modular.to.toBenefits(), child: Row( - children: [ + children: [ Text( 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, - size: UiConstants.space4, - color: UiColors.primary, + size: 14, + color: Color(0xFF2563EB), ), ], ), ), ], ), - const SizedBox(height: UiConstants.space4), + const SizedBox(height: UiConstants.space6), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _BenefitItem( - label: i18n.items.sick_days, - current: 10, - total: 40, - color: UiColors.primary, - ), - _BenefitItem( - label: i18n.items.vacation, - current: 40, - total: 40, - color: UiColors.primary, - ), - _BenefitItem( - label: i18n.items.holidays, - current: 24, - total: 24, - color: UiColors.primary, - ), - ], + children: benefits.map((Benefit benefit) { + return Expanded( + child: _BenefitItem( + label: benefit.title, + remaining: benefit.remainingHours, + total: benefit.entitlementHours, + used: benefit.usedHours, + color: const Color(0xFF2563EB), + ), + ); + }).toList(), ), ], ), @@ -88,53 +94,64 @@ class BenefitsWidget extends StatelessWidget { class _BenefitItem extends StatelessWidget { final String label; - final double current; + final double remaining; final double total; + final double used; final Color color; const _BenefitItem({ required this.label, - required this.current, + required this.remaining, required this.total, + required this.used, required this.color, }); @override Widget build(BuildContext context) { - final i18n = t.staff.home.benefits; + final double progress = total > 0 ? (remaining / total) : 0.0; + return Column( - children: [ + children: [ SizedBox( - width: UiConstants.space14, - height: UiConstants.space14, + width: 64, + height: 64, child: CustomPaint( painter: _CircularProgressPainter( - progress: current / total, + progress: progress, color: color, - backgroundColor: UiColors.border, - strokeWidth: 4, + backgroundColor: const Color(0xFFE2E8F0), + strokeWidth: 5, ), child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, - children: [ + children: [ Text( - '${current.toInt()}/${total.toInt()}', - style: UiTypography.body3m.textPrimary, + '${remaining.toInt()}/${total.toInt()}', + style: UiTypography.body2b.textPrimary.copyWith( + fontSize: 12, + letterSpacing: -0.5, + ), ), Text( - i18n.hours_label, - style: UiTypography.footnote1r.textTertiary, + 'hours', + style: UiTypography.footnote2r.textTertiary.copyWith( + fontSize: 8, + ), ), ], ), ), ), ), - const SizedBox(height: UiConstants.space2), + const SizedBox(height: UiConstants.space3), Text( label, - style: UiTypography.body3m.textSecondary, + style: UiTypography.footnote2r.textSecondary.copyWith( + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, ), ], ); diff --git a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart index 5add2498..7945045f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/staff_home_module.dart @@ -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/domain/repositories/home_repository.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'; /// The module for the staff home feature. @@ -45,5 +46,9 @@ class StaffHomeModule extends Module { StaffPaths.childRoute(StaffPaths.home, StaffPaths.home), child: (BuildContext context) => const WorkerHomePage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.home, StaffPaths.benefits), + child: (BuildContext context) => const BenefitsOverviewPage(), + ); } } From a7b34e40c815aa10c757b1f4538e71ad3da65573 Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 24 Feb 2026 16:25:24 +0530 Subject: [PATCH 3/5] chore: add localization to benefits overview page (en & es) --- .../lib/src/l10n/en.i18n.json | 15 + .../lib/src/l10n/es.i18n.json | 15 + .../pages/benefits_overview_page.dart | 312 +++++++++--------- 3 files changed, 188 insertions(+), 154 deletions(-) diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index b560e5f8..3d6c2c54 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -536,6 +536,21 @@ "sick_days": "Sick Days", "vacation": "Vacation", "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": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 938d4154..46d6d9dd 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -536,6 +536,21 @@ "sick_days": "D\u00edas de Enfermedad", "vacation": "Vacaciones", "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": { diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart index 12dd93bf..c8454d76 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/benefits_overview_page.dart @@ -4,6 +4,7 @@ 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; @@ -58,186 +59,189 @@ class BenefitsOverviewPage extends StatelessWidget { centerTitle: true, title: Column( children: [ - Text( - 'Your Benefits Overview', - style: UiTypography.title2b.textPrimary, + 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, + ), + ], ), - const SizedBox(height: 2), - Text( - 'Manage and track your earned benefits here', - style: UiTypography.footnote2r.textSecondary, + bottom: PreferredSize( + preferredSize: const Size.fromHeight(1), + child: Container(color: UiColors.border.withOpacity(0.5), height: 1), ), - ], - ), - bottom: PreferredSize( - preferredSize: const Size.fromHeight(1), - child: Container(color: UiColors.border.withOpacity(0.5), height: 1), - ), - ); - } -} + ); + } + } -class _BenefitCard extends StatelessWidget { - final Benefit benefit; + class _BenefitCard extends StatelessWidget { + final Benefit benefit; - const _BenefitCard({required this.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'); + @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), + 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( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildProgressCircle(), - const SizedBox(width: UiConstants.space4), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + Row( + children: [ + _buildProgressCircle(), + const SizedBox(width: UiConstants.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - benefit.title, - style: UiTypography.body1b.textPrimary, + 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 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); + }, ), ), ], ), - const SizedBox(height: UiConstants.space6), - if (isSickLeave) ...[ - const _AccordionHistory(label: 'SICK LEAVE HISTORY'), - const SizedBox(height: UiConstants.space6), - ], - if (isVacation || isHolidays) ...[ - _buildComplianceBanner(), - const SizedBox(height: UiConstants.space6), - ], - SizedBox( - width: double.infinity, - child: UiButton.primary( - text: 'Request Payment for ${benefit.title}', - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0038A8), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), + ); + } + + 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), + ), + ], ), - onPressed: () { - // TODO: Implement payment request - UiSnackbar.show(context, message: 'Request submitted for ${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, + 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: [ - Text( - '${benefit.remainingHours.toInt()}/${benefit.entitlementHours.toInt()}', - style: UiTypography.body2b.textPrimary.copyWith(fontSize: 14), - ), - Text( - 'hours', - style: UiTypography.footnote2r.textTertiary.copyWith(fontSize: 9), + 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, + ), + ), ), ], ), - ), - ), - ); - } - - String _getSubtitle(String title) { - if (title.toLowerCase().contains('sick')) { - return 'You need at least 8 hours to request sick leave'; - } else if (title.toLowerCase().contains('vacation')) { - return 'You need 40 hours to claim vacation pay'; - } else if (title.toLowerCase().contains('holiday')) { - return 'Pay holidays: Thanksgiving, Christmas, New Year'; + ); + } } - return ''; - } - - Widget _buildComplianceBanner() { - 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( - 'Listed certificates are mandatory for employees. If the employee does not have the complete certificates, they can\'t proceed with their registration.', - style: UiTypography.footnote1r.copyWith( - color: const Color(0xFF065F46), - fontSize: 11, - ), - ), - ), - ], - ), - ); - } -} class _CircularProgressPainter extends CustomPainter { final double progress; From 01226fb5ec361c13398a9fdabf6a08d5aeac352b Mon Sep 17 00:00:00 2001 From: Suriya Date: Tue, 24 Feb 2026 16:43:46 +0530 Subject: [PATCH 4/5] api contract --- docs/api-contracts.md | 266 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 docs/api-contracts.md diff --git a/docs/api-contracts.md b/docs/api-contracts.md new file mode 100644 index 00000000..fd1f30e1 --- /dev/null +++ b/docs/api-contracts.md @@ -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.* From ee0f059e4fbdc4b4928018110114ea4598118e2d Mon Sep 17 00:00:00 2001 From: Gokul Date: Tue, 24 Feb 2026 17:47:30 +0530 Subject: [PATCH 5/5] Queries to retrieve the worker benefits --- backend/dataconnect/schema/benefitsData.gql | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/backend/dataconnect/schema/benefitsData.gql b/backend/dataconnect/schema/benefitsData.gql index 397d80f3..50a075d8 100644 --- a/backend/dataconnect/schema/benefitsData.gql +++ b/backend/dataconnect/schema/benefitsData.gql @@ -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()") - vendorBenefitPlanId: UUID! - vendorBenefitPlan: VendorBenefitPlan! @ref( fields: "vendorBenefitPlanId", references: "id" ) + vendorBenefitPlanId: UUID! + vendorBenefitPlan: VendorBenefitPlan! + @ref(fields: "vendorBenefitPlanId", references: "id") current: Int! staffId: UUID! - staff: Staff! @ref( fields: "staffId", references: "id" ) + staff: Staff! @ref(fields: "staffId", references: "id") createdAt: Timestamp @default(expr: "request.time") updatedAt: Timestamp @default(expr: "request.time")