From 44674da8ac83c837f93754f9aa9ccd51e5d3d86f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 7 Nov 2025 17:19:56 +0530 Subject: [PATCH] added process flow and make payment functionallity --- .../document/user_document_controller.dart | 28 +- .../finance/payment_request_controller.dart | 13 +- .../payment_request_detail_controller.dart | 354 +++++++++- lib/helpers/services/api_endpoints.dart | 8 +- lib/helpers/services/api_service.dart | 108 +++ .../widgets/expense/expense_form_widgets.dart | 5 +- .../finance/make_expense_bottom_sheet.dart | 222 ++++++ .../finance/payment_mode_response_model.dart | 65 ++ .../payment_request_details_model.dart | 138 +++- ...ent_request_rembursement_bottom_sheet.dart | 271 ++++++++ .../payment_request_detail_screen.dart | 636 ++++++++++-------- lib/view/finance/payment_request_screen.dart | 27 +- 12 files changed, 1550 insertions(+), 325 deletions(-) create mode 100644 lib/model/finance/make_expense_bottom_sheet.dart create mode 100644 lib/model/finance/payment_mode_response_model.dart create mode 100644 lib/model/finance/payment_request_rembursement_bottom_sheet.dart diff --git a/lib/controller/document/user_document_controller.dart b/lib/controller/document/user_document_controller.dart index 5667003..724ef57 100644 --- a/lib/controller/document/user_document_controller.dart +++ b/lib/controller/document/user_document_controller.dart @@ -3,6 +3,7 @@ import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/model/document/document_filter_model.dart'; import 'package:marco/model/document/documents_list_model.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; class DocumentController extends GetxController { // ==================== Observables ==================== @@ -38,7 +39,6 @@ class DocumentController extends GetxController { final endDate = Rxn(); // ==================== Lifecycle ==================== - @override void onClose() { // Don't dispose searchController here - it's managed by the page @@ -87,13 +87,22 @@ class DocumentController extends GetxController { entityId: entityId, reset: true, ); + + showAppSnackbar( + title: 'Success', + message: 'Document state updated successfully', + type: SnackbarType.success, + ); + return true; } else { errorMessage.value = 'Failed to update document state'; + _showError('Failed to update document state'); return false; } } catch (e) { errorMessage.value = 'Error updating document: $e'; + _showError('Error updating document: $e'); debugPrint('❌ Error toggling document state: $e'); return false; } finally { @@ -110,17 +119,13 @@ class DocumentController extends GetxController { bool reset = false, }) async { try { - // Reset pagination if needed if (reset) { pageNumber.value = 1; documents.clear(); hasMore.value = true; } - // Don't fetch if no more data if (!hasMore.value && !reset) return; - - // Prevent duplicate requests if (isLoading.value) return; isLoading.value = true; @@ -187,15 +192,10 @@ class DocumentController extends GetxController { /// Show error message void _showError(String message) { - Get.snackbar( - 'Error', - message, - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red.shade100, - colorText: Colors.red.shade900, - margin: const EdgeInsets.all(16), - borderRadius: 8, - duration: const Duration(seconds: 3), + showAppSnackbar( + title: 'Error', + message: message, + type: SnackbarType.error, ); } diff --git a/lib/controller/finance/payment_request_controller.dart b/lib/controller/finance/payment_request_controller.dart index ace2a79..67e198d 100644 --- a/lib/controller/finance/payment_request_controller.dart +++ b/lib/controller/finance/payment_request_controller.dart @@ -40,10 +40,12 @@ class PaymentRequestController extends GetxController { statuses.assignAll(response.data.status); createdBy.assignAll(response.data.createdBy); } else { - logSafe("Payment request filter API returned null", level: LogLevel.warning); + logSafe("Payment request filter API returned null", + level: LogLevel.warning); } } catch (e, stack) { - logSafe("Exception in fetchPaymentRequestFilterOptions: $e", level: LogLevel.error); + logSafe("Exception in fetchPaymentRequestFilterOptions: $e", + level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } } @@ -84,9 +86,11 @@ class PaymentRequestController extends GetxController { if (response != null && response.data.data.isNotEmpty) { if (_pageNumber == 1) { + // First page, replace the list paymentRequests.assignAll(response.data.data); } else { - paymentRequests.addAll(response.data.data); + // Insert new data at the top for latest first + paymentRequests.insertAll(0, response.data.data); } } else { if (_pageNumber == 1) { @@ -97,7 +101,8 @@ class PaymentRequestController extends GetxController { } } catch (e, stack) { errorMessage.value = 'Failed to fetch payment requests.'; - logSafe("Exception in _fetchPaymentRequestsFromApi: $e", level: LogLevel.error); + logSafe("Exception in _fetchPaymentRequestsFromApi: $e", + level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); } } diff --git a/lib/controller/finance/payment_request_detail_controller.dart b/lib/controller/finance/payment_request_detail_controller.dart index 63cb0eb..bb3421a 100644 --- a/lib/controller/finance/payment_request_detail_controller.dart +++ b/lib/controller/finance/payment_request_detail_controller.dart @@ -1,31 +1,363 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/employees/employee_model.dart'; +import 'package:marco/model/finance/payment_request_details_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/helpers/widgets/time_stamp_image_helper.dart'; +import 'package:image_picker/image_picker.dart'; +import 'dart:convert'; +import 'dart:io'; +import 'package:file_picker/file_picker.dart'; +import 'package:geocoding/geocoding.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mime/mime.dart'; class PaymentRequestDetailController extends GetxController { - final Rx paymentRequest = Rx(null); + final Rx paymentRequest = Rx(null); final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final RxList paymentModes = [].obs; + + // Employee selection + final Rx selectedReimbursedBy = Rx(null); + final RxList allEmployees = [].obs; + final RxList employeeSearchResults = [].obs; + final TextEditingController employeeSearchController = + TextEditingController(); + final RxBool isSearchingEmployees = false.obs; + + // Attachments + final RxList attachments = [].obs; + final RxList> existingAttachments = + >[].obs; + final isProcessingAttachment = false.obs; + + // Payment mode + final selectedPaymentMode = Rxn(); + + // Text controllers for form + final TextEditingController locationController = TextEditingController(); + final TextEditingController gstNumberController = TextEditingController(); + + // Form submission state + final RxBool isSubmitting = false.obs; late String _requestId; + bool _isInitialized = false; + RxBool paymentSheetOpened = false.obs; + final ImagePicker _picker = ImagePicker(); + /// Initialize controller void init(String requestId) { + if (_isInitialized) return; + _isInitialized = true; + _requestId = requestId; - fetchPaymentRequestDetail(); + + // Fetch payment request details + employees concurrently + Future.wait([ + fetchPaymentRequestDetail(), + fetchAllEmployees(), + fetchPaymentModes(), + ]); } - Future fetchPaymentRequestDetail() async { + /// Generic API wrapper for error handling + Future _apiCallWrapper( + Future Function() apiCall, String operationName) async { + isLoading.value = true; + errorMessage.value = ''; 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"; - } + final result = await apiCall(); + return result; } catch (e) { - errorMessage.value = "Error fetching payment request details: $e"; + errorMessage.value = 'Error during $operationName: $e'; + showAppSnackbar( + title: 'Error', + message: errorMessage.value, + type: SnackbarType.error); + return null; } finally { isLoading.value = false; } } + + /// Fetch payment request details + Future fetchPaymentRequestDetail() async { + isLoading.value = true; + try { + final response = + await ApiService.getExpensePaymentRequestDetailApi(_requestId); + if (response != null) { + paymentRequest.value = response.data; + } else { + errorMessage.value = "Failed to fetch payment request details"; + showAppSnackbar( + title: 'Error', + message: errorMessage.value, + type: SnackbarType.error, + ); + } + } catch (e) { + errorMessage.value = "Error fetching payment request details: $e"; + showAppSnackbar( + title: 'Error', + message: errorMessage.value, + type: SnackbarType.error, + ); + } finally { + isLoading.value = false; + } + } + + /// Pick files from gallery or file picker + Future pickAttachments() async { + try { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'], + allowMultiple: true, + ); + if (result != null) { + attachments.addAll( + result.paths.whereType().map(File.new), + ); + } + } catch (e) { + _errorSnackbar("Attachment error: $e"); + } + } + + void removeAttachment(File file) => attachments.remove(file); + + Future pickFromCamera() async { + try { + final pickedFile = await _picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) { + isProcessingAttachment.value = true; + File imageFile = File(pickedFile.path); + + File timestampedFile = await TimestampImageHelper.addTimestamp( + imageFile: imageFile, + ); + + attachments.add(timestampedFile); + attachments.refresh(); + } + } catch (e) { + _errorSnackbar("Camera error: $e"); + } finally { + isProcessingAttachment.value = false; + } + } + + // --- Location --- + final RxBool isFetchingLocation = false.obs; + + Future fetchCurrentLocation() async { + isFetchingLocation.value = true; + try { + if (!await _ensureLocationPermission()) return; + + final position = await Geolocator.getCurrentPosition(); + final placemarks = + await placemarkFromCoordinates(position.latitude, position.longitude); + + locationController.text = placemarks.isNotEmpty + ? [ + placemarks.first.name, + placemarks.first.street, + placemarks.first.locality, + placemarks.first.administrativeArea, + placemarks.first.country, + ].where((e) => e?.isNotEmpty == true).join(", ") + : "${position.latitude}, ${position.longitude}"; + } catch (e) { + _errorSnackbar("Location error: $e"); + } finally { + isFetchingLocation.value = false; + } + } + + Future _ensureLocationPermission() async { + var permission = await Geolocator.checkPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + permission = await Geolocator.requestPermission(); + if (permission == LocationPermission.denied || + permission == LocationPermission.deniedForever) { + _errorSnackbar("Location permission denied."); + return false; + } + } + if (!await Geolocator.isLocationServiceEnabled()) { + _errorSnackbar("Location service disabled."); + return false; + } + return true; + } + + /// Fetch all employees + Future fetchAllEmployees() async { + final response = await _apiCallWrapper( + () => ApiService.getAllEmployees(), "fetch all employees"); + + if (response != null && response.isNotEmpty) { + try { + allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e))); + } catch (e) { + errorMessage.value = 'Failed to parse employee data: $e'; + showAppSnackbar( + title: 'Error', + message: errorMessage.value, + type: SnackbarType.error); + } + } else { + allEmployees.clear(); + } + } + + /// Fetch payment modes + Future fetchPaymentModes() async { + isLoading.value = true; + try { + final paymentModesData = await ApiService.getMasterPaymentModes(); + if (paymentModesData is List) { + paymentModes.value = + paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList(); + } else { + paymentModes.clear(); + showAppSnackbar( + title: 'Error', + message: 'Failed to fetch payment modes', + type: SnackbarType.error); + } + } catch (e) { + paymentModes.clear(); + showAppSnackbar( + title: 'Error', + message: 'Error fetching payment modes: $e', + type: SnackbarType.error); + } finally { + isLoading.value = false; + } + } + + /// Search employees + Future searchEmployees(String query) async { + if (query.trim().isEmpty) { + employeeSearchResults.clear(); + return; + } + + isSearchingEmployees.value = true; + try { + final data = + await ApiService.searchEmployeesBasic(searchString: query.trim()); + employeeSearchResults.assignAll( + (data ?? []).map((e) => EmployeeModel.fromJson(e)), + ); + } catch (e) { + employeeSearchResults.clear(); + } finally { + isSearchingEmployees.value = false; + } + } + + /// Update payment request status + Future updatePaymentRequestStatus({ + required String statusId, + required String comment, + String? paidTransactionId, + String? paidById, + DateTime? paidAt, + double? baseAmount, + double? taxAmount, + String? tdsPercentage, + }) async { + isLoading.value = true; + try { + final success = await ApiService.updateExpensePaymentRequestStatusApi( + paymentRequestId: _requestId, + statusId: statusId, + comment: comment, + paidTransactionId: paidTransactionId, + paidById: paidById, + paidAt: paidAt, + baseAmount: baseAmount, + taxAmount: taxAmount, + tdsPercentage: tdsPercentage, + ); + + if (success) { + showAppSnackbar( + title: 'Success', + message: 'Payment submitted successfully', + type: SnackbarType.success); + await fetchPaymentRequestDetail(); + } else { + showAppSnackbar( + title: 'Error', + message: 'Failed to update status. Please try again.', + type: SnackbarType.error); + } + + return success; + } catch (e) { + showAppSnackbar( + title: 'Error', + message: 'Something went wrong: $e', + type: SnackbarType.error); + return false; + } finally { + isLoading.value = false; + } + } + + // --- Snackbar Helper --- + void _errorSnackbar(String msg, [String title = "Error"]) { + showAppSnackbar(title: title, message: msg, type: SnackbarType.error); + } + + // --- Payment Mode Selection --- + void selectPaymentMode(PaymentModeModel mode) { + selectedPaymentMode.value = mode; + } + + // --- Submit Expense --- + Future submitExpense() async { + if (selectedPaymentMode.value == null) return false; + + isSubmitting.value = true; + try { + // Prepare attachments with all required fields + final attachmentsPayload = attachments.map((file) { + final bytes = file.readAsBytesSync(); + final mimeType = + lookupMimeType(file.path) ?? 'application/octet-stream'; + + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": mimeType, + "description": "", + "fileSize": bytes.length, + "isActive": true, + }; + }).toList(); + + // Call API + return await ApiService.createExpenseForPRApi( + paymentModeId: selectedPaymentMode.value!.id, + location: locationController.text, + gstNumber: gstNumberController.text, + paymentRequestId: _requestId, + billAttachments: attachmentsPayload, + ); + } finally { + isSubmitting.value = false; + } + } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index f88215d..b8181a2 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -22,7 +22,13 @@ class ApiEndpoints { static const String getExpensePaymentRequestDetails = "/Expense/get/payment-request/details"; static const String getExpensePaymentRequestFilter = - "/Expense/get/payment-request/details"; + "/Expense/payment-request/filter"; + static const String updateExpensePaymentRequestStatus = + "/Expense/payment-request/action"; + static const String createExpenseforPR = + "/Expense/payment-request/expense/create"; + + 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 faa6153..a54d398 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -297,6 +297,114 @@ class ApiService { } } + /// Create Expense for Payment Request + static Future createExpenseForPRApi({ + required String paymentModeId, + required String location, + required String gstNumber, + required String paymentRequestId, + List> billAttachments = const [], + }) async { + const endpoint = ApiEndpoints.createExpenseforPR; + + final body = { + "paymentModeId": paymentModeId, + "location": location, + "gstNumber": gstNumber, + "paymentRequestId": paymentRequestId, + "billAttachments": billAttachments, + }; + + try { + final response = await _postRequest(endpoint, body); + + if (response == null) { + logSafe("Create Expense for PR failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Create Expense for PR response status: ${response.statusCode}"); + logSafe("Create Expense for PR response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe( + "Expense for Payment Request created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create Expense for Payment Request: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during createExpenseForPRApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } + } + + /// Update Expense Payment Request Status + static Future updateExpensePaymentRequestStatusApi({ + required String paymentRequestId, + required String statusId, + required String comment, + String? paidTransactionId, + String? paidById, + DateTime? paidAt, + double? baseAmount, + double? taxAmount, + String? tdsPercentage, + }) async { + const endpoint = ApiEndpoints.updateExpensePaymentRequestStatus; + logSafe("Updating Payment Request Status for ID: $paymentRequestId"); + + final body = { + "paymentRequestId": paymentRequestId, + "statusId": statusId, + "comment": comment, + "paidTransactionId": paidTransactionId, + "paidById": paidById, + "paidAt": paidAt?.toIso8601String(), + "baseAmount": baseAmount, + "taxAmount": taxAmount, + "tdsPercentage": tdsPercentage ?? "0", + }; + + try { + final response = await _postRequest(endpoint, body); + + if (response == null) { + logSafe("Update Payment Request Status failed: null response", + level: LogLevel.error); + return false; + } + + logSafe( + "Update Payment Request Status response: ${response.statusCode} -> ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Payment Request status updated successfully!"); + return true; + } else { + logSafe( + "Failed to update Payment Request Status: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during updateExpensePaymentRequestStatusApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } + } + /// Get Expense Payment Request Detail by ID static Future getExpensePaymentRequestDetailApi( String paymentRequestId) async { diff --git a/lib/helpers/widgets/expense/expense_form_widgets.dart b/lib/helpers/widgets/expense/expense_form_widgets.dart index 137fa0c..d383ac9 100644 --- a/lib/helpers/widgets/expense/expense_form_widgets.dart +++ b/lib/helpers/widgets/expense/expense_form_widgets.dart @@ -67,6 +67,7 @@ class CustomTextField extends StatelessWidget { final int maxLines; final TextInputType keyboardType; final String? Function(String?)? validator; + final Widget? suffixIcon; const CustomTextField({ required this.controller, @@ -74,8 +75,9 @@ class CustomTextField extends StatelessWidget { this.maxLines = 1, this.keyboardType = TextInputType.text, this.validator, + this.suffixIcon, Key? key, - }) : super(key: key); + }) ; @override Widget build(BuildContext context) { @@ -91,6 +93,7 @@ class CustomTextField extends StatelessWidget { fillColor: Colors.grey.shade100, contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + suffixIcon: suffixIcon, border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Colors.grey.shade300), diff --git a/lib/model/finance/make_expense_bottom_sheet.dart b/lib/model/finance/make_expense_bottom_sheet.dart new file mode 100644 index 0000000..5652ff6 --- /dev/null +++ b/lib/model/finance/make_expense_bottom_sheet.dart @@ -0,0 +1,222 @@ +// create_expense_bottom_sheet.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_spacing.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart'; +import 'package:marco/helpers/utils/validators.dart'; +import 'package:marco/controller/finance/payment_request_detail_controller.dart'; + +Future showCreateExpenseBottomSheet() { + return Get.bottomSheet( + _CreateExpenseBottomSheet(), + isScrollControlled: true, + ); +} + +class _CreateExpenseBottomSheet extends StatefulWidget { + @override + State<_CreateExpenseBottomSheet> createState() => + _CreateExpenseBottomSheetState(); +} + +class _CreateExpenseBottomSheetState extends State<_CreateExpenseBottomSheet> { + final controller = Get.put(PaymentRequestDetailController()); + final _formKey = GlobalKey(); + final _paymentModeDropdownKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Obx( + () => Form( + key: _formKey, + child: BaseBottomSheet( + title: "Create New Expense", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () async { + if (_formKey.currentState!.validate() && _validateSelections()) { + final success = await controller.submitExpense(); + if (success) { + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } + } + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropdown( + "Payment Mode*", + Icons.payment_outlined, + controller.selectedPaymentMode.value?.name ?? "Select Mode", + controller.paymentModes, + (p) => p.name, + controller.selectPaymentMode, + key: _paymentModeDropdownKey, + ), + _gap(), + _buildTextField( + "GST Number", + Icons.receipt_outlined, + controller.gstNumberController, + hint: "Enter GST Number", + validator: null, // optional field + ), + _gap(), + _buildTextField( + "Location*", + Icons.location_on_outlined, + controller.locationController, + hint: "Enter location", + validator: Validators.requiredField, + keyboardType: TextInputType.text, + suffixIcon: IconButton( + icon: const Icon(Icons.my_location_outlined), + onPressed: () async { + await controller.fetchCurrentLocation(); + }, + ), + ), + _gap(), + _buildAttachmentField(), + ], + ), + ), + ), + ), + ); + } + + Widget _buildDropdown(String title, IconData icon, String value, + List options, String Function(T) getLabel, ValueChanged onSelected, + {required GlobalKey key}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle(icon: icon, title: title, requiredField: true), + MySpacing.height(6), + DropdownTile( + key: key, + title: value, + onTap: () => _showOptionList(options, getLabel, onSelected, key), + ), + ], + ); + } + + Widget _buildTextField( + String title, + IconData icon, + TextEditingController controller, { + String? hint, + FormFieldValidator? validator, + TextInputType? keyboardType, + Widget? suffixIcon, // add this + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: icon, title: title, requiredField: validator != null), + MySpacing.height(6), + CustomTextField( + controller: controller, + hint: hint ?? "", + validator: validator, + keyboardType: keyboardType ?? TextInputType.text, + suffixIcon: suffixIcon, + ), + ], + ); + } + + Widget _buildAttachmentField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.attach_file, + title: "Upload Bill*", + requiredField: true), + MySpacing.height(6), + Obx(() { + if (controller.isProcessingAttachment.value) { + return Center( + child: Column( + children: const [ + CircularProgressIndicator(), + SizedBox(height: 8), + Text("Processing file, please wait..."), + ], + ), + ); + } + return AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + controller: controller, + onAdd: controller.pickAttachments, + ); + }), + ], + ); + } + + Widget _gap([double h = 16]) => MySpacing.height(h); + + Future _showOptionList(List options, String Function(T) getLabel, + ValueChanged onSelected, GlobalKey key) async { + if (options.isEmpty) { + _showError("No options available"); + return; + } + + final RenderBox button = + key.currentContext!.findRenderObject() as RenderBox; + final RenderBox overlay = + Overlay.of(context).context.findRenderObject() as RenderBox; + final position = button.localToGlobal(Offset.zero, ancestor: overlay); + + final selected = await showMenu( + context: context, + position: RelativeRect.fromLTRB( + position.dx, + position.dy + button.size.height, + overlay.size.width - position.dx - button.size.width, + 0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + items: options + .map( + (opt) => PopupMenuItem(value: opt, child: Text(getLabel(opt)))) + .toList(), + ); + + if (selected != null) onSelected(selected); + } + + bool _validateSelections() { + if (controller.selectedPaymentMode.value == null) { + return _showError("Please select a payment mode"); + } + if (controller.locationController.text.trim().isEmpty) { + return _showError("Please enter location"); + } + if (controller.attachments.isEmpty) { + return _showError("Please upload bill"); + } + return true; + } + + bool _showError(String msg) { + showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); + return false; + } +} diff --git a/lib/model/finance/payment_mode_response_model.dart b/lib/model/finance/payment_mode_response_model.dart new file mode 100644 index 0000000..3af3a8d --- /dev/null +++ b/lib/model/finance/payment_mode_response_model.dart @@ -0,0 +1,65 @@ +class PaymentModeResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + PaymentModeResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory PaymentModeResponse.fromJson(Map json) { + return PaymentModeResponse( + success: json['success'] as bool, + message: json['message'] as String, + data: (json['data'] as List) + .map((item) => PaymentModeData.fromJson(item)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] as int, + timestamp: DateTime.parse(json['timestamp'] as String), + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class PaymentModeData { + final String id; + final String name; + final String description; + + PaymentModeData({ + required this.id, + required this.name, + required this.description, + }); + + factory PaymentModeData.fromJson(Map json) { + return PaymentModeData( + id: json['id'] as String, + name: json['name'] as String, + description: json['description'] as String, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'description': description, + }; +} diff --git a/lib/model/finance/payment_request_details_model.dart b/lib/model/finance/payment_request_details_model.dart index e9f6b06..a1c9820 100644 --- a/lib/model/finance/payment_request_details_model.dart +++ b/lib/model/finance/payment_request_details_model.dart @@ -19,7 +19,9 @@ class PaymentRequestDetail { PaymentRequestDetail( success: json['success'], message: json['message'], - data: json['data'] != null ? PaymentRequestData.fromJson(json['data']) : null, + data: json['data'] != null + ? PaymentRequestData.fromJson(json['data']) + : null, errors: json['errors'], statusCode: json['statusCode'], timestamp: DateTime.parse(json['timestamp']), @@ -52,15 +54,15 @@ class PaymentRequestData { ExpenseStatus expenseStatus; String? paidTransactionId; DateTime? paidAt; - String? paidBy; + User? paidBy; bool isAdvancePayment; DateTime createdAt; - CreatedBy createdBy; + User createdBy; DateTime updatedAt; - dynamic updatedBy; + User? updatedBy; List nextStatus; - List updateLogs; - List attachments; + List updateLogs; + List attachments; bool isActive; bool isExpenseCreated; @@ -103,8 +105,12 @@ class PaymentRequestData { 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, + 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'], @@ -112,17 +118,23 @@ class PaymentRequestData { expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']), paidTransactionId: json['paidTransactionId'], paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null, - paidBy: json['paidBy'], + paidBy: + json['paidBy'] != null ? User.fromJson(json['paidBy']) : null, isAdvancePayment: json['isAdvancePayment'], createdAt: DateTime.parse(json['createdAt']), - createdBy: CreatedBy.fromJson(json['createdBy']), + createdBy: User.fromJson(json['createdBy']), updatedAt: DateTime.parse(json['updatedAt']), - updatedBy: json['updatedBy'], + updatedBy: + json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, nextStatus: (json['nextStatus'] as List) .map((e) => NextStatus.fromJson(e)) .toList(), - updateLogs: json['updateLogs'] ?? [], - attachments: json['attachments'] ?? [], + updateLogs: (json['updateLogs'] as List) + .map((e) => UpdateLog.fromJson(e)) + .toList(), + attachments: (json['attachments'] as List) + .map((e) => Attachment.fromJson(e)) + .toList(), isActive: json['isActive'], isExpenseCreated: json['isExpenseCreated'], ); @@ -144,15 +156,15 @@ class PaymentRequestData { 'expenseStatus': expenseStatus.toJson(), 'paidTransactionId': paidTransactionId, 'paidAt': paidAt?.toIso8601String(), - 'paidBy': paidBy, + 'paidBy': paidBy?.toJson(), 'isAdvancePayment': isAdvancePayment, 'createdAt': createdAt.toIso8601String(), 'createdBy': createdBy.toJson(), 'updatedAt': updatedAt.toIso8601String(), - 'updatedBy': updatedBy, + 'updatedBy': updatedBy?.toJson(), 'nextStatus': nextStatus.map((e) => e.toJson()).toList(), - 'updateLogs': updateLogs, - 'attachments': attachments, + 'updateLogs': updateLogs.map((e) => e.toJson()).toList(), + 'attachments': attachments.map((e) => e.toJson()).toList(), 'isActive': isActive, 'isExpenseCreated': isExpenseCreated, }; @@ -196,15 +208,10 @@ class Project { Project({required this.id, required this.name}); - factory Project.fromJson(Map json) => Project( - id: json['id'], - name: json['name'], - ); + factory Project.fromJson(Map json) => + Project(id: json['id'], name: json['name']); - Map toJson() => { - 'id': id, - 'name': name, - }; + Map toJson() => {'id': id, 'name': name}; } class ExpenseCategory { @@ -222,7 +229,8 @@ class ExpenseCategory { required this.description, }); - factory ExpenseCategory.fromJson(Map json) => ExpenseCategory( + factory ExpenseCategory.fromJson(Map json) => + ExpenseCategory( id: json['id'], name: json['name'], noOfPersonsRequired: json['noOfPersonsRequired'], @@ -281,7 +289,7 @@ class ExpenseStatus { }; } -class CreatedBy { +class User { String id; String firstName; String lastName; @@ -290,7 +298,7 @@ class CreatedBy { String jobRoleId; String jobRoleName; - CreatedBy({ + User({ required this.id, required this.firstName, required this.lastName, @@ -300,7 +308,7 @@ class CreatedBy { required this.jobRoleName, }); - factory CreatedBy.fromJson(Map json) => CreatedBy( + factory User.fromJson(Map json) => User( id: json['id'], firstName: json['firstName'], lastName: json['lastName'], @@ -362,3 +370,75 @@ class NextStatus { 'isSystem': isSystem, }; } + +class UpdateLog { + String id; + ExpenseStatus status; + ExpenseStatus nextStatus; + String comment; + DateTime updatedAt; + User updatedBy; + + UpdateLog({ + required this.id, + required this.status, + required this.nextStatus, + required this.comment, + required this.updatedAt, + required this.updatedBy, + }); + + factory UpdateLog.fromJson(Map json) => UpdateLog( + id: json['id'], + status: ExpenseStatus.fromJson(json['status']), + nextStatus: ExpenseStatus.fromJson(json['nextStatus']), + comment: json['comment'], + updatedAt: DateTime.parse(json['updatedAt']), + updatedBy: User.fromJson(json['updatedBy']), + ); + + Map toJson() => { + 'id': id, + 'status': status.toJson(), + 'nextStatus': nextStatus.toJson(), + 'comment': comment, + 'updatedAt': updatedAt.toIso8601String(), + 'updatedBy': updatedBy.toJson(), + }; +} + +class Attachment { + String id; + String fileName; + String url; + String? thumbUrl; + int fileSize; + String contentType; + + Attachment({ + required this.id, + required this.fileName, + required this.url, + this.thumbUrl, + required this.fileSize, + required this.contentType, + }); + + factory Attachment.fromJson(Map json) => Attachment( + id: json['id'], + fileName: json['fileName'], + url: json['url'], + thumbUrl: json['thumbUrl'], + fileSize: json['fileSize'], + contentType: json['contentType'], + ); + + Map toJson() => { + 'id': id, + 'fileName': fileName, + 'url': url, + 'thumbUrl': thumbUrl, + 'fileSize': fileSize, + 'contentType': contentType, + }; +} diff --git a/lib/model/finance/payment_request_rembursement_bottom_sheet.dart b/lib/model/finance/payment_request_rembursement_bottom_sheet.dart new file mode 100644 index 0000000..e212d95 --- /dev/null +++ b/lib/model/finance/payment_request_rembursement_bottom_sheet.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; + +import 'package:marco/controller/finance/payment_request_detail_controller.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/base_bottom_sheet.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/expense/employee_selector_bottom_sheet.dart'; + +class UpdatePaymentRequestWithReimbursement extends StatefulWidget { + final String expenseId; + final String statusId; + final void Function() onClose; + + const UpdatePaymentRequestWithReimbursement({ + super.key, + required this.expenseId, + required this.onClose, + required this.statusId, + }); + + @override + State createState() => + _UpdatePaymentRequestWithReimbursement(); +} + +class _UpdatePaymentRequestWithReimbursement + extends State { + final PaymentRequestDetailController controller = + Get.find(); + + final TextEditingController commentCtrl = TextEditingController(); + final TextEditingController txnCtrl = TextEditingController(); + final TextEditingController tdsCtrl = TextEditingController(text: '0'); + final TextEditingController baseAmountCtrl = TextEditingController(); + final TextEditingController taxAmountCtrl = TextEditingController(); + final RxString dateStr = ''.obs; + + @override + void dispose() { + commentCtrl.dispose(); + txnCtrl.dispose(); + tdsCtrl.dispose(); + baseAmountCtrl.dispose(); + taxAmountCtrl.dispose(); + super.dispose(); + } + + /// Employee selection bottom sheet + void _showEmployeeList() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + backgroundColor: Colors.transparent, + builder: (_) => ReusableEmployeeSelectorBottomSheet( + searchController: controller.employeeSearchController, + searchResults: controller.employeeSearchResults, + isSearching: controller.isSearchingEmployees, + onSearch: controller.searchEmployees, + onSelect: (emp) => controller.selectedReimbursedBy.value = emp, + ), + ); + + // Optional cleanup + controller.employeeSearchController.clear(); + controller.employeeSearchResults.clear(); + } + + InputDecoration _inputDecoration(String hint) { + return InputDecoration( + hintText: hint, + hintStyle: MyTextStyle.bodySmall(xMuted: true), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5), + ), + contentPadding: MySpacing.all(16), + ); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + return BaseBottomSheet( + title: "Proceed Payment", + isSubmitting: controller.isLoading.value, + onCancel: () { + widget.onClose(); + Navigator.pop(context); + }, + onSubmit: () async { + // Mandatory fields validation + if (commentCtrl.text.trim().isEmpty || + txnCtrl.text.trim().isEmpty || + dateStr.value.isEmpty || + baseAmountCtrl.text.trim().isEmpty || + taxAmountCtrl.text.trim().isEmpty) { + showAppSnackbar( + title: "Incomplete", + message: "Please fill all mandatory fields", + type: SnackbarType.warning, + ); + return; + } + + try { + // Parse inputs + final parsedDate = + DateFormat('dd-MM-yyyy').parse(dateStr.value, true); + final baseAmount = double.tryParse(baseAmountCtrl.text.trim()) ?? 0; + final taxAmount = double.tryParse(taxAmountCtrl.text.trim()) ?? 0; + final tdsPercentage = + tdsCtrl.text.trim().isEmpty ? null : tdsCtrl.text.trim(); + + // Call API + final success = await controller.updatePaymentRequestStatus( + statusId: widget.statusId, + comment: commentCtrl.text.trim(), + paidTransactionId: txnCtrl.text.trim(), + paidById: controller.selectedReimbursedBy.value?.id, + paidAt: parsedDate, + baseAmount: baseAmount, + taxAmount: taxAmount, + tdsPercentage: tdsPercentage, + ); + + // Show snackbar + showAppSnackbar( + title: success ? 'Success' : 'Error', + message: success + ? 'Payment updated successfully' + : 'Failed to update payment', + type: success ? SnackbarType.success : SnackbarType.error, + ); + + if (success) { + // Ensure bottom sheet closes and callback is called + widget.onClose(); // optional callback for parent refresh + if (Navigator.canPop(context)) { + Navigator.pop(context); + } else { + Get.close(1); // fallback if Navigator can't pop + } + } + } catch (e, st) { + print("Error updating payment: $e\n$st"); + showAppSnackbar( + title: 'Error', + message: 'Something went wrong. Please try again.', + type: SnackbarType.error, + ); + } + }, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.labelMedium("Transaction ID*"), + MySpacing.height(8), + TextField( + controller: txnCtrl, + decoration: _inputDecoration("Enter transaction ID"), + ), + MySpacing.height(16), + MyText.labelMedium("Transaction Date*"), + MySpacing.height(8), + GestureDetector( + onTap: () async { + final today = DateTime.now(); + final firstDate = DateTime(2020); + final lastDate = today; + + final picked = await showDatePicker( + context: context, + initialDate: today, + firstDate: firstDate, + lastDate: lastDate, + ); + + if (picked != null) { + dateStr.value = DateFormat('dd-MM-yyyy').format(picked); + } + }, + child: AbsorbPointer( + child: TextField( + controller: TextEditingController(text: dateStr.value), + decoration: _inputDecoration("Select Date").copyWith( + suffixIcon: const Icon(Icons.calendar_today), + ), + ), + ), + ), + MySpacing.height(16), + MyText.labelMedium("Paid By (Optional)"), + MySpacing.height(8), + GestureDetector( + onTap: _showEmployeeList, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + controller.selectedReimbursedBy.value == null + ? "Select Paid By" + : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}', + style: const TextStyle(fontSize: 14), + ), + const Icon(Icons.arrow_drop_down, size: 22), + ], + ), + ), + ), + MySpacing.height(16), + MyText.labelMedium("TDS Percentage (Optional)"), + MySpacing.height(8), + TextField( + controller: tdsCtrl, + keyboardType: TextInputType.number, + decoration: _inputDecoration("Enter TDS Percentage"), + ), + MySpacing.height(16), + MyText.labelMedium("Base Amount*"), + MySpacing.height(8), + TextField( + controller: baseAmountCtrl, + keyboardType: TextInputType.number, + decoration: _inputDecoration("Enter Base Amount"), + ), + MySpacing.height(16), + MyText.labelMedium("Tax Amount*"), + MySpacing.height(8), + TextField( + controller: taxAmountCtrl, + keyboardType: TextInputType.number, + decoration: _inputDecoration("Enter Tax Amount"), + ), + MySpacing.height(16), + MyText.labelMedium("Comment*"), + MySpacing.height(8), + TextField( + controller: commentCtrl, + decoration: _inputDecoration("Enter comment"), + ), + ], + ), + ); + }); + } +} diff --git a/lib/view/finance/payment_request_detail_screen.dart b/lib/view/finance/payment_request_detail_screen.dart index 6eb8743..f9b86a2 100644 --- a/lib/view/finance/payment_request_detail_screen.dart +++ b/lib/view/finance/payment_request_detail_screen.dart @@ -14,6 +14,12 @@ 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; +import 'package:marco/model/expense/comment_bottom_sheet.dart'; +import 'package:marco/helpers/services/storage/local_storage.dart'; +import 'package:marco/model/employees/employee_info.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart'; +import 'package:marco/model/finance/make_expense_bottom_sheet.dart'; class PaymentRequestDetailScreen extends StatefulWidget { final String paymentRequestId; @@ -29,11 +35,46 @@ class _PaymentRequestDetailScreenState extends State final controller = Get.put(PaymentRequestDetailController()); final projectController = Get.find(); final permissionController = Get.find(); + final RxBool canSubmit = false.obs; + bool _checkedPermission = false; + + EmployeeInfo? employeeInfo; @override void initState() { super.initState(); controller.init(widget.paymentRequestId); + _loadEmployeeInfo(); + } + + void _checkPermissionToSubmit(PaymentRequestData request) { + const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id; + final hasDraftNextStatus = + request.nextStatus.any((s) => s.id == draftStatusId); + + final result = isCreatedByCurrentUser && hasDraftNextStatus; + + // Debug log + print('🔐 Submit Permission Check:\n' + 'Logged-in employee: ${employeeInfo?.id}\n' + 'Created by: ${request.createdBy.id}\n' + 'Has Draft Next Status: $hasDraftNextStatus\n' + 'Can Submit: $result'); + + canSubmit.value = result; + } + + Future _loadEmployeeInfo() async { + employeeInfo = await LocalStorage.getEmployeeInfo(); + setState(() {}); + } + + Color _parseColor(String hexColor) { + String hex = hexColor.toUpperCase().replaceAll('#', ''); + if (hex.length == 6) hex = 'FF$hex'; + return Color(int.parse(hex, radix: 16)); } @override @@ -46,9 +87,7 @@ class _PaymentRequestDetailScreenState extends State if (controller.isLoading.value) { return SkeletonLoaders.paymentRequestDetailSkeletonLoader(); } - - final request = - controller.paymentRequest.value as PaymentRequestData?; + final request = controller.paymentRequest.value; if (controller.errorMessage.isNotEmpty || request == null) { return Center(child: MyText.bodyMedium("No data to display.")); } @@ -57,7 +96,11 @@ class _PaymentRequestDetailScreenState extends State onRefresh: controller.fetchPaymentRequestDetail, child: SingleChildScrollView( padding: EdgeInsets.fromLTRB( - 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom), + 12, + 12, + 12, + 60 + MediaQuery.of(context).padding.bottom, + ), child: Center( child: Container( constraints: const BoxConstraints(maxWidth: 520), @@ -71,13 +114,12 @@ class _PaymentRequestDetailScreenState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _Header(request: request), + _Header(request: request, colorParser: _parseColor), const Divider(height: 30, thickness: 1.2), - - // Move Logs here, right after header - _Logs(logs: request.updateLogs), + _Logs( + logs: request.updateLogs, + colorParser: _parseColor), const Divider(height: 30, thickness: 1.2), - _Parties(request: request), const Divider(height: 30, thickness: 1.2), _DetailsTable(request: request), @@ -93,6 +135,134 @@ class _PaymentRequestDetailScreenState extends State ); }), ), + bottomNavigationBar: Obx(() { + final request = controller.paymentRequest.value; + if (request == null || + controller.isLoading.value || + employeeInfo == null) { + return const SizedBox.shrink(); + } + + // Check permissions once + if (!_checkedPermission) { + _checkedPermission = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkPermissionToSubmit(request); + }); + } + + // Filter statuses + const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95'; + const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; + + final availableStatuses = request.nextStatus.where((status) { + if (status.id == draftStatusId) { + return employeeInfo?.id == request.createdBy.id; + } + return permissionController + .hasAnyPermission(status.permissionIds ?? []); + }).toList(); + + // If there are no next statuses, show "Create Expense" button + if (availableStatuses.isEmpty) { + return SafeArea( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey.shade300)), + ), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + backgroundColor: Colors.blue, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + showCreateExpenseBottomSheet(); + }, + child: const Text( + "Create Expense", + style: TextStyle( + color: Colors.white, fontWeight: FontWeight.bold), + ), + ), + ), + ); + } + + // Normal status buttons + return SafeArea( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Colors.grey.shade300)), + ), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + child: Wrap( + alignment: WrapAlignment.center, + spacing: 10, + runSpacing: 10, + children: availableStatuses.map((status) { + final color = _parseColor(status.color); + + return ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + backgroundColor: color, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () async { + if (status.id == reimbursementStatusId) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: + BorderRadius.vertical(top: Radius.circular(5)), + ), + builder: (ctx) => UpdatePaymentRequestWithReimbursement( + expenseId: request.paymentRequestUID, + statusId: status.id, + onClose: () {}, + ), + ); + } else { + final comment = await showCommentBottomSheet( + context, status.displayName); + if (comment == null || comment.trim().isEmpty) return; + + final success = + await controller.updatePaymentRequestStatus( + statusId: status.id, + comment: comment.trim(), + ); + + showAppSnackbar( + title: success ? 'Success' : 'Error', + message: success + ? 'Status updated successfully' + : 'Failed to update status', + type: + success ? SnackbarType.success : SnackbarType.error, + ); + + if (success) await controller.fetchPaymentRequestDetail(); + } + }, + child: Text(status.displayName, + style: const TextStyle(color: Colors.white)), + ); + }).toList(), + ), + ), + ); + }), ); } @@ -126,27 +296,25 @@ class _PaymentRequestDetailScreenState extends State 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], - ), + 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], ), - ], - ); - }, - ), + ), + ], + ); + }), ], ), ), @@ -158,28 +326,17 @@ class _PaymentRequestDetailScreenState extends State } } -// 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)); - } + final Color Function(String) colorParser; + const _Header({required this.request, required this.colorParser}); @override Widget build(BuildContext context) { - final statusColor = parseColorFromHex(request.expenseStatus.color); - + final statusColor = colorParser(request.expenseStatus.color); return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Left side: wrap in Expanded to prevent overflow Expanded( child: Row( children: [ @@ -199,8 +356,6 @@ class _Header extends StatelessWidget { ], ), ), - - // Right side: Status Chip Container( decoration: BoxDecoration( color: statusColor.withOpacity(0.15), @@ -211,7 +366,6 @@ class _Header extends StatelessWidget { 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, @@ -228,185 +382,20 @@ class _Header extends StatelessWidget { } } -// 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}); + final List logs; + final Color Function(String) colorParser; + const _Logs({required this.logs, required this.colorParser}); - // 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); + DateTime _parseTimestamp(DateTime ts) => ts; @override Widget build(BuildContext context) { - if (logs.isEmpty) return MyText.bodyMedium('No Timeline', color: Colors.grey); + if (logs.isEmpty) { + return MyText.bodyMedium('No Timeline', color: Colors.grey); + } final reversedLogs = logs.reversed.toList(); - return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -416,32 +405,24 @@ class _Logs extends StatelessWidget { 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 log = reversedLogs[index]; - final nextStatusMap = log['nextStatus'] ?? {}; - final nextStatusName = nextStatusMap['name'] ?? ''; + final status = log.status.name; + final description = log.status.description; + final comment = log.comment; + final nextStatusName = log.nextStatus.name; - final updatedBy = log['updatedBy'] ?? {}; + final updatedBy = log.updatedBy; final initials = - "${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}" - "${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}"; - final name = - "${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}"; + '${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}' + '${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}'; + final name = '${updatedBy.firstName} ${updatedBy.lastName}'; - final timestamp = parseTimestamp(log['updatedAt']); + 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; + final statusColor = colorParser(log.status.color); + final nextStatusColor = colorParser(log.nextStatus.color); return TimelineTile( alignment: TimelineAlign.start, @@ -451,10 +432,8 @@ class _Logs extends StatelessWidget { width: 16, height: 16, indicator: Container( - decoration: BoxDecoration( - shape: BoxShape.circle, - color: statusColor, - ), + decoration: + BoxDecoration(shape: BoxShape.circle, color: statusColor), ), ), beforeLineStyle: @@ -464,20 +443,14 @@ class _Logs extends StatelessWidget { 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, - ), + MyText.bodyMedium(status, + fontWeight: 600, color: statusColor), + MyText.bodySmall(timeAgo, + color: Colors.grey[600], + textAlign: TextAlign.right), ], ), if (description.isNotEmpty) ...[ @@ -502,11 +475,8 @@ class _Logs extends StatelessWidget { ), const SizedBox(width: 6), Expanded( - child: MyText.bodySmall( - name, - overflow: TextOverflow.ellipsis, - ), - ), + child: MyText.bodySmall(name, + overflow: TextOverflow.ellipsis)), if (nextStatusName.isNotEmpty) Container( padding: const EdgeInsets.symmetric( @@ -515,11 +485,8 @@ class _Logs extends StatelessWidget { color: nextStatusColor.withOpacity(0.15), borderRadius: BorderRadius.circular(4), ), - child: MyText.bodySmall( - nextStatusName, - fontWeight: 600, - color: nextStatusColor, - ), + child: MyText.bodySmall(nextStatusName, + fontWeight: 600, color: nextStatusColor), ), ], ), @@ -533,3 +500,146 @@ class _Logs extends StatelessWidget { ); } } + +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'), + ], + ); + } +} + +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"), + ], + ); + } +} + +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]; + final isImage = doc.contentType.startsWith('image/'); + + return GestureDetector( + onTap: () async { + final imageDocs = documents + .where((d) => d.contentType.startsWith('image/')) + .toList(); + final initialIndex = + imageDocs.indexWhere((d) => d.id == doc.id); + + if (isImage && imageDocs.isNotEmpty && initialIndex != -1) { + showDialog( + context: context, + builder: (_) => ImageViewerDialog( + imageSources: imageDocs.map((e) => e.url).toList(), + initialIndex: initialIndex, + ), + ); + } else { + final Uri url = Uri.parse(doc.url); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + showAppSnackbar( + title: 'Error', + message: 'Could not open document.', + type: SnackbarType.error, + ); + } + } + }, + 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(isImage ? 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, + ), + ), + ], + ), + ), + ); + }, + ), + ], + ); + } +} + +// Utility widget for 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), + ), + ], + ), + ); diff --git a/lib/view/finance/payment_request_screen.dart b/lib/view/finance/payment_request_screen.dart index 57163c8..84697ec 100644 --- a/lib/view/finance/payment_request_screen.dart +++ b/lib/view/finance/payment_request_screen.dart @@ -268,6 +268,18 @@ class _PaymentRequestMainScreenState extends State final list = filteredList(isHistory: isHistory); + // Single ScrollController for this list + final scrollController = ScrollController(); + + // Load more when reaching near bottom + scrollController.addListener(() { + if (scrollController.position.pixels >= + scrollController.position.maxScrollExtent - 100 && + !paymentController.isLoading.value) { + paymentController.loadMorePaymentRequests(); + } + }); + return RefreshIndicator( onRefresh: _refreshPaymentRequests, child: list.isEmpty @@ -288,11 +300,22 @@ class _PaymentRequestMainScreenState extends State ], ) : ListView.separated( + controller: scrollController, // attach controller padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), - itemCount: list.length, + itemCount: list.length + 1, // extra item for loading separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20), itemBuilder: (context, index) { + if (index == list.length) { + // Show loading indicator at bottom + return Obx(() => paymentController.isLoading.value + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator()), + ) + : const SizedBox.shrink()); + } + final item = list[index]; return _buildPaymentRequestTile(item); }, @@ -349,7 +372,7 @@ class _PaymentRequestMainScreenState extends State borderRadius: BorderRadius.circular(5), ), child: MyText.bodySmall( - item.expenseStatus.displayName, + item.expenseStatus.name, color: Colors.white, fontWeight: 500, ),