From f55cf343fbba8abe2f5b1602535f06a79aa63c47 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 6 Nov 2025 17:44:18 +0530 Subject: [PATCH] added code for payement request and payment request details screen --- .../add_payment_request_controller.dart | 2 +- .../finance/payment_request_controller.dart | 123 ++++ .../payment_request_detail_controller.dart | 31 + lib/helpers/services/api_endpoints.dart | 6 + lib/helpers/services/api_service.dart | 123 ++++ lib/helpers/utils/date_time_utils.dart | 3 + lib/helpers/widgets/my_custom_skeleton.dart | 131 +++++ .../add_payment_request_bottom_sheet.dart | 2 +- .../payment_request_details_model.dart | 364 ++++++++++++ lib/model/finance/payment_request_filter.dart | 108 ++++ .../payment_request_filter_bottom_sheet.dart | 471 +++++++++++++++ .../finance/payment_request_list_model.dart | 306 ++++++++++ lib/routes.dart | 6 + lib/view/finance/finance_screen.dart | 16 +- .../payment_request_detail_screen.dart | 535 ++++++++++++++++++ lib/view/finance/payment_request_screen.dart | 365 ++++++++++++ pubspec.yaml | 1 + 17 files changed, 2578 insertions(+), 15 deletions(-) create mode 100644 lib/controller/finance/payment_request_controller.dart create mode 100644 lib/controller/finance/payment_request_detail_controller.dart create mode 100644 lib/model/finance/payment_request_details_model.dart create mode 100644 lib/model/finance/payment_request_filter.dart create mode 100644 lib/model/finance/payment_request_filter_bottom_sheet.dart create mode 100644 lib/model/finance/payment_request_list_model.dart create mode 100644 lib/view/finance/payment_request_detail_screen.dart create mode 100644 lib/view/finance/payment_request_screen.dart diff --git a/lib/controller/finance/add_payment_request_controller.dart b/lib/controller/finance/add_payment_request_controller.dart index 3f20f56..0381981 100644 --- a/lib/controller/finance/add_payment_request_controller.dart +++ b/lib/controller/finance/add_payment_request_controller.dart @@ -15,7 +15,7 @@ import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; import 'package:marco/model/finance/expense_category_model.dart'; import 'package:marco/model/finance/currency_list_model.dart'; -class PaymentRequestController extends GetxController { +class AddPaymentRequestController extends GetxController { // Loading States final isLoadingPayees = false.obs; final isLoadingCategories = false.obs; diff --git a/lib/controller/finance/payment_request_controller.dart b/lib/controller/finance/payment_request_controller.dart new file mode 100644 index 0000000..ace2a79 --- /dev/null +++ b/lib/controller/finance/payment_request_controller.dart @@ -0,0 +1,123 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/model/finance/payment_request_list_model.dart'; +import 'package:marco/model/finance/payment_request_filter.dart'; +import 'package:marco/helpers/services/app_logger.dart'; + +class PaymentRequestController extends GetxController { + // ---------------- Observables ---------------- + final RxList paymentRequests = [].obs; + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + final RxBool isFilterApplied = false.obs; + + // ---------------- Pagination ---------------- + int _pageSize = 20; + int _pageNumber = 1; + bool _hasMoreData = true; + + // ---------------- Filters ---------------- + RxMap appliedFilter = {}.obs; + RxString searchString = ''.obs; + + // ---------------- Filter Options ---------------- + RxList projects = [].obs; + RxList payees = [].obs; + RxList categories = [].obs; + RxList currencies = [].obs; + RxList statuses = [].obs; + RxList createdBy = [].obs; + + // ---------------- Fetch Filter Options ---------------- + Future fetchPaymentRequestFilterOptions() async { + try { + final response = await ApiService.getExpensePaymentRequestFilterApi(); + if (response != null) { + projects.assignAll(response.data.projects); + payees.assignAll(response.data.payees); + categories.assignAll(response.data.expenseCategory); + currencies.assignAll(response.data.currency); + statuses.assignAll(response.data.status); + createdBy.assignAll(response.data.createdBy); + } else { + logSafe("Payment request filter API returned null", level: LogLevel.warning); + } + } catch (e, stack) { + logSafe("Exception in fetchPaymentRequestFilterOptions: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + } + + // ---------------- Fetch Payment Requests ---------------- + Future fetchPaymentRequests({int pageSize = 20}) async { + isLoading.value = true; + errorMessage.value = ''; + _pageNumber = 1; + _pageSize = pageSize; + _hasMoreData = true; + paymentRequests.clear(); + + await _fetchPaymentRequestsFromApi(); + isLoading.value = false; + } + + // ---------------- Load More ---------------- + Future loadMorePaymentRequests() async { + if (isLoading.value || !_hasMoreData) return; + + _pageNumber += 1; + isLoading.value = true; + + await _fetchPaymentRequestsFromApi(); + isLoading.value = false; + } + + // ---------------- Internal API Call ---------------- + Future _fetchPaymentRequestsFromApi() async { + try { + final response = await ApiService.getExpensePaymentRequestListApi( + pageSize: _pageSize, + pageNumber: _pageNumber, + filter: appliedFilter, + searchString: searchString.value, + ); + + if (response != null && response.data.data.isNotEmpty) { + if (_pageNumber == 1) { + paymentRequests.assignAll(response.data.data); + } else { + paymentRequests.addAll(response.data.data); + } + } else { + if (_pageNumber == 1) { + errorMessage.value = 'No payment requests found.'; + } else { + _hasMoreData = false; + } + } + } catch (e, stack) { + errorMessage.value = 'Failed to fetch payment requests.'; + logSafe("Exception in _fetchPaymentRequestsFromApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + } + + // ---------------- Filter Management ---------------- + void setFilterApplied(bool applied) { + isFilterApplied.value = applied; + } + + void applyFilter(Map filter, {String search = ''}) { + appliedFilter.assignAll(filter); + searchString.value = search; + isFilterApplied.value = filter.isNotEmpty || search.isNotEmpty; + fetchPaymentRequests(); + } + + void clearFilter() { + appliedFilter.clear(); + searchString.value = ''; + isFilterApplied.value = false; + fetchPaymentRequests(); + } +} diff --git a/lib/controller/finance/payment_request_detail_controller.dart b/lib/controller/finance/payment_request_detail_controller.dart new file mode 100644 index 0000000..63cb0eb --- /dev/null +++ b/lib/controller/finance/payment_request_detail_controller.dart @@ -0,0 +1,31 @@ +import 'package:get/get.dart'; +import 'package:marco/helpers/services/api_service.dart'; + +class PaymentRequestDetailController extends GetxController { + final Rx paymentRequest = Rx(null); + final RxBool isLoading = false.obs; + final RxString errorMessage = ''.obs; + + late String _requestId; + + void init(String requestId) { + _requestId = requestId; + fetchPaymentRequestDetail(); + } + + Future fetchPaymentRequestDetail() async { + try { + isLoading.value = true; + final response = await ApiService.getExpensePaymentRequestDetailApi(_requestId); + if (response != null) { + paymentRequest.value = response.data; // adapt to your API model + } else { + errorMessage.value = "Failed to fetch payment request details"; + } + } catch (e) { + errorMessage.value = "Error fetching payment request details: $e"; + } finally { + isLoading.value = false; + } + } +} diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 0b5f88a..f88215d 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -17,6 +17,12 @@ class ApiEndpoints { "/dashboard/attendance-overview"; static const String createExpensePaymentRequest = "/expense/payment-request/create"; + static const String getExpensePaymentRequestList = + "/Expense/get/payment-requests/list"; + static const String getExpensePaymentRequestDetails = + "/Expense/get/payment-request/details"; + static const String getExpensePaymentRequestFilter = + "/Expense/get/payment-request/details"; static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTeams = "/dashboard/teams"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index dfc1b07..faa6153 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -25,6 +25,9 @@ import 'package:marco/model/dashboard/monthly_expence_model.dart'; import 'package:marco/model/finance/expense_category_model.dart'; import 'package:marco/model/finance/currency_list_model.dart'; import 'package:marco/model/finance/payment_payee_request_model.dart'; +import 'package:marco/model/finance/payment_request_list_model.dart'; +import 'package:marco/model/finance/payment_request_filter.dart'; +import 'package:marco/model/finance/payment_request_details_model.dart'; class ApiService { static const bool enableLogs = true; @@ -294,6 +297,126 @@ class ApiService { } } + /// Get Expense Payment Request Detail by ID + static Future getExpensePaymentRequestDetailApi( + String paymentRequestId) async { + final endpoint = + "${ApiEndpoints.getExpensePaymentRequestDetails}/$paymentRequestId"; + logSafe( + "Fetching Expense Payment Request Detail for ID: $paymentRequestId"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Expense Payment Request Detail request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Expense Payment Request Detail", + ); + + if (jsonResponse != null) { + return PaymentRequestDetail.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpensePaymentRequestDetailApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + static Future + getExpensePaymentRequestFilterApi() async { + const endpoint = ApiEndpoints.getExpensePaymentRequestFilter; + logSafe("Fetching Expense Payment Request Filter"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Expense Payment Request Filter request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Expense Payment Request Filter", + ); + + if (jsonResponse != null) { + return PaymentRequestFilter.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpensePaymentRequestFilterApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Expense Payment Request List + static Future getExpensePaymentRequestListApi({ + bool isActive = true, + int pageSize = 20, + int pageNumber = 1, + Map? filter, + String searchString = '', + }) async { + const endpoint = ApiEndpoints.getExpensePaymentRequestList; + logSafe("Fetching Expense Payment Request List"); + + try { + final queryParams = { + 'isActive': isActive.toString(), + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + 'filter': jsonEncode(filter ?? + { + "projectIds": [], + "statusIds": [], + "createdByIds": [], + "currencyIds": [], + "expenseCategoryIds": [], + "payees": [], + "startDate": null, + "endDate": null + }), + 'searchString': searchString, + }; + + final response = await _getRequest(endpoint, queryParams: queryParams); + + if (response == null) { + logSafe("Expense Payment Request List request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData( + response, + label: "Expense Payment Request List", + ); + + if (jsonResponse != null) { + return PaymentRequestResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpensePaymentRequestListApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Create Expense Payment Request (Project API style) static Future createExpensePaymentRequestApi({ required String title, diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 169ce78..0fd54b1 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,6 +1,9 @@ import 'package:intl/intl.dart'; class DateTimeUtils { + /// Default date format + static const String defaultFormat = 'dd MMM yyyy'; + /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 4537143..c390e7a 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -33,6 +33,137 @@ class SkeletonLoaders { ); } +// Add this inside SkeletonLoaders class + static Widget paymentRequestDetailSkeletonLoader() { + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 30), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: MyCard.bordered( + paddingAll: 16, + borderRadiusAll: 8, + shadow: MyShadow(elevation: 3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header (Created At + Status) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 140, + height: 16, + color: Colors.grey.shade300, + ), + Container( + width: 80, + height: 20, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(5), + ), + ), + ], + ), + MySpacing.height(24), + + // Parties Section + ...List.generate( + 4, + (index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + height: 14, + width: double.infinity, + color: Colors.grey.shade300, + ), + )), + MySpacing.height(24), + + // Details Table + ...List.generate( + 6, + (index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + height: 14, + width: double.infinity, + color: Colors.grey.shade300, + ), + )), + MySpacing.height(24), + + // Documents Section + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Container( + height: 40, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(8), + ), + ), + )), + ), + MySpacing.height(24), + + // Logs / Timeline + Column( + children: List.generate( + 3, + (index) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, + ), + ), + MySpacing.width(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, + width: 120, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 12, + width: double.infinity, + color: Colors.grey.shade300, + ), + MySpacing.height(6), + Container( + height: 12, + width: 80, + color: Colors.grey.shade300, + ), + MySpacing.height(16), + ], + ), + ), + ], + )), + ), + ], + ), + ), + ), + ), + ); + } + // Chart Skeleton Loader (Donut Chart) static Widget chartSkeletonLoader() { return MyCard.bordered( diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart index 6d817c6..07a0db5 100644 --- a/lib/model/finance/add_payment_request_bottom_sheet.dart +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -28,7 +28,7 @@ class _PaymentRequestBottomSheet extends StatefulWidget { class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> with UIMixin { - final controller = Get.put(PaymentRequestController()); + final controller = Get.put(AddPaymentRequestController()); final _formKey = GlobalKey(); final _projectDropdownKey = GlobalKey(); diff --git a/lib/model/finance/payment_request_details_model.dart b/lib/model/finance/payment_request_details_model.dart new file mode 100644 index 0000000..e9f6b06 --- /dev/null +++ b/lib/model/finance/payment_request_details_model.dart @@ -0,0 +1,364 @@ +class PaymentRequestDetail { + bool success; + String message; + PaymentRequestData? data; + dynamic errors; + int statusCode; + DateTime timestamp; + + PaymentRequestDetail({ + required this.success, + required this.message, + this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory PaymentRequestDetail.fromJson(Map json) => + PaymentRequestDetail( + success: json['success'], + message: json['message'], + data: json['data'] != null ? PaymentRequestData.fromJson(json['data']) : null, + errors: json['errors'], + statusCode: json['statusCode'], + timestamp: DateTime.parse(json['timestamp']), + ); + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class PaymentRequestData { + String id; + String title; + String description; + String paymentRequestUID; + String payee; + Currency currency; + double amount; + double? baseAmount; + double? taxAmount; + DateTime dueDate; + Project project; + dynamic recurringPayment; + ExpenseCategory expenseCategory; + ExpenseStatus expenseStatus; + String? paidTransactionId; + DateTime? paidAt; + String? paidBy; + bool isAdvancePayment; + DateTime createdAt; + CreatedBy createdBy; + DateTime updatedAt; + dynamic updatedBy; + List nextStatus; + List updateLogs; + List attachments; + bool isActive; + bool isExpenseCreated; + + PaymentRequestData({ + required this.id, + required this.title, + required this.description, + required this.paymentRequestUID, + required this.payee, + required this.currency, + required this.amount, + this.baseAmount, + this.taxAmount, + required this.dueDate, + required this.project, + this.recurringPayment, + required this.expenseCategory, + required this.expenseStatus, + this.paidTransactionId, + this.paidAt, + this.paidBy, + required this.isAdvancePayment, + required this.createdAt, + required this.createdBy, + required this.updatedAt, + this.updatedBy, + required this.nextStatus, + required this.updateLogs, + required this.attachments, + required this.isActive, + required this.isExpenseCreated, + }); + + factory PaymentRequestData.fromJson(Map json) => + PaymentRequestData( + id: json['id'], + title: json['title'], + description: json['description'], + paymentRequestUID: json['paymentRequestUID'], + payee: json['payee'], + currency: Currency.fromJson(json['currency']), + amount: (json['amount'] as num).toDouble(), + baseAmount: json['baseAmount'] != null ? (json['baseAmount'] as num).toDouble() : null, + taxAmount: json['taxAmount'] != null ? (json['taxAmount'] as num).toDouble() : null, + dueDate: DateTime.parse(json['dueDate']), + project: Project.fromJson(json['project']), + recurringPayment: json['recurringPayment'], + expenseCategory: ExpenseCategory.fromJson(json['expenseCategory']), + expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']), + paidTransactionId: json['paidTransactionId'], + paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null, + paidBy: json['paidBy'], + isAdvancePayment: json['isAdvancePayment'], + createdAt: DateTime.parse(json['createdAt']), + createdBy: CreatedBy.fromJson(json['createdBy']), + updatedAt: DateTime.parse(json['updatedAt']), + updatedBy: json['updatedBy'], + nextStatus: (json['nextStatus'] as List) + .map((e) => NextStatus.fromJson(e)) + .toList(), + updateLogs: json['updateLogs'] ?? [], + attachments: json['attachments'] ?? [], + isActive: json['isActive'], + isExpenseCreated: json['isExpenseCreated'], + ); + + Map toJson() => { + 'id': id, + 'title': title, + 'description': description, + 'paymentRequestUID': paymentRequestUID, + 'payee': payee, + 'currency': currency.toJson(), + 'amount': amount, + 'baseAmount': baseAmount, + 'taxAmount': taxAmount, + 'dueDate': dueDate.toIso8601String(), + 'project': project.toJson(), + 'recurringPayment': recurringPayment, + 'expenseCategory': expenseCategory.toJson(), + 'expenseStatus': expenseStatus.toJson(), + 'paidTransactionId': paidTransactionId, + 'paidAt': paidAt?.toIso8601String(), + 'paidBy': paidBy, + 'isAdvancePayment': isAdvancePayment, + 'createdAt': createdAt.toIso8601String(), + 'createdBy': createdBy.toJson(), + 'updatedAt': updatedAt.toIso8601String(), + 'updatedBy': updatedBy, + 'nextStatus': nextStatus.map((e) => e.toJson()).toList(), + 'updateLogs': updateLogs, + 'attachments': attachments, + 'isActive': isActive, + 'isExpenseCreated': isExpenseCreated, + }; +} + +class Currency { + String id; + String currencyCode; + String currencyName; + String symbol; + bool isActive; + + Currency({ + required this.id, + required this.currencyCode, + required this.currencyName, + required this.symbol, + required this.isActive, + }); + + factory Currency.fromJson(Map json) => Currency( + id: json['id'], + currencyCode: json['currencyCode'], + currencyName: json['currencyName'], + symbol: json['symbol'], + isActive: json['isActive'], + ); + + Map toJson() => { + 'id': id, + 'currencyCode': currencyCode, + 'currencyName': currencyName, + 'symbol': symbol, + 'isActive': isActive, + }; +} + +class Project { + String id; + String name; + + Project({required this.id, required this.name}); + + factory Project.fromJson(Map json) => Project( + id: json['id'], + name: json['name'], + ); + + Map toJson() => { + 'id': id, + 'name': name, + }; +} + +class ExpenseCategory { + String id; + String name; + bool noOfPersonsRequired; + bool isAttachmentRequried; + String description; + + ExpenseCategory({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.isAttachmentRequried, + required this.description, + }); + + factory ExpenseCategory.fromJson(Map json) => ExpenseCategory( + id: json['id'], + name: json['name'], + noOfPersonsRequired: json['noOfPersonsRequired'], + isAttachmentRequried: json['isAttachmentRequried'], + description: json['description'], + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'noOfPersonsRequired': noOfPersonsRequired, + 'isAttachmentRequried': isAttachmentRequried, + 'description': description, + }; +} + +class ExpenseStatus { + String id; + String name; + String displayName; + String description; + List? permissionIds; + String color; + bool isSystem; + + ExpenseStatus({ + required this.id, + required this.name, + required this.displayName, + required this.description, + this.permissionIds, + required this.color, + required this.isSystem, + }); + + factory ExpenseStatus.fromJson(Map json) => ExpenseStatus( + id: json['id'], + name: json['name'], + displayName: json['displayName'], + description: json['description'], + permissionIds: json['permissionIds'] != null + ? List.from(json['permissionIds']) + : null, + color: json['color'], + isSystem: json['isSystem'], + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'displayName': displayName, + 'description': description, + 'permissionIds': permissionIds, + 'color': color, + 'isSystem': isSystem, + }; +} + +class CreatedBy { + String id; + String firstName; + String lastName; + String email; + String photo; + String jobRoleId; + String jobRoleName; + + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json['id'], + firstName: json['firstName'], + lastName: json['lastName'], + email: json['email'], + photo: json['photo'], + jobRoleId: json['jobRoleId'], + jobRoleName: json['jobRoleName'], + ); + + Map toJson() => { + 'id': id, + 'firstName': firstName, + 'lastName': lastName, + 'email': email, + 'photo': photo, + 'jobRoleId': jobRoleId, + 'jobRoleName': jobRoleName, + }; +} + +class NextStatus { + String id; + String name; + String displayName; + String description; + List? permissionIds; + String color; + bool isSystem; + + NextStatus({ + required this.id, + required this.name, + required this.displayName, + required this.description, + this.permissionIds, + required this.color, + required this.isSystem, + }); + + factory NextStatus.fromJson(Map json) => NextStatus( + id: json['id'], + name: json['name'], + displayName: json['displayName'], + description: json['description'], + permissionIds: json['permissionIds'] != null + ? List.from(json['permissionIds']) + : null, + color: json['color'], + isSystem: json['isSystem'], + ); + + Map toJson() => { + 'id': id, + 'name': name, + 'displayName': displayName, + 'description': description, + 'permissionIds': permissionIds, + 'color': color, + 'isSystem': isSystem, + }; +} diff --git a/lib/model/finance/payment_request_filter.dart b/lib/model/finance/payment_request_filter.dart new file mode 100644 index 0000000..9145503 --- /dev/null +++ b/lib/model/finance/payment_request_filter.dart @@ -0,0 +1,108 @@ +import 'dart:convert'; + +PaymentRequestFilter paymentRequestFilterFromJson(String str) => + PaymentRequestFilter.fromJson(json.decode(str)); + +String paymentRequestFilterToJson(PaymentRequestFilter data) => + json.encode(data.toJson()); + +class PaymentRequestFilter { + PaymentRequestFilter({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + bool success; + String message; + PaymentRequestFilterData data; + dynamic errors; + int statusCode; + DateTime timestamp; + + factory PaymentRequestFilter.fromJson(Map json) => + PaymentRequestFilter( + success: json["success"], + message: json["message"], + data: PaymentRequestFilterData.fromJson(json["data"]), + errors: json["errors"], + statusCode: json["statusCode"], + timestamp: DateTime.parse(json["timestamp"]), + ); + + Map toJson() => { + "success": success, + "message": message, + "data": data.toJson(), + "errors": errors, + "statusCode": statusCode, + "timestamp": timestamp.toIso8601String(), + }; +} + +class PaymentRequestFilterData { + PaymentRequestFilterData({ + required this.projects, + required this.currency, + required this.createdBy, + required this.status, + required this.expenseCategory, + required this.payees, + }); + + List projects; + List currency; + List createdBy; + List status; + List expenseCategory; + List payees; + + factory PaymentRequestFilterData.fromJson(Map json) => + PaymentRequestFilterData( + projects: List.from( + json["projects"].map((x) => IdNameModel.fromJson(x))), + currency: List.from( + json["currency"].map((x) => IdNameModel.fromJson(x))), + createdBy: List.from( + json["createdBy"].map((x) => IdNameModel.fromJson(x))), + status: List.from( + json["status"].map((x) => IdNameModel.fromJson(x))), + expenseCategory: List.from( + json["expenseCategory"].map((x) => IdNameModel.fromJson(x))), + payees: List.from( + json["payees"].map((x) => IdNameModel.fromJson(x))), + ); + + Map toJson() => { + "projects": List.from(projects.map((x) => x.toJson())), + "currency": List.from(currency.map((x) => x.toJson())), + "createdBy": List.from(createdBy.map((x) => x.toJson())), + "status": List.from(status.map((x) => x.toJson())), + "expenseCategory": + List.from(expenseCategory.map((x) => x.toJson())), + "payees": List.from(payees.map((x) => x.toJson())), + }; +} + +class IdNameModel { + IdNameModel({ + required this.id, + required this.name, + }); + + String id; + String name; + + factory IdNameModel.fromJson(Map json) => IdNameModel( + id: json["id"].toString(), + name: json["name"] ?? "", + ); + + Map toJson() => { + "id": id, + "name": name, + }; +} diff --git a/lib/model/finance/payment_request_filter_bottom_sheet.dart b/lib/model/finance/payment_request_filter_bottom_sheet.dart new file mode 100644 index 0000000..8d680b2 --- /dev/null +++ b/lib/model/finance/payment_request_filter_bottom_sheet.dart @@ -0,0 +1,471 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/finance/payment_request_controller.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/helpers/widgets/my_text_style.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/date_range_picker.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart'; + +class PaymentRequestFilterBottomSheet extends StatefulWidget { + final PaymentRequestController controller; + final ScrollController scrollController; + + const PaymentRequestFilterBottomSheet({ + super.key, + required this.controller, + required this.scrollController, + }); + + @override + State createState() => + _PaymentRequestFilterBottomSheetState(); +} + +class _PaymentRequestFilterBottomSheetState + extends State with UIMixin { + // ---------------- Date Range ---------------- + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + + // ---------------- Selected Filters (store IDs internally) ---------------- + final RxString selectedProjectId = ''.obs; + final RxList selectedSubmittedBy = [].obs; + final RxList selectedPayees = [].obs; + final RxString selectedCategoryId = ''.obs; + final RxString selectedCurrencyId = ''.obs; + final RxString selectedStatusId = ''.obs; + + // Computed display names + String get selectedProjectName => + widget.controller.projects + .firstWhereOrNull((e) => e.id == selectedProjectId.value) + ?.name ?? + 'Please select...'; + + String get selectedCategoryName => + widget.controller.categories + .firstWhereOrNull((e) => e.id == selectedCategoryId.value) + ?.name ?? + 'Please select...'; + + String get selectedCurrencyName => + widget.controller.currencies + .firstWhereOrNull((e) => e.id == selectedCurrencyId.value) + ?.name ?? + 'Please select...'; + + String get selectedStatusName => + widget.controller.statuses + .firstWhereOrNull((e) => e.id == selectedStatusId.value) + ?.name ?? + 'Please select...'; + + // ---------------- Filter Data ---------------- + final RxBool isFilterLoading = true.obs; + + // Individual RxLists for safe Obx usage + final RxList projectNames = [].obs; + final RxList submittedByNames = [].obs; + final RxList payeeNames = [].obs; + final RxList categoryNames = [].obs; + final RxList currencyNames = [].obs; + final RxList statusNames = [].obs; + + @override + void initState() { + super.initState(); + _loadFilterData(); + } + + Future _loadFilterData() async { + isFilterLoading.value = true; + await widget.controller.fetchPaymentRequestFilterOptions(); + + projectNames.assignAll(widget.controller.projects.map((e) => e.name)); + submittedByNames.assignAll(widget.controller.createdBy.map((e) => e.name)); + payeeNames.assignAll(widget.controller.payees.map((e) => e.name)); + categoryNames.assignAll(widget.controller.categories.map((e) => e.name)); + currencyNames.assignAll(widget.controller.currencies.map((e) => e.name)); + statusNames.assignAll(widget.controller.statuses.map((e) => e.name)); + + // 🔹 Prefill existing applied filter (if any) + final existing = widget.controller.appliedFilter; + + if (existing.isNotEmpty) { + // Project + if (existing['projectIds'] != null && + (existing['projectIds'] as List).isNotEmpty) { + selectedProjectId.value = (existing['projectIds'] as List).first; + } + + // Submitted By + if (existing['createdByIds'] != null && + existing['createdByIds'] is List) { + selectedSubmittedBy.assignAll( + (existing['createdByIds'] as List) + .map((id) => widget.controller.createdBy + .firstWhereOrNull((e) => e.id == id)) + .whereType() + .toList(), + ); + } + + // Payees + if (existing['payees'] != null && existing['payees'] is List) { + selectedPayees.assignAll( + (existing['payees'] as List) + .map((id) => + widget.controller.payees.firstWhereOrNull((e) => e.id == id)) + .whereType() + .toList(), + ); + } + + // Category + if (existing['expenseCategoryIds'] != null && + (existing['expenseCategoryIds'] as List).isNotEmpty) { + selectedCategoryId.value = + (existing['expenseCategoryIds'] as List).first; + } + + // Currency + if (existing['currencyIds'] != null && + (existing['currencyIds'] as List).isNotEmpty) { + selectedCurrencyId.value = (existing['currencyIds'] as List).first; + } + + // Status + if (existing['statusIds'] != null && + (existing['statusIds'] as List).isNotEmpty) { + selectedStatusId.value = (existing['statusIds'] as List).first; + } + + // Dates + if (existing['startDate'] != null && existing['endDate'] != null) { + startDate.value = DateTime.tryParse(existing['startDate']); + endDate.value = DateTime.tryParse(existing['endDate']); + } + } + + isFilterLoading.value = false; + } + + Future> searchEmployees( + String query, List items) async { + final allEmployees = items + .map((e) => EmployeeModel( + id: e, + name: e, + firstName: e, + lastName: '', + jobRoleID: '', + employeeId: e, + designation: '', + activity: 0, + action: 0, + jobRole: '', + email: '-', + phoneNumber: '-', + )) + .toList(); + + if (query.trim().isEmpty) return allEmployees; + + return allEmployees + .where((e) => e.name.toLowerCase().contains(query.toLowerCase())) + .toList(); + } + + @override + Widget build(BuildContext context) { + return BaseBottomSheet( + title: 'Filter Payment Requests', + onCancel: () => Get.back(), + onSubmit: () { + _applyFilters(); + Get.back(); + }, + submitText: 'Apply', + submitColor: contentTheme.primary, + submitIcon: Icons.check_circle_outline, + child: SingleChildScrollView( + controller: widget.scrollController, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Align( + alignment: Alignment.centerRight, + child: TextButton( + onPressed: clearFilters, + child: MyText( + "Reset Filters", + style: MyTextStyle.labelMedium( + color: Colors.red, + fontWeight: 600, + ), + ), + ), + ), + MySpacing.height(8), + _buildDateRangeFilter(), + MySpacing.height(16), + _buildProjectFilter(), + MySpacing.height(16), + _buildSubmittedByFilter(), + MySpacing.height(16), + _buildPayeeFilter(), + MySpacing.height(16), + _buildCategoryFilter(), + MySpacing.height(16), + _buildCurrencyFilter(), + MySpacing.height(16), + _buildStatusFilter(), + ], + ), + ), + ); + } + + void clearFilters() { + startDate.value = null; + endDate.value = null; + selectedProjectId.value = ''; + selectedSubmittedBy.clear(); + selectedPayees.clear(); + selectedCategoryId.value = ''; + selectedCurrencyId.value = ''; + selectedStatusId.value = ''; + widget.controller.setFilterApplied(false); + } + + void _applyFilters() { + final Map filter = { + "projectIds": + selectedProjectId.value.isEmpty ? [] : [selectedProjectId.value], + "createdByIds": selectedSubmittedBy.map((e) => e.id).toList(), + "payees": selectedPayees.map((e) => e.id).toList(), + "expenseCategoryIds": + selectedCategoryId.value.isEmpty ? [] : [selectedCategoryId.value], + "currencyIds": + selectedCurrencyId.value.isEmpty ? [] : [selectedCurrencyId.value], + "statusIds": + selectedStatusId.value.isEmpty ? [] : [selectedStatusId.value], + "startDate": startDate.value?.toIso8601String(), + "endDate": endDate.value?.toIso8601String(), + }; + + widget.controller.applyFilter(filter); + } + + Widget _buildField(String label, Widget child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium(label), + MySpacing.height(8), + child, + ], + ); + } + + Widget _buildDateRangeFilter() { + return _buildField( + "Filter By Date", + DateRangePickerWidget( + startDate: startDate, + endDate: endDate, + startLabel: "Start Date", + endLabel: "End Date", + onDateRangeSelected: (start, end) { + startDate.value = start; + endDate.value = end; + }, + ), + ); + } + + Widget _buildProjectFilter() { + return _buildField( + "Project", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _popupSelector( + currentValue: selectedProjectName, + items: projectNames, + onSelected: (value) { + final proj = widget.controller.projects + .firstWhereOrNull((e) => e.name == value); + if (proj != null) selectedProjectId.value = proj.id; + }, + ); + }), + ); + } + + Widget _buildSubmittedByFilter() { + return _buildField( + "Submitted By", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _employeeSelector( + selectedSubmittedBy, "Search Submitted By", submittedByNames); + }), + ); + } + + Widget _buildPayeeFilter() { + return _buildField( + "Payee", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _employeeSelector(selectedPayees, "Search Payee", payeeNames); + }), + ); + } + + Widget _buildCategoryFilter() { + return _buildField( + "Category", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _popupSelector( + currentValue: selectedCategoryName, + items: categoryNames, + onSelected: (value) { + final cat = widget.controller.categories + .firstWhereOrNull((e) => e.name == value); + if (cat != null) selectedCategoryId.value = cat.id; + }, + ); + }), + ); + } + + Widget _buildCurrencyFilter() { + return _buildField( + "Currency", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _popupSelector( + currentValue: selectedCurrencyName, + items: currencyNames, + onSelected: (value) { + final cur = widget.controller.currencies + .firstWhereOrNull((e) => e.name == value); + if (cur != null) selectedCurrencyId.value = cur.id; + }, + ); + }), + ); + } + + Widget _buildStatusFilter() { + return _buildField( + "Status", + Obx(() { + if (isFilterLoading.value) return const CircularProgressIndicator(); + return _popupSelector( + currentValue: selectedStatusName, + items: statusNames, + onSelected: (value) { + final st = widget.controller.statuses + .firstWhereOrNull((e) => e.name == value); + if (st != null) selectedStatusId.value = st.id; + }, + ); + }), + ); + } + + Widget _popupSelector({ + required String currentValue, + required List items, + required ValueChanged onSelected, + }) { + return PopupMenuButton( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: onSelected, + itemBuilder: (context) => + items.map((e) => PopupMenuItem(value: e, child: MyText(e))).toList(), + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: MyText( + currentValue, + style: const TextStyle(color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + const Icon(Icons.arrow_drop_down, color: Colors.grey), + ], + ), + ), + ); + } + + Widget _employeeSelector(RxList selectedEmployees, + String title, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Obx(() { + if (selectedEmployees.isEmpty) return const SizedBox.shrink(); + return Wrap( + spacing: 8, + children: selectedEmployees + .map((emp) => Chip( + label: MyText(emp.name), + onDeleted: () => selectedEmployees.remove(emp), + )) + .toList(), + ); + }), + MySpacing.height(8), + GestureDetector( + onTap: () async { + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => EmployeeSelectorBottomSheet( + selectedEmployees: selectedEmployees, + searchEmployees: (query) => searchEmployees(query, items), + title: title, + ), + ); + if (result != null) selectedEmployees.assignAll(result); + }, + child: Container( + padding: MySpacing.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + children: [ + const Icon(Icons.search, color: Colors.grey), + MySpacing.width(8), + Expanded(child: MyText(title)), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/model/finance/payment_request_list_model.dart b/lib/model/finance/payment_request_list_model.dart new file mode 100644 index 0000000..538ccd4 --- /dev/null +++ b/lib/model/finance/payment_request_list_model.dart @@ -0,0 +1,306 @@ +import 'dart:convert'; + +PaymentRequestResponse paymentRequestResponseFromJson(String str) => + PaymentRequestResponse.fromJson(json.decode(str)); + +String paymentRequestResponseToJson(PaymentRequestResponse data) => + json.encode(data.toJson()); + +class PaymentRequestResponse { + PaymentRequestResponse({ + required this.success, + required this.message, + required this.data, + }); + + bool success; + String message; + PaymentRequestData data; + + factory PaymentRequestResponse.fromJson(Map json) => + PaymentRequestResponse( + success: json["success"], + message: json["message"], + data: PaymentRequestData.fromJson(json["data"]), + ); + + Map toJson() => { + "success": success, + "message": message, + "data": data.toJson(), + }; +} + +class PaymentRequestData { + PaymentRequestData({ + required this.currentPage, + required this.totalPages, + required this.totalEntities, + required this.data, + }); + + int currentPage; + int totalPages; + int totalEntities; + List data; + + factory PaymentRequestData.fromJson(Map json) => + PaymentRequestData( + currentPage: json["currentPage"], + totalPages: json["totalPages"], + totalEntities: json["totalEntities"], + data: List.from( + json["data"].map((x) => PaymentRequest.fromJson(x))), + ); + + Map toJson() => { + "currentPage": currentPage, + "totalPages": totalPages, + "totalEntities": totalEntities, + "data": List.from(data.map((x) => x.toJson())), + }; +} + +class PaymentRequest { + PaymentRequest({ + required this.id, + required this.title, + required this.description, + this.recurringPayment, + required this.paymentRequestUID, + required this.payee, + required this.currency, + required this.amount, + required this.dueDate, + required this.project, + required this.expenseCategory, + required this.expenseStatus, + required this.isAdvancePayment, + required this.createdAt, + required this.createdBy, + required this.isActive, + required this.isExpenseCreated, + }); + + String id; + String title; + String description; + dynamic recurringPayment; + String paymentRequestUID; + String payee; + Currency currency; + num amount; + DateTime dueDate; + Project project; + ExpenseCategory expenseCategory; + ExpenseStatus expenseStatus; + bool isAdvancePayment; + DateTime createdAt; + CreatedBy createdBy; + bool isActive; + bool isExpenseCreated; + + factory PaymentRequest.fromJson(Map json) => PaymentRequest( + id: json["id"], + title: json["title"], + description: json["description"], + recurringPayment: json["recurringPayment"], + paymentRequestUID: json["paymentRequestUID"], + payee: json["payee"], + currency: Currency.fromJson(json["currency"]), + amount: json["amount"], + dueDate: DateTime.parse(json["dueDate"]), + project: Project.fromJson(json["project"]), + expenseCategory: ExpenseCategory.fromJson(json["expenseCategory"]), + expenseStatus: ExpenseStatus.fromJson(json["expenseStatus"]), + isAdvancePayment: json["isAdvancePayment"], + createdAt: DateTime.parse(json["createdAt"]), + createdBy: CreatedBy.fromJson(json["createdBy"]), + isActive: json["isActive"], + isExpenseCreated: json["isExpenseCreated"], + ); + + Map toJson() => { + "id": id, + "title": title, + "description": description, + "recurringPayment": recurringPayment, + "paymentRequestUID": paymentRequestUID, + "payee": payee, + "currency": currency.toJson(), + "amount": amount, + "dueDate": dueDate.toIso8601String(), + "project": project.toJson(), + "expenseCategory": expenseCategory.toJson(), + "expenseStatus": expenseStatus.toJson(), + "isAdvancePayment": isAdvancePayment, + "createdAt": createdAt.toIso8601String(), + "createdBy": createdBy.toJson(), + "isActive": isActive, + "isExpenseCreated": isExpenseCreated, + }; +} + +class Currency { + Currency({ + required this.id, + required this.currencyCode, + required this.currencyName, + required this.symbol, + required this.isActive, + }); + + String id; + String currencyCode; + String currencyName; + String symbol; + bool isActive; + + factory Currency.fromJson(Map json) => Currency( + id: json["id"], + currencyCode: json["currencyCode"], + currencyName: json["currencyName"], + symbol: json["symbol"], + isActive: json["isActive"], + ); + + Map toJson() => { + "id": id, + "currencyCode": currencyCode, + "currencyName": currencyName, + "symbol": symbol, + "isActive": isActive, + }; +} + +class Project { + Project({ + required this.id, + required this.name, + }); + + String id; + String name; + + factory Project.fromJson(Map json) => Project( + id: json["id"], + name: json["name"], + ); + + Map toJson() => { + "id": id, + "name": name, + }; +} + +class ExpenseCategory { + ExpenseCategory({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.isAttachmentRequried, + required this.description, + }); + + String id; + String name; + bool noOfPersonsRequired; + bool isAttachmentRequried; + String description; + + factory ExpenseCategory.fromJson(Map json) => ExpenseCategory( + id: json["id"], + name: json["name"], + noOfPersonsRequired: json["noOfPersonsRequired"], + isAttachmentRequried: json["isAttachmentRequried"], + description: json["description"], + ); + + Map toJson() => { + "id": id, + "name": name, + "noOfPersonsRequired": noOfPersonsRequired, + "isAttachmentRequried": isAttachmentRequried, + "description": description, + }; +} + +class ExpenseStatus { + ExpenseStatus({ + required this.id, + required this.name, + required this.displayName, + required this.description, + this.permissionIds, + required this.color, + required this.isSystem, + }); + + String id; + String name; + String displayName; + String description; + dynamic permissionIds; + String color; + bool isSystem; + + factory ExpenseStatus.fromJson(Map json) => ExpenseStatus( + id: json["id"], + name: json["name"], + displayName: json["displayName"], + description: json["description"], + permissionIds: json["permissionIds"], + color: json["color"], + isSystem: json["isSystem"], + ); + + Map toJson() => { + "id": id, + "name": name, + "displayName": displayName, + "description": description, + "permissionIds": permissionIds, + "color": color, + "isSystem": isSystem, + }; +} + +class CreatedBy { + CreatedBy({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + String id; + String firstName; + String lastName; + String email; + String photo; + String jobRoleId; + String jobRoleName; + + factory CreatedBy.fromJson(Map json) => CreatedBy( + id: json["id"], + firstName: json["firstName"], + lastName: json["lastName"], + email: json["email"], + photo: json["photo"], + jobRoleId: json["jobRoleId"], + jobRoleName: json["jobRoleName"], + ); + + Map toJson() => { + "id": id, + "firstName": firstName, + "lastName": lastName, + "email": email, + "photo": photo, + "jobRoleId": jobRoleId, + "jobRoleName": jobRoleName, + }; +} diff --git a/lib/routes.dart b/lib/routes.dart index d63efba..a220b02 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -19,6 +19,8 @@ import 'package:marco/view/directory/directory_main_screen.dart'; import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/finance/finance_screen.dart'; +import 'package:marco/view/finance/payment_request_screen.dart'; + class AuthMiddleware extends GetMiddleware { @override @@ -65,6 +67,10 @@ getPageRoute() { name: '/dashboard/finance', page: () => FinanceScreen(), middlewares: [AuthMiddleware()]), + GetPage( + name: '/dashboard/payment-request', + page: () => PaymentRequestMainScreen(), + middlewares: [AuthMiddleware()]), // Expense GetPage( name: '/dashboard/expense-main-page', diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart index a67968f..08ee803 100644 --- a/lib/view/finance/finance_screen.dart +++ b/lib/view/finance/finance_screen.dart @@ -6,7 +6,6 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/widgets/my_card.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; -import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart'; class FinanceScreen extends StatefulWidget { const FinanceScreen({super.key}); @@ -111,14 +110,6 @@ class _FinanceScreenState extends State child: _buildFinanceModulesCompact(), ), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - showPaymentRequestBottomSheet(); - }, - backgroundColor: contentTheme.primary, - child: Icon(Icons.add), - tooltip: "Create Payment Request", - ), ); } @@ -129,8 +120,8 @@ class _FinanceScreenState extends State contentTheme.info, "/dashboard/expense-main-page"), _FinanceStatItem(LucideIcons.receipt_text, "Payment Request", contentTheme.primary, "/dashboard/payment-request"), - _FinanceStatItem( - LucideIcons.wallet, "Advance Payment", contentTheme.warning, "/dashboard/advance-payment"), + _FinanceStatItem(LucideIcons.wallet, "Advance Payment", + contentTheme.warning, "/dashboard/advance-payment"), ]; final projectSelected = projectController.selectedProject != null; @@ -209,8 +200,7 @@ class _FinanceScreenState extends State if (!isEnabled) { Get.defaultDialog( title: "No Project Selected", - middleText: - "Please select a project before accessing this section.", + middleText: "Please select a project before accessing this section.", confirm: ElevatedButton( onPressed: () => Get.back(), child: const Text("OK"), diff --git a/lib/view/finance/payment_request_detail_screen.dart b/lib/view/finance/payment_request_detail_screen.dart new file mode 100644 index 0000000..6eb8743 --- /dev/null +++ b/lib/view/finance/payment_request_detail_screen.dart @@ -0,0 +1,535 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/finance/payment_request_detail_controller.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/permission_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_refresh_indicator.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:timeline_tile/timeline_tile.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/model/finance/payment_request_details_model.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; +import 'package:marco/helpers/widgets/my_custom_skeleton.dart'; +import 'package:timeago/timeago.dart' as timeago; + +class PaymentRequestDetailScreen extends StatefulWidget { + final String paymentRequestId; + const PaymentRequestDetailScreen({super.key, required this.paymentRequestId}); + + @override + State createState() => + _PaymentRequestDetailScreenState(); +} + +class _PaymentRequestDetailScreenState extends State + with UIMixin { + final controller = Get.put(PaymentRequestDetailController()); + final projectController = Get.find(); + final permissionController = Get.find(); + + @override + void initState() { + super.initState(); + controller.init(widget.paymentRequestId); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(), + body: SafeArea( + child: Obx(() { + if (controller.isLoading.value) { + return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); + } + + final request = + controller.paymentRequest.value as PaymentRequestData?; + if (controller.errorMessage.isNotEmpty || request == null) { + return Center(child: MyText.bodyMedium("No data to display.")); + } + + return MyRefreshIndicator( + onRefresh: controller.fetchPaymentRequestDetail, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB( + 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom), + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 520), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5)), + elevation: 3, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 14, horizontal: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(request: request), + const Divider(height: 30, thickness: 1.2), + + // Move Logs here, right after header + _Logs(logs: request.updateLogs), + const Divider(height: 30, thickness: 1.2), + + _Parties(request: request), + const Divider(height: 30, thickness: 1.2), + _DetailsTable(request: request), + const Divider(height: 30, thickness: 1.2), + _Documents(documents: request.attachments), + ], + ), + ), + ), + ), + ), + ), + ); + }), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.back(), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Payment Request Details', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final name = projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +// Header Row +class _Header extends StatelessWidget { + final PaymentRequestData request; + const _Header({required this.request}); + + // Helper to parse hex color string to Color + Color parseColorFromHex(String hexColor) { + hexColor = hexColor.toUpperCase().replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF" + hexColor; // Add alpha if missing + } + return Color(int.parse(hexColor, radix: 16)); + } + + @override + Widget build(BuildContext context) { + final statusColor = parseColorFromHex(request.expenseStatus.color); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Left side: wrap in Expanded to prevent overflow + Expanded( + child: Row( + children: [ + const Icon(Icons.calendar_month, size: 18, color: Colors.grey), + MySpacing.width(6), + MyText.bodySmall('Created At:', fontWeight: 600), + MySpacing.width(6), + Expanded( + child: MyText.bodySmall( + DateTimeUtils.convertUtcToLocal( + request.createdAt.toIso8601String(), + format: 'dd MMM yyyy'), + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + + // Right side: Status Chip + Container( + decoration: BoxDecoration( + color: statusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(5)), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( + children: [ + Icon(Icons.flag, size: 16, color: statusColor), + MySpacing.width(4), + SizedBox( + // Prevent overflow of long status text + width: 100, + child: MyText.labelSmall( + request.expenseStatus.displayName, + color: statusColor, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ); + } +} + +// Horizontal label-value row +Widget labelValueRow(String label, String value) => Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 120, + child: MyText.bodySmall( + label, + fontWeight: 600, + ), + ), + Expanded( + child: MyText.bodySmall( + value, + fontWeight: 500, + softWrap: true, + ), + ), + ], + ), + ); + +// Parties Section +class _Parties extends StatelessWidget { + final PaymentRequestData request; + const _Parties({required this.request}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelValueRow('Project', request.project.name), + labelValueRow('Payee', request.payee), + labelValueRow('Created By', + '${request.createdBy.firstName} ${request.createdBy.lastName}'), + labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'), + ], + ); + } +} + +// Details Table +class _DetailsTable extends StatelessWidget { + final PaymentRequestData request; + const _DetailsTable({required this.request}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + labelValueRow("Payment Request ID:", request.paymentRequestUID), + labelValueRow("Expense Category:", request.expenseCategory.name), + labelValueRow("Amount:", + "${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"), + labelValueRow( + "Due Date:", + DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(), + format: 'dd MMM yyyy'), + ), + labelValueRow("Description:", request.description), + labelValueRow( + "Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"), + ], + ); + } +} + +// Documents Section +class _Documents extends StatelessWidget { + final List documents; + const _Documents({required this.documents}); + + @override + Widget build(BuildContext context) { + if (documents.isEmpty) + return MyText.bodyMedium('No Documents', color: Colors.grey); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Documents:", fontWeight: 600), + const SizedBox(height: 12), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: documents.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final doc = documents[index] as Map; + return GestureDetector( + onTap: () async { + final imageDocs = documents + .where((d) => + (d['contentType'] as String).startsWith('image/')) + .toList(); + + final initialIndex = + imageDocs.indexWhere((d) => d['id'] == doc['id']); + + if (imageDocs.isNotEmpty && initialIndex != -1) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: + imageDocs.map((e) => e['url'] as String).toList(), + initialIndex: initialIndex, + ), + ); + } else { + final Uri url = Uri.parse(doc['url'] as String); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not open document.')), + ); + } + } + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(5), + color: Colors.grey.shade100, + ), + child: Row( + children: [ + Icon( + (doc['contentType'] as String).startsWith('image/') + ? Icons.image + : Icons.insert_drive_file, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 7), + Expanded( + child: MyText.labelSmall( + doc['fileName'] ?? '', + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} + +class _Logs extends StatelessWidget { + final List logs; + const _Logs({required this.logs}); + + // Helper to parse hex color string to Color + Color parseColorFromHex(String hexColor) { + hexColor = hexColor.toUpperCase().replaceAll("#", ""); + if (hexColor.length == 6) { + hexColor = "FF" + hexColor; // Add alpha for opacity if missing + } + return Color(int.parse(hexColor, radix: 16)); + } + + DateTime parseTimestamp(String ts) => DateTime.parse(ts); + + @override + Widget build(BuildContext context) { + if (logs.isEmpty) return MyText.bodyMedium('No Timeline', color: Colors.grey); + + final reversedLogs = logs.reversed.toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall("Timeline:", fontWeight: 600), + ListView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: reversedLogs.length, + itemBuilder: (_, index) { + final log = reversedLogs[index] as Map; + final statusMap = log['status'] ?? {}; + final status = statusMap['name'] ?? ''; + final description = statusMap['description'] ?? ''; + final comment = log['comment'] ?? ''; + + final nextStatusMap = log['nextStatus'] ?? {}; + final nextStatusName = nextStatusMap['name'] ?? ''; + + final updatedBy = log['updatedBy'] ?? {}; + final initials = + "${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}" + "${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}"; + final name = + "${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}"; + + final timestamp = parseTimestamp(log['updatedAt']); + final timeAgo = timeago.format(timestamp); + + final statusColor = statusMap['color'] != null + ? parseColorFromHex(statusMap['color']) + : Colors.black; + + final nextStatusColor = nextStatusMap['color'] != null + ? parseColorFromHex(nextStatusMap['color']) + : Colors.blue.shade700; + + return TimelineTile( + alignment: TimelineAlign.start, + isFirst: index == 0, + isLast: index == reversedLogs.length - 1, + indicatorStyle: IndicatorStyle( + width: 16, + height: 16, + indicator: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: statusColor, + ), + ), + ), + beforeLineStyle: + LineStyle(color: Colors.grey.shade300, thickness: 2), + endChild: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Status and time in one row + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodyMedium( + status, + fontWeight: 600, + color: statusColor, + ), + MyText.bodySmall( + timeAgo, + color: Colors.grey[600], + textAlign: TextAlign.right, + ), + ], + ), + if (description.isNotEmpty) ...[ + const SizedBox(height: 4), + MyText.bodySmall(description, color: Colors.grey[800]), + ], + if (comment.isNotEmpty) ...[ + const SizedBox(height: 8), + MyText.bodyMedium(comment, fontWeight: 500), + ], + const SizedBox(height: 8), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall(initials, fontWeight: 600), + ), + const SizedBox(width: 6), + Expanded( + child: MyText.bodySmall( + name, + overflow: TextOverflow.ellipsis, + ), + ), + if (nextStatusName.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: nextStatusColor.withOpacity(0.15), + borderRadius: BorderRadius.circular(4), + ), + child: MyText.bodySmall( + nextStatusName, + fontWeight: 600, + color: nextStatusColor, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ) + ], + ); + } +} diff --git a/lib/view/finance/payment_request_screen.dart b/lib/view/finance/payment_request_screen.dart new file mode 100644 index 0000000..397d889 --- /dev/null +++ b/lib/view/finance/payment_request_screen.dart @@ -0,0 +1,365 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/project_controller.dart'; +import 'package:marco/controller/finance/payment_request_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/finance/payment_request_filter_bottom_sheet.dart'; +import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/view/finance/payment_request_detail_screen.dart'; + +class PaymentRequestMainScreen extends StatefulWidget { + const PaymentRequestMainScreen({super.key}); + + @override + State createState() => + _PaymentRequestMainScreenState(); +} + +class _PaymentRequestMainScreenState extends State + with SingleTickerProviderStateMixin, UIMixin { + late TabController _tabController; + final searchController = TextEditingController(); + final paymentController = Get.put(PaymentRequestController()); + final projectController = Get.find(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + + WidgetsBinding.instance.addPostFrameCallback((_) { + paymentController.fetchPaymentRequests(); + }); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + Future _refreshPaymentRequests() async { + await paymentController.fetchPaymentRequests(); + } + + void _openFilterBottomSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (context) => PaymentRequestFilterBottomSheet( + controller: paymentController, + scrollController: ScrollController(), + ), + ); + } + + List filteredList({required bool isHistory}) { + final query = searchController.text.trim().toLowerCase(); + final now = DateTime.now(); + + final filtered = paymentController.paymentRequests.where((e) { + return query.isEmpty || + e.title.toLowerCase().contains(query) || + e.payee.toLowerCase().contains(query); + }).toList() + ..sort((a, b) => b.dueDate.compareTo(a.dueDate)); + + return isHistory + ? filtered + .where((e) => e.dueDate.isBefore(DateTime(now.year, now.month))) + .toList() + : filtered + .where((e) => + e.dueDate.month == now.month && e.dueDate.year == now.year) + .toList(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + appBar: _buildAppBar(), + body: Column( + children: [ + Container( + color: Colors.white, + child: TabBar( + controller: _tabController, + labelColor: Colors.black, + unselectedLabelColor: Colors.grey, + indicatorColor: Colors.red, + tabs: const [ + Tab(text: "Current Month"), + Tab(text: "History"), + ], + ), + ), + Expanded( + child: Container( + color: Colors.grey[100], + child: Column( + children: [ + _buildSearchBar(), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildPaymentRequestList(isHistory: false), + _buildPaymentRequestList(isHistory: true), + ], + ), + ), + ], + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () { + showPaymentRequestBottomSheet(); + }, + backgroundColor: contentTheme.primary, + icon: const Icon(Icons.add), + label: const Text("Create Payment Request"), + ), + ); + } + + PreferredSizeWidget _buildAppBar() { + return PreferredSize( + preferredSize: const Size.fromHeight(72), + child: AppBar( + backgroundColor: const Color(0xFFF5F5F5), + elevation: 0.5, + automaticallyImplyLeading: false, + titleSpacing: 0, + title: Padding( + padding: MySpacing.xy(16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios_new, + color: Colors.black, size: 20), + onPressed: () => Get.offNamed('/dashboard/finance'), + ), + MySpacing.width(8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge( + 'Payment Requests', + fontWeight: 700, + color: Colors.black, + ), + MySpacing.height(2), + GetBuilder( + builder: (_) { + final name = projectController.selectedProject?.name ?? + 'Select Project'; + return Row( + children: [ + const Icon(Icons.work_outline, + size: 14, color: Colors.grey), + MySpacing.width(4), + Expanded( + child: MyText.bodySmall( + name, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + color: Colors.grey[700], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSearchBar() { + return Padding( + padding: MySpacing.fromLTRB(12, 10, 12, 0), + child: Row( + children: [ + Expanded( + child: SizedBox( + height: 35, + child: TextField( + controller: searchController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12), + prefixIcon: + const Icon(Icons.search, size: 20, color: Colors.grey), + hintText: 'Search payment requests...', + filled: true, + fillColor: Colors.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: + BorderSide(color: contentTheme.primary, width: 1.5), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(5), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + ), + ), + ), + MySpacing.width(4), + Obx(() { + return IconButton( + icon: Stack( + clipBehavior: Clip.none, + children: [ + const Icon(Icons.tune, color: Colors.black), + if (paymentController.isFilterApplied.value) + Positioned( + top: -1, + right: -1, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], + ), + onPressed: _openFilterBottomSheet, + ); + }), + ], + ), + ); + } + + Widget _buildPaymentRequestList({required bool isHistory}) { + return Obx(() { + if (paymentController.isLoading.value && + paymentController.paymentRequests.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + + final list = filteredList(isHistory: isHistory); + + return RefreshIndicator( + onRefresh: _refreshPaymentRequests, + child: list.isEmpty + ? ListView( + physics: const AlwaysScrollableScrollPhysics(), + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 0.5, + child: Center( + child: Text( + paymentController.errorMessage.isNotEmpty + ? paymentController.errorMessage.value + : "No payment requests found", + style: const TextStyle(color: Colors.grey), + ), + ), + ), + ], + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: list.length, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + final item = list[index]; + return _buildPaymentRequestTile(item); + }, + ), + ); + }); + } + + Widget _buildPaymentRequestTile(dynamic item) { + final dueDate = + DateTimeUtils.formatDate(item.dueDate, DateTimeUtils.defaultFormat); + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + // Navigate to detail screen, passing the payment request ID + Get.to(() => PaymentRequestDetailScreen(paymentRequestId: item.id)); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + MyText.bodyMedium(item.expenseCategory.name, fontWeight: 600), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + MyText.bodySmall("Payee: ", color: Colors.grey[600]), + MyText.bodyMedium(item.payee, fontWeight: 600), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Row( + children: [ + MyText.bodySmall("Due Date: ", color: Colors.grey[600]), + MyText.bodySmall(dueDate, fontWeight: 500), + ], + ), + const Spacer(), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Color(int.parse( + '0xff${item.expenseStatus.color.substring(1)}')), + borderRadius: BorderRadius.circular(5), + ), + child: MyText.bodySmall( + item.expenseStatus.displayName, + color: Colors.white, + fontWeight: 500, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index fcaef42..ce635f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -82,6 +82,7 @@ dependencies: flutter_local_notifications: 19.4.0 equatable: ^2.0.7 mime: ^2.0.0 + timeago: ^3.7.1 timeline_tile: ^2.0.0 dev_dependencies: