diff --git a/lib/controller/finance/add_payment_request_controller.dart b/lib/controller/finance/add_payment_request_controller.dart new file mode 100644 index 0000000..0381981 --- /dev/null +++ b/lib/controller/finance/add_payment_request_controller.dart @@ -0,0 +1,296 @@ +// payment_request_controller.dart +import 'dart:io'; +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; +import 'package:intl/intl.dart'; + +import 'package:marco/helpers/services/api_service.dart'; +import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/widgets/my_snackbar.dart'; +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 AddPaymentRequestController extends GetxController { + // Loading States + final isLoadingPayees = false.obs; + final isLoadingCategories = false.obs; + final isLoadingCurrencies = false.obs; + final isProcessingAttachment = false.obs; + final isSubmitting = false.obs; + + // Data Lists + final payees = [].obs; + final categories = [].obs; + final currencies = [].obs; + final globalProjects = >[].obs; + + // Selected Values + final selectedProject = Rx?>(null); + final selectedCategory = Rx(null); + final selectedPayee = ''.obs; + final selectedCurrency = Rx(null); + final isAdvancePayment = false.obs; + final selectedDueDate = Rx(null); + + // Text Controllers + final titleController = TextEditingController(); + final dueDateController = TextEditingController(); + final amountController = TextEditingController(); + final descriptionController = TextEditingController(); + + // Attachments + final attachments = [].obs; + final existingAttachments = >[].obs; + final ImagePicker _picker = ImagePicker(); + + @override + void onInit() { + super.onInit(); + fetchAllMasterData(); + fetchGlobalProjects(); + } + + @override + void onClose() { + titleController.dispose(); + dueDateController.dispose(); + amountController.dispose(); + descriptionController.dispose(); + super.onClose(); + } + + /// Fetch all master data concurrently + Future fetchAllMasterData() async { + await Future.wait([ + _fetchData( + payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees), + _fetchData(categories, ApiService.getMasterExpenseCategoriesApi, + isLoadingCategories), + _fetchData( + currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies), + ]); + } + + /// Generic fetch handler + Future _fetchData( + RxList list, Future Function() apiCall, RxBool loader) async { + try { + loader.value = true; + final response = await apiCall(); + if (response != null && response.data.isNotEmpty) { + list.value = response.data; + } else { + list.clear(); + } + } catch (e) { + logSafe("Error fetching data: $e", level: LogLevel.error); + list.clear(); + } finally { + loader.value = false; + } + } + + /// Fetch projects + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + globalProjects.value = (response ?? []) + .map>((e) => { + 'id': e['id']?.toString() ?? '', + 'name': e['name']?.toString().trim() ?? '', + }) + .where((p) => p['id']!.isNotEmpty && p['name']!.isNotEmpty) + .toList(); + } catch (e) { + logSafe("Error fetching projects: $e", level: LogLevel.error); + globalProjects.clear(); + } + } + + /// Pick due date + Future pickDueDate(BuildContext context) async { + final pickedDate = await showDatePicker( + context: context, + initialDate: selectedDueDate.value ?? DateTime.now(), + firstDate: DateTime(DateTime.now().year - 5), + lastDate: DateTime(DateTime.now().year + 5), + ); + + if (pickedDate != null) { + selectedDueDate.value = pickedDate; + dueDateController.text = DateFormat('dd MMM yyyy').format(pickedDate); + } + } + + /// Generic file picker for multiple sources + Future pickAttachments( + {bool fromGallery = false, bool fromCamera = false}) async { + try { + if (fromCamera) { + final pickedFile = await _picker.pickImage(source: ImageSource.camera); + if (pickedFile != null) { + isProcessingAttachment.value = true; + final timestamped = await TimestampImageHelper.addTimestamp( + imageFile: File(pickedFile.path)); + attachments.add(timestamped); + } + } else if (fromGallery) { + final pickedFile = await _picker.pickImage(source: ImageSource.gallery); + if (pickedFile != null) attachments.add(File(pickedFile.path)); + } else { + final result = await FilePicker.platform + .pickFiles(type: FileType.any, allowMultiple: true); + if (result != null && result.paths.isNotEmpty) + attachments.addAll(result.paths.whereType().map(File.new)); + } + attachments.refresh(); + } catch (e) { + _errorSnackbar("Attachment error: $e"); + } finally { + isProcessingAttachment.value = false; + } + } + + /// Selection handlers + void selectProject(Map project) => + selectedProject.value = project; + void selectCategory(ExpenseCategory category) => + selectedCategory.value = category; + void selectPayee(String payee) => selectedPayee.value = payee; + void selectCurrency(Currency currency) => selectedCurrency.value = currency; + + void addAttachment(File file) => attachments.add(file); + void removeAttachment(File file) => attachments.remove(file); + + /// Build attachment payload + Future>> buildAttachmentPayload() async { + final existingPayload = existingAttachments + .map((e) => { + "documentId": e['documentId'], + "fileName": e['fileName'], + "contentType": e['contentType'] ?? 'application/octet-stream', + "fileSize": e['fileSize'] ?? 0, + "description": "", + "url": e['url'], + "isActive": e['isActive'] ?? true, + "base64Data": "", + }) + .toList(); + + final newPayload = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + return { + "fileName": file.path.split('/').last, + "base64Data": base64Encode(bytes), + "contentType": lookupMimeType(file.path) ?? 'application/octet-stream', + "fileSize": await file.length(), + "description": "", + }; + })); + + return [...existingPayload, ...newPayload]; + } + + /// Submit payment request (Project API style) + Future submitPaymentRequest() async { + if (isSubmitting.value) return false; + + try { + isSubmitting.value = true; + + // Validate form + if (!_validateForm()) return false; + + // Build attachment payload + final billAttachments = await buildAttachmentPayload(); + + final payload = { + "title": titleController.text.trim(), + "projectId": selectedProject.value?['id'] ?? '', + "expenseCategoryId": selectedCategory.value?.id ?? '', + "amount": double.tryParse(amountController.text.trim()) ?? 0, + "currencyId": selectedCurrency.value?.id ?? '', + "description": descriptionController.text.trim(), + "payee": selectedPayee.value, + "dueDate": selectedDueDate.value?.toIso8601String(), + "isAdvancePayment": isAdvancePayment.value, + "billAttachments": billAttachments.map((a) { + return { + "fileName": a['fileName'], + "fileSize": a['fileSize'], + "contentType": a['contentType'], + }; + }).toList(), + }; + + logSafe("💡 Submitting Payment Request: ${jsonEncode(payload)}"); + + final success = await ApiService.createExpensePaymentRequestApi( + title: payload['title'], + projectId: payload['projectId'], + expenseCategoryId: payload['expenseCategoryId'], + amount: payload['amount'], + currencyId: payload['currencyId'], + description: payload['description'], + payee: payload['payee'], + dueDate: selectedDueDate.value, + isAdvancePayment: payload['isAdvancePayment'], + billAttachments: billAttachments, + ); + + logSafe("💡 Payment Request API Response: $success"); + + if (success == true) { + logSafe("✅ Payment request created successfully."); + return true; + } else { + return _errorSnackbar("Failed to create payment request."); + } + } catch (e, st) { + logSafe("💥 Submit Payment Request Error: $e\n$st", + level: LogLevel.error); + return _errorSnackbar("Something went wrong. Please try again later."); + } finally { + isSubmitting.value = false; + } + } + + /// Form validation + bool _validateForm() { + if (selectedProject.value == null || + selectedProject.value!['id'].toString().isEmpty) + return _errorSnackbar("Please select a project"); + if (selectedCategory.value == null) + return _errorSnackbar("Please select a category"); + if (selectedPayee.value.isEmpty) + return _errorSnackbar("Please select a payee"); + if (selectedCurrency.value == null) + return _errorSnackbar("Please select currency"); + return true; + } + + bool _errorSnackbar(String msg, [String title = "Error"]) { + showAppSnackbar(title: title, message: msg, type: SnackbarType.error); + return false; + } + + /// Clear form + void clearForm() { + titleController.clear(); + dueDateController.clear(); + amountController.clear(); + descriptionController.clear(); + selectedProject.value = null; + selectedCategory.value = null; + selectedPayee.value = ''; + selectedCurrency.value = null; + isAdvancePayment.value = false; + attachments.clear(); + existingAttachments.clear(); + } +} diff --git a/lib/controller/finance/payment_request_controller.dart b/lib/controller/finance/payment_request_controller.dart new file mode 100644 index 0000000..67e198d --- /dev/null +++ b/lib/controller/finance/payment_request_controller.dart @@ -0,0 +1,128 @@ +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) { + // First page, replace the list + paymentRequests.assignAll(response.data.data); + } else { + // Insert new data at the top for latest first + paymentRequests.insertAll(0, 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..bb3421a --- /dev/null +++ b/lib/controller/finance/payment_request_detail_controller.dart @@ -0,0 +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 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; + + // Fetch payment request details + employees concurrently + Future.wait([ + fetchPaymentRequestDetail(), + fetchAllEmployees(), + fetchPaymentModes(), + ]); + } + + /// Generic API wrapper for error handling + Future _apiCallWrapper( + Future Function() apiCall, String operationName) async { + isLoading.value = true; + errorMessage.value = ''; + try { + final result = await apiCall(); + return result; + } catch (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 1fb047d..b5ae4b0 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -3,9 +3,28 @@ class ApiEndpoints { // static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; + static const String getMasterCurrencies = "/Master/currencies/list"; + static const String getMasterExpensesCategories = + "/Master/expenses-categories"; + static const String getExpensePaymentRequestPayee = + "/Expense/payment-request/payee"; // Dashboard Module API Endpoints static const String getDashboardAttendanceOverview = "/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/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 3c83de7..6c1d273 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -26,6 +26,12 @@ import 'package:marco/model/all_organization_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart'; 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'; import 'package:marco/model/finance/advance_payment_model.dart'; class ApiService { @@ -296,6 +302,378 @@ 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 { + 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, + required String projectId, + required String expenseCategoryId, + required String currencyId, + required String payee, + required double amount, + DateTime? dueDate, + required String description, + required bool isAdvancePayment, + List> billAttachments = const [], + }) async { + const endpoint = ApiEndpoints.createExpensePaymentRequest; + + final body = { + "title": title, + "projectId": projectId, + "expenseCategoryId": expenseCategoryId, + "currencyId": currencyId, + "payee": payee, + "amount": amount, + "dueDate": dueDate?.toIso8601String(), + "description": description, + "isAdvancePayment": isAdvancePayment, + "billAttachments": billAttachments, + }; + + try { + final response = await _postRequest(endpoint, body); + if (response == null) { + logSafe("Create Payment Request failed: null response", + level: LogLevel.error); + return false; + } + + logSafe("Create Payment Request response status: ${response.statusCode}"); + logSafe("Create Payment Request response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Payment Request created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create Payment Request: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + return false; + } + } catch (e, stack) { + logSafe("Exception during createExpensePaymentRequestApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + return false; + } + } + + /// Get Master Currencies + static Future getMasterCurrenciesApi() async { + const endpoint = ApiEndpoints.getMasterCurrencies; + logSafe("Fetching Master Currencies"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Master Currencies request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = + _parseResponseForAllData(response, label: "Master Currencies"); + if (jsonResponse != null) { + return CurrencyListResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterCurrenciesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Master Expense Categories + static Future + getMasterExpenseCategoriesApi() async { + const endpoint = ApiEndpoints.getMasterExpensesCategories; + logSafe("Fetching Master Expense Categories"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Master Expense Categories request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Master Expense Categories"); + if (jsonResponse != null) { + return ExpenseCategoryResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getMasterExpenseCategoriesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + + /// Get Expense Payment Request Payee + static Future + getExpensePaymentRequestPayeeApi() async { + const endpoint = ApiEndpoints.getExpensePaymentRequestPayee; + logSafe("Fetching Expense Payment Request Payees"); + + try { + final response = await _getRequest(endpoint); + + if (response == null) { + logSafe("Expense Payment Request Payee request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Expense Payment Request Payee"); + if (jsonResponse != null) { + return PaymentRequestPayeeResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getExpensePaymentRequestPayeeApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Get Monthly Expense Report (categoryId is optional) static Future getDashboardMonthlyExpensesApi({ @@ -411,6 +789,58 @@ class ApiService { return null; } +/// Create Project API + static Future createProjectApi({ + required String name, + required String projectAddress, + required String shortName, + required String contactPerson, + required DateTime startDate, + required DateTime endDate, + required String projectStatusId, + }) async { + const endpoint = ApiEndpoints.createProject; + logSafe("Creating project: $name"); + + final Map payload = { + "name": name, + "projectAddress": projectAddress, + "shortName": shortName, + "contactPerson": contactPerson, + "startDate": startDate.toIso8601String(), + "endDate": endDate.toIso8601String(), + "projectStatusId": projectStatusId, + }; + + try { + final response = + await _postRequest(endpoint, payload, customTimeout: extendedTimeout); + + if (response == null) { + logSafe("Create project failed: null response", level: LogLevel.error); + return false; + } + + logSafe("Create project response status: ${response.statusCode}"); + logSafe("Create project response body: ${response.body}"); + + final json = jsonDecode(response.body); + if (json['success'] == true) { + logSafe("Project created successfully: ${json['data']}"); + return true; + } else { + logSafe( + "Failed to create project: ${json['message'] ?? 'Unknown error'}", + level: LogLevel.warning, + ); + } + } catch (e, stack) { + logSafe("Exception during createProjectApi: $e", level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return false; + } /// Get Organizations assigned to a Project static Future getAssignedOrganizations( 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/expense/expense_form_widgets.dart b/lib/helpers/widgets/expense/expense_form_widgets.dart index e921683..d383ac9 100644 --- a/lib/helpers/widgets/expense/expense_form_widgets.dart +++ b/lib/helpers/widgets/expense/expense_form_widgets.dart @@ -1,4 +1,4 @@ -// expense_form_widgets.dart +// form_widgets.dart import 'dart:io'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher_string.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; -import 'package:marco/controller/expense/add_expense_controller.dart'; /// 🔹 Common Colors & Styles final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); @@ -68,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, @@ -75,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) { @@ -92,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), @@ -161,9 +163,10 @@ class TileContainer extends StatelessWidget { } /// ========================== -/// Attachments Section +/// Attachments Section (Reusable) /// ========================== -class AttachmentsSection extends StatelessWidget { +class AttachmentsSection extends StatelessWidget { + final T controller; // 🔹 Now any controller can be passed final RxList attachments; final RxList> existingAttachments; final ValueChanged onRemoveNew; @@ -171,6 +174,7 @@ class AttachmentsSection extends StatelessWidget { final VoidCallback onAdd; const AttachmentsSection({ + required this.controller, required this.attachments, required this.existingAttachments, required this.onRemoveNew, @@ -239,8 +243,20 @@ class AttachmentsSection extends StatelessWidget { ), )), _buildActionTile(Icons.attach_file, onAdd), - _buildActionTile(Icons.camera_alt, - () => Get.find().pickFromCamera()), + _buildActionTile(Icons.camera_alt, () { + // 🔹 Dynamically call pickFromCamera if it exists + final fn = controller as dynamic; + if (fn.pickFromCamera != null) { + fn.pickFromCamera(); + } else { + showAppSnackbar( + title: 'Error', + message: + 'This controller does not support camera attachments.', + type: SnackbarType.error, + ); + } + }), ], ), ], @@ -402,7 +418,6 @@ class _AttachmentTile extends StatelessWidget { ); } - /// map extensions to icons/colors static (IconData, Color) _fileIcon(String ext) { switch (ext) { case 'pdf': diff --git a/lib/helpers/widgets/expense/expense_main_components.dart b/lib/helpers/widgets/expense/expense_main_components.dart index 5f19f8a..d13afca 100644 --- a/lib/helpers/widgets/expense/expense_main_components.dart +++ b/lib/helpers/widgets/expense/expense_main_components.dart @@ -32,7 +32,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { IconButton( icon: const Icon(Icons.arrow_back_ios_new, color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard'), + onPressed: () => Get.offNamed('/dashboard/finance'), ), MySpacing.width(8), Expanded( diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 1fbecf7..b81ee63 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -33,6 +33,229 @@ class SkeletonLoaders { ); } +// Inside SkeletonLoaders class + static Widget paymentRequestListSkeletonLoader() { + return ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: 6, + separatorBuilder: (_, __) => + Divider(color: Colors.grey.shade300, height: 20), + itemBuilder: (context, index) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Category name placeholder + Container( + height: 14, + width: 120, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 6), + + // Payee placeholder + Row( + children: [ + Container( + height: 12, + width: 50, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Container( + height: 12, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + + // Due date and status placeholders + Row( + children: [ + // Due date label + value + Row( + children: [ + Container( + height: 12, + width: 50, + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 6), + Container( + height: 12, + width: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + const Spacer(), + // Status chip placeholder + Container( + height: 20, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(6), + ), + ), + ], + ), + ], + ), + ); + }, + ); + } + +// 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), + ], + ), + ), + ], + )), + ), + ], + ), + ), + ), + ), + ); + } // Employee Detail Skeleton Loader static Widget employeeDetailSkeletonLoader() { return SingleChildScrollView( diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 7d0ef4b..68e3f9d 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -457,6 +457,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> attachments: controller.attachments, existingAttachments: controller.existingAttachments, onRemoveNew: controller.removeAttachment, + controller: controller, onRemoveExisting: (item) async { await showDialog( context: context, diff --git a/lib/model/finance/add_payment_request_bottom_sheet.dart b/lib/model/finance/add_payment_request_bottom_sheet.dart new file mode 100644 index 0000000..07a0db5 --- /dev/null +++ b/lib/model/finance/add_payment_request_bottom_sheet.dart @@ -0,0 +1,441 @@ +// payment_request_bottom_sheet.dart +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/finance/add_payment_request_controller.dart'; +import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; +import 'package:marco/helpers/utils/base_bottom_sheet.dart'; +import 'package:marco/helpers/utils/validators.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/widgets/my_confirmation_dialog.dart'; + +Future showPaymentRequestBottomSheet({bool isEdit = false}) { + return Get.bottomSheet( + _PaymentRequestBottomSheet(isEdit: isEdit), + isScrollControlled: true, + ); +} + +class _PaymentRequestBottomSheet extends StatefulWidget { + final bool isEdit; + const _PaymentRequestBottomSheet({this.isEdit = false}); + + @override + State<_PaymentRequestBottomSheet> createState() => + _PaymentRequestBottomSheetState(); +} + +class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> + with UIMixin { + final controller = Get.put(AddPaymentRequestController()); + final _formKey = GlobalKey(); + + final _projectDropdownKey = GlobalKey(); + final _categoryDropdownKey = GlobalKey(); + final _currencyDropdownKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Obx(() => Form( + key: _formKey, + child: BaseBottomSheet( + title: widget.isEdit + ? "Edit Payment Request" + : "Create Payment Request", + isSubmitting: controller.isSubmitting.value, + onCancel: Get.back, + onSubmit: () async { + if (_formKey.currentState!.validate() && _validateSelections()) { + final success = await controller.submitPaymentRequest(); + if (success) { + // First close the BottomSheet + Get.back(); + // Then show Snackbar + showAppSnackbar( + title: "Success", + message: "Payment request created successfully!", + type: SnackbarType.success, + ); + } + } + }, + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDropdown( + "Select Project", + Icons.work_outline, + controller.selectedProject.value?['name'] ?? + "Select Project", + controller.globalProjects, + (p) => p['name'], + controller.selectProject, + key: _projectDropdownKey), + _gap(), + _buildDropdown( + "Expense Category", + Icons.category_outlined, + controller.selectedCategory.value?.name ?? + "Select Category", + controller.categories, + (c) => c.name, + controller.selectCategory, + key: _categoryDropdownKey), + _gap(), + _buildTextField( + "Title", Icons.title_outlined, controller.titleController, + hint: "Enter title", validator: Validators.requiredField), + _gap(), + _buildRadio("Is Advance Payment", Icons.attach_money_outlined, + controller.isAdvancePayment, ["Yes", "No"]), + _gap(), + _buildDueDateField(), + _gap(), + _buildTextField("Amount", Icons.currency_rupee, + controller.amountController, + hint: "Enter Amount", + keyboardType: TextInputType.number, + validator: (v) => (v != null && + v.isNotEmpty && + double.tryParse(v) != null) + ? null + : "Enter valid amount"), + _gap(), + _buildPayeeAutocompleteField(), + _gap(), + _buildDropdown( + "Currency", + Icons.monetization_on_outlined, + controller.selectedCurrency.value?.currencyName ?? + "Select Currency", + controller.currencies, + (c) => c.currencyName, + controller.selectCurrency, + key: _currencyDropdownKey), + _gap(), + _buildTextField("Description", Icons.description_outlined, + controller.descriptionController, + hint: "Enter description", + maxLines: 3, + validator: Validators.requiredField), + _gap(), + _buildAttachmentsSection(), + ], + ), + ), + ), + )); + } + + 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, + TextInputType? keyboardType, + FormFieldValidator? validator, + int maxLines = 1}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: icon, title: title, requiredField: validator != null), + MySpacing.height(6), + CustomTextField( + controller: controller, + hint: hint ?? "", + keyboardType: keyboardType ?? TextInputType.text, + validator: validator, + maxLines: maxLines, + ), + ], + ); + } + + Widget _buildRadio( + String title, IconData icon, RxBool controller, List labels) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 20), + const SizedBox(width: 6), + Text(title, + style: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ), + MySpacing.height(6), + Obx(() => Row( + children: labels.asMap().entries.map((entry) { + final i = entry.key; + final label = entry.value; + final value = i == 0; + return Expanded( + child: RadioListTile( + contentPadding: EdgeInsets.zero, + title: Text(label), + value: value, + groupValue: controller.value, + activeColor: contentTheme.primary, + onChanged: (val) => + val != null ? controller.value = val : null, + ), + ); + }).toList(), + )), + ], + ); + } + + Widget _buildDueDateField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.calendar_today, + title: "Due To Date", + requiredField: true), + MySpacing.height(6), + GestureDetector( + onTap: () => controller.pickDueDate(context), + child: AbsorbPointer( + child: TextFormField( + controller: controller.dueDateController, + decoration: InputDecoration( + hintText: "Select Due Date", + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + validator: (_) => controller.selectedDueDate.value == null + ? "Please select a due date" + : null, + ), + ), + ), + ], + ); + } + + Widget _buildPayeeAutocompleteField() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: Icons.person_outline, title: "Payee", requiredField: true), + const SizedBox(height: 6), + Autocomplete( + optionsBuilder: (textEditingValue) { + final query = textEditingValue.text.toLowerCase(); + return query.isEmpty + ? const Iterable.empty() + : controller.payees + .where((p) => p.toLowerCase().contains(query)); + }, + displayStringForOption: (option) => option, + fieldViewBuilder: + (context, fieldController, focusNode, onFieldSubmitted) { + // Avoid updating during build + WidgetsBinding.instance.addPostFrameCallback((_) { + if (fieldController.text != controller.selectedPayee.value) { + fieldController.text = controller.selectedPayee.value; + fieldController.selection = TextSelection.fromPosition( + TextPosition(offset: fieldController.text.length)); + } + }); + + return TextFormField( + controller: fieldController, + focusNode: focusNode, + decoration: InputDecoration( + hintText: "Type or select payee", + filled: true, + fillColor: Colors.grey.shade100, + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + ), + validator: (v) => + v == null || v.trim().isEmpty ? "Please enter payee" : null, + onChanged: (val) => controller.selectedPayee.value = val, + ); + }, + onSelected: (selection) => controller.selectedPayee.value = selection, + optionsViewBuilder: (context, onSelected, options) => Material( + color: Colors.white, + elevation: 4, + borderRadius: BorderRadius.circular(8), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200, minWidth: 300), + child: ListView.builder( + padding: EdgeInsets.zero, + itemCount: options.length, + itemBuilder: (_, index) => InkWell( + onTap: () => onSelected(options.elementAt(index)), + child: Container( + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 12), + child: Text(options.elementAt(index), + style: const TextStyle(fontSize: 14)), + ), + ), + ), + ), + ), + ), + ], + ); + } + + Widget _buildAttachmentsSection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SectionTitle( + icon: Icons.attach_file, title: "Attachments", requiredField: true), + MySpacing.height(10), + Obx(() { + if (controller.isProcessingAttachment.value) { + return Center( + child: Column( + children: [ + CircularProgressIndicator(color: contentTheme.primary), + const SizedBox(height: 8), + Text("Processing image, please wait...", + style: + TextStyle(fontSize: 14, color: contentTheme.primary)), + ], + ), + ); + } + + return AttachmentsSection( + attachments: controller.attachments, + existingAttachments: controller.existingAttachments, + onRemoveNew: controller.removeAttachment, + controller: controller, + onRemoveExisting: (item) async { + await showDialog( + context: context, + barrierDismissible: false, + builder: (_) => ConfirmDialog( + title: "Remove Attachment", + message: "Are you sure you want to remove this attachment?", + confirmText: "Remove", + icon: Icons.delete, + confirmColor: Colors.redAccent, + onConfirm: () async { + final index = controller.existingAttachments.indexOf(item); + if (index != -1) { + controller.existingAttachments[index]['isActive'] = false; + controller.existingAttachments.refresh(); + } + showAppSnackbar( + title: 'Removed', + message: 'Attachment has been removed.', + type: SnackbarType.success); + Navigator.pop(context); + }, + ), + ); + }, + 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; + } + + if (key.currentContext == null) { + final selected = await showDialog( + context: context, + builder: (_) => SimpleDialog( + children: options + .map((opt) => SimpleDialogOption( + onPressed: () => Navigator.pop(context, opt), + child: Text(getLabel(opt)), + )) + .toList(), + ), + ); + if (selected != null) onSelected(selected); + 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.selectedProject.value == null || + controller.selectedProject.value!['id'].toString().isEmpty) { + return _showError("Please select a project"); + } + if (controller.selectedCategory.value == null) + return _showError("Please select a category"); + if (controller.selectedPayee.value.isEmpty) + return _showError("Please select a payee"); + if (controller.selectedCurrency.value == null) + return _showError("Please select currency"); + return true; + } + + bool _showError(String msg) { + showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error); + return false; + } +} diff --git a/lib/model/finance/currency_list_model.dart b/lib/model/finance/currency_list_model.dart new file mode 100644 index 0000000..b6410e0 --- /dev/null +++ b/lib/model/finance/currency_list_model.dart @@ -0,0 +1,77 @@ +class CurrencyListResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + CurrencyListResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory CurrencyListResponse.fromJson(Map json) { + return CurrencyListResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List? ?? []) + .map((e) => Currency.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class Currency { + final String id; + final String currencyCode; + final String currencyName; + final String symbol; + final bool isActive; + + Currency({ + required this.id, + required this.currencyCode, + required this.currencyName, + required this.symbol, + required this.isActive, + }); + + factory Currency.fromJson(Map json) { + return Currency( + id: json['id'] ?? '', + currencyCode: json['currencyCode'] ?? '', + currencyName: json['currencyName'] ?? '', + symbol: json['symbol'] ?? '', + isActive: json['isActive'] ?? false, + ); + } + + Map toJson() { + return { + 'id': id, + 'currencyCode': currencyCode, + 'currencyName': currencyName, + 'symbol': symbol, + 'isActive': isActive, + }; + } +} diff --git a/lib/model/finance/expense_category_model.dart b/lib/model/finance/expense_category_model.dart new file mode 100644 index 0000000..f1cf5fb --- /dev/null +++ b/lib/model/finance/expense_category_model.dart @@ -0,0 +1,77 @@ +class ExpenseCategoryResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + ExpenseCategoryResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory ExpenseCategoryResponse.fromJson(Map json) { + return ExpenseCategoryResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List? ?? []) + .map((e) => ExpenseCategory.fromJson(e)) + .toList(), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +class ExpenseCategory { + final String id; + final String name; + final bool noOfPersonsRequired; + final bool isAttachmentRequried; + final String description; + + ExpenseCategory({ + required this.id, + required this.name, + required this.noOfPersonsRequired, + required this.isAttachmentRequried, + required this.description, + }); + + factory ExpenseCategory.fromJson(Map json) { + return ExpenseCategory( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + isAttachmentRequried: json['isAttachmentRequried'] ?? false, + description: json['description'] ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'noOfPersonsRequired': noOfPersonsRequired, + 'isAttachmentRequried': isAttachmentRequried, + 'description': description, + }; + } +} 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_payee_request_model.dart b/lib/model/finance/payment_payee_request_model.dart new file mode 100644 index 0000000..5d0dcc9 --- /dev/null +++ b/lib/model/finance/payment_payee_request_model.dart @@ -0,0 +1,39 @@ +class PaymentRequestPayeeResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + PaymentRequestPayeeResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory PaymentRequestPayeeResponse.fromJson(Map json) { + return PaymentRequestPayeeResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: List.from(json['data'] ?? []), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data, + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} 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..a1c9820 --- /dev/null +++ b/lib/model/finance/payment_request_details_model.dart @@ -0,0 +1,444 @@ +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; + User? paidBy; + bool isAdvancePayment; + DateTime createdAt; + User createdBy; + DateTime updatedAt; + User? 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'] != null ? User.fromJson(json['paidBy']) : null, + isAdvancePayment: json['isAdvancePayment'], + createdAt: DateTime.parse(json['createdAt']), + createdBy: User.fromJson(json['createdBy']), + updatedAt: DateTime.parse(json['updatedAt']), + updatedBy: + json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null, + nextStatus: (json['nextStatus'] as List) + .map((e) => NextStatus.fromJson(e)) + .toList(), + 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'], + ); + + 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?.toJson(), + 'isAdvancePayment': isAdvancePayment, + 'createdAt': createdAt.toIso8601String(), + 'createdBy': createdBy.toJson(), + 'updatedAt': updatedAt.toIso8601String(), + 'updatedBy': updatedBy?.toJson(), + 'nextStatus': nextStatus.map((e) => e.toJson()).toList(), + 'updateLogs': updateLogs.map((e) => e.toJson()).toList(), + 'attachments': attachments.map((e) => e.toJson()).toList(), + '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 User { + String id; + String firstName; + String lastName; + String email; + String photo; + String jobRoleId; + String jobRoleName; + + User({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.photo, + required this.jobRoleId, + required this.jobRoleName, + }); + + factory User.fromJson(Map json) => User( + 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, + }; +} + +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_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/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/routes.dart b/lib/routes.dart index 381eb07..74e840a 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -23,6 +23,7 @@ import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart'; +import 'package:marco/view/finance/payment_request_screen.dart'; class AuthMiddleware extends GetMiddleware { @override RouteSettings? redirect(String? route) { @@ -91,6 +92,15 @@ getPageRoute() { name: '/dashboard/document-main-page', page: () => UserDocumentsPage(), middlewares: [AuthMiddleware()]), + // Finance + GetPage( + name: '/dashboard/finance', + page: () => FinanceScreen(), + middlewares: [AuthMiddleware()]), + GetPage( + name: '/dashboard/payment-request', + page: () => PaymentRequestMainScreen(), + middlewares: [AuthMiddleware()]), // Authentication GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), @@ -124,7 +134,7 @@ getPageRoute() { ), // Advance Payment GetPage( - name: '/dashboard/finance/advance-payment', + name: '/dashboard/advance-payment', page: () => AdvancePaymentScreen(), middlewares: [AuthMiddleware()], ), diff --git a/lib/view/finance/advance_payment_screen.dart b/lib/view/finance/advance_payment_screen.dart index f4e22ba..96d14d3 100644 --- a/lib/view/finance/advance_payment_screen.dart +++ b/lib/view/finance/advance_payment_screen.dart @@ -20,6 +20,7 @@ class _AdvancePaymentScreenState extends State late final AdvancePaymentController controller; late final TextEditingController _searchCtrl; final FocusNode _searchFocus = FocusNode(); + final projectController = Get.find(); @override void initState() { @@ -48,12 +49,11 @@ class _AdvancePaymentScreenState extends State @override Widget build(BuildContext context) { - final projectController = Get.find(); return Scaffold( backgroundColor: const Color( - 0xFFF5F5F5), // ✅ light grey background (Expense screen style) - appBar: _buildAppBar(projectController), + 0xFFF5F5F5), + appBar: _buildAppBar(), body: GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: RefreshIndicator( @@ -63,15 +63,15 @@ class _AdvancePaymentScreenState extends State await controller.fetchAdvancePayments(emp.id.toString()); } }, - color: Colors.white, // spinner color - backgroundColor: Colors.blue, // circle background color + color: Colors.white, + backgroundColor: contentTheme.primary, strokeWidth: 2.5, displacement: 60, child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), child: Container( color: - const Color(0xFFF5F5F5), // ✅ match background inside scroll + const Color(0xFFF5F5F5), child: Column( children: [ _buildSearchBar(), @@ -88,54 +88,62 @@ class _AdvancePaymentScreenState extends State } // ---------------- AppBar ---------------- - PreferredSizeWidget _buildAppBar(ProjectController projectController) { - return AppBar( - backgroundColor: Colors.grey[100], - elevation: 0.5, - automaticallyImplyLeading: false, - titleSpacing: 0, - title: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.arrow_back_ios_new, - color: Colors.black, size: 20), - onPressed: () => Get.offNamed('/dashboard/finance'), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge("Advance Payment", - fontWeight: 700, color: Colors.black), - MySpacing.height(2), - GetBuilder( - builder: (_) { - final projectName = - 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( - projectName, - overflow: TextOverflow.ellipsis, - fontWeight: 600, - color: Colors.grey[700], - ), - ), - ], - ); - }, - ), - ], + 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( + 'Advance Payments', + 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], + ), + ), + ], + ); + }, + ), + ], + ), + ), + ], + ), ), ), ); @@ -285,8 +293,7 @@ class _AdvancePaymentScreenState extends State color: Colors.grey[100], padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( - mainAxisAlignment: - MainAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.end, children: [ const Text( "Current Balance : ", diff --git a/lib/view/finance/finance_screen.dart b/lib/view/finance/finance_screen.dart index 64f5bb1..5e72e7c 100644 --- a/lib/view/finance/finance_screen.dart +++ b/lib/view/finance/finance_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:get/get.dart'; import 'package:marco/controller/project_controller.dart'; 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'; @@ -106,484 +107,116 @@ class _FinanceScreenState extends State opacity: _fadeAnimation, child: SingleChildScrollView( padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildWelcomeSection(), - MySpacing.height(24), - _buildFinanceModules(), - MySpacing.height(24), - _buildQuickStatsSection(), - ], - ), + child: _buildFinanceModulesCompact(), ), ), ); } - Widget _buildWelcomeSection() { - final projectSelected = projectController.selectedProject != null; - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - contentTheme.primary.withValues(alpha: 0.1), - contentTheme.info.withValues(alpha: 0.05), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: contentTheme.primary.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: contentTheme.primary.withValues(alpha: 0.1), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Icon( - LucideIcons.landmark, - color: contentTheme.primary, - size: 24, - ), - ), - MySpacing.width(12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - 'Financial Management', - fontWeight: 700, - color: Colors.black87, - ), - MySpacing.height(2), - MyText.bodySmall( - projectSelected - ? 'Manage your project finances' - : 'Select a project to get started', - color: Colors.grey[600], - ), - ], - ), - ), - ], - ), - if (!projectSelected) ...[ - MySpacing.height(12), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.orange.withValues(alpha: 0.3)), - ), - child: Row( - children: [ - Icon( - LucideIcons.badge_alert, - size: 16, - color: Colors.orange[700], - ), - MySpacing.width(8), - Expanded( - child: MyText.bodySmall( - 'Please select a project to access finance modules', - color: Colors.orange[700], - fontWeight: 500, - ), - ), - ], - ), - ), - ], - ], - ), - ); - } - - Widget _buildFinanceModules() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - 'Finance Modules', - fontWeight: 700, - color: Colors.black87, - ), - MySpacing.height(4), - MyText.bodySmall( - 'Select a module to manage', - color: Colors.grey[600], - ), - MySpacing.height(16), - _buildModuleGrid(), - ], - ); - } - - Widget _buildModuleGrid() { + // --- Finance Modules (Compact Dashboard-style) --- + Widget _buildFinanceModulesCompact() { final stats = [ - _FinanceStatItem( - LucideIcons.badge_dollar_sign, - "Expense", - "Track and manage expenses", - contentTheme.info, - "/dashboard/expense-main-page", - ), - _FinanceStatItem( - LucideIcons.receipt_text, - "Payment Request", - "Submit payment requests", - contentTheme.primary, - "/dashboard/payment-request", - ), - _FinanceStatItem( - LucideIcons.wallet, - "Advance Payment", - "Manage advance payments", - contentTheme.warning, - "/dashboard/finance/advance-payment", - ), + _FinanceStatItem(LucideIcons.badge_dollar_sign, "Expense", + 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"), ]; final projectSelected = projectController.selectedProject != null; - return GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: 12, - mainAxisSpacing: 12, - childAspectRatio: 1.1, - ), - itemCount: stats.length, - itemBuilder: (context, index) { - return _buildModernFinanceCard( - stats[index], - projectSelected, - index, - ); - }, - ); + return LayoutBuilder(builder: (context, constraints) { + int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4); + double cardWidth = + (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount; + + return Wrap( + spacing: 6, + runSpacing: 6, + alignment: WrapAlignment.end, + children: stats + .map((stat) => + _buildFinanceModuleCard(stat, projectSelected, cardWidth)) + .toList(), + ); + }); } - Widget _buildModernFinanceCard( - _FinanceStatItem statItem, - bool isProjectSelected, - int index, - ) { + Widget _buildFinanceModuleCard( + _FinanceStatItem stat, bool isProjectSelected, double width) { final bool isEnabled = isProjectSelected; - return TweenAnimationBuilder( - duration: Duration(milliseconds: 400 + (index * 100)), - tween: Tween(begin: 0.0, end: 1.0), - curve: Curves.easeOutCubic, - builder: (context, value, child) { - return Transform.scale( - scale: value, - child: Opacity( - opacity: isEnabled ? 1.0 : 0.5, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () => _onCardTap(statItem, isEnabled), - borderRadius: BorderRadius.circular(16), - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: isEnabled - ? statItem.color.withValues(alpha: 0.2) - : Colors.grey.withValues(alpha: 0.2), - width: 1.5, - ), - ), - child: Stack( - children: [ - // Content - Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(12), - child: Icon( - statItem.icon, - size: 28, - color: statItem.color, - ), - ), - MySpacing.height(12), - MyText.titleSmall( - statItem.title, - fontWeight: 700, - color: Colors.black87, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - MySpacing.height(4), - if (isEnabled) - Row( - children: [ - MyText.bodySmall( - 'View Details', - color: statItem.color, - fontWeight: 600, - fontSize: 11, - ), - MySpacing.width(4), - Icon( - LucideIcons.arrow_right, - size: 14, - color: statItem.color, - ), - ], - ), - ], - ), - ), - // Lock icon for disabled state - if (!isEnabled) - Positioned( - top: 12, - right: 12, - child: Container( - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - color: Colors.grey[200], - borderRadius: BorderRadius.circular(6), - ), - child: Icon( - LucideIcons.lock, - size: 14, - color: Colors.grey[600], - ), - ), - ), - ], - ), - ), - ), - ), - ), - ); - }, - ); - } - - Widget _buildQuickStatsSection() { - final projectSelected = projectController.selectedProject != null; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleMedium( - 'Quick Stats', - fontWeight: 700, - color: Colors.black87, - ), - MySpacing.height(4), - MyText.bodySmall( - 'Overview of your finances', - color: Colors.grey[600], - ), - MySpacing.height(16), - _buildStatsRow(projectSelected), - ], - ); - } - - Widget _buildStatsRow(bool projectSelected) { - final stats = [ - _QuickStat( - icon: LucideIcons.trending_up, - label: 'Total Expenses', - value: projectSelected ? '₹0' : '--', - color: contentTheme.danger, - ), - _QuickStat( - icon: LucideIcons.clock, - label: 'Pending', - value: projectSelected ? '0' : '--', - color: contentTheme.warning, - ), - ]; - - return Row( - children: stats - .map((stat) => Expanded( - child: Padding( - padding: const EdgeInsets.only(right: 8), - child: _buildStatCard(stat, projectSelected), - ), - )) - .toList(), - ); - } - - Widget _buildStatCard(_QuickStat stat, bool isEnabled) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: stat.color.withValues(alpha: 0.2), - width: 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: stat.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - stat.icon, - size: 20, - color: stat.color, - ), - ), - MySpacing.height(12), - MyText.bodySmall( - stat.label, - color: Colors.grey[600], - fontSize: 11, - ), - MySpacing.height(4), - MyText.titleLarge( - stat.value, - fontWeight: 700, - color: isEnabled ? Colors.black87 : Colors.grey[400], - ), - ], - ), - ); - } - - void _onCardTap(_FinanceStatItem statItem, bool isEnabled) { - if (!isEnabled) { - Get.dialog( - Dialog( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(24), + return Opacity( + opacity: isEnabled ? 1.0 : 0.4, + child: IgnorePointer( + ignoring: !isEnabled, + child: InkWell( + onTap: () => _onCardTap(stat, isEnabled), + borderRadius: BorderRadius.circular(5), + child: MyCard.bordered( + width: width, + height: 60, + paddingAll: 4, + borderRadiusAll: 5, + border: Border.all(color: Colors.grey.withOpacity(0.15)), child: Column( - mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(4), decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), - shape: BoxShape.circle, + color: stat.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), ), child: Icon( - LucideIcons.badge_alert, - color: Colors.orange[700], - size: 32, + stat.icon, + size: 16, + color: stat.color, ), ), - MySpacing.height(16), - MyText.titleMedium( - "No Project Selected", - fontWeight: 700, - color: Colors.black87, - textAlign: TextAlign.center, - ), - MySpacing.height(8), - MyText.bodyMedium( - "Please select a project before accessing this section.", - color: Colors.grey[600], - textAlign: TextAlign.center, - ), - MySpacing.height(24), - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () => Get.back(), - style: ElevatedButton.styleFrom( - backgroundColor: contentTheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - elevation: 0, - ), - child: MyText.bodyMedium( - "OK", - color: Colors.white, - fontWeight: 600, + MySpacing.height(4), + Flexible( + child: Text( + stat.title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 10, + overflow: TextOverflow.ellipsis, ), + maxLines: 2, + softWrap: true, ), ), ], ), ), ), - ); - return; - } + ), + ); + } - Get.toNamed(statItem.route); + void _onCardTap(_FinanceStatItem statItem, bool isEnabled) { + if (!isEnabled) { + Get.defaultDialog( + title: "No Project Selected", + middleText: "Please select a project before accessing this section.", + confirm: ElevatedButton( + onPressed: () => Get.back(), + child: const Text("OK"), + ), + ); + } else { + Get.toNamed(statItem.route); + } } } class _FinanceStatItem { final IconData icon; final String title; - final String subtitle; final Color color; final String route; - _FinanceStatItem( - this.icon, - this.title, - this.subtitle, - this.color, - this.route, - ); -} - -class _QuickStat { - final IconData icon; - final String label; - final String value; - final Color color; - - _QuickStat({ - required this.icon, - required this.label, - required this.value, - required this.color, - }); + _FinanceStatItem(this.icon, this.title, this.color, this.route); } 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..f9b86a2 --- /dev/null +++ b/lib/view/finance/payment_request_detail_screen.dart @@ -0,0 +1,645 @@ +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; +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; + 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(); + 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 + 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; + 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, + 60 + 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, colorParser: _parseColor), + const Divider(height: 30, thickness: 1.2), + _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), + const Divider(height: 30, thickness: 1.2), + _Documents(documents: request.attachments), + ], + ), + ), + ), + ), + ), + ), + ); + }), + ), + 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(), + ), + ), + ); + }), + ); + } + + 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], + ), + ), + ], + ); + }), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _Header extends StatelessWidget { + final PaymentRequestData request; + final Color Function(String) colorParser; + const _Header({required this.request, required this.colorParser}); + + @override + Widget build(BuildContext context) { + final statusColor = colorParser(request.expenseStatus.color); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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, + ), + ), + ], + ), + ), + 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( + width: 100, + child: MyText.labelSmall( + request.expenseStatus.displayName, + color: statusColor, + fontWeight: 600, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ); + } +} + +class _Logs extends StatelessWidget { + final List logs; + final Color Function(String) colorParser; + const _Logs({required this.logs, required this.colorParser}); + + DateTime _parseTimestamp(DateTime ts) => 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]; + + final status = log.status.name; + final description = log.status.description; + final comment = log.comment; + final nextStatusName = log.nextStatus.name; + + final updatedBy = log.updatedBy; + final initials = + '${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 timeAgo = timeago.format(timestamp); + + final statusColor = colorParser(log.status.color); + final nextStatusColor = colorParser(log.nextStatus.color); + + 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: [ + 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), + ), + ], + ), + ], + ), + ), + ); + }, + ) + ], + ); + } +} + +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 new file mode 100644 index 0000000..42719ee --- /dev/null +++ b/lib/view/finance/payment_request_screen.dart @@ -0,0 +1,389 @@ +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'; +import 'package:marco/helpers/widgets/my_custom_skeleton.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 SkeletonLoaders.paymentRequestListSkeletonLoader(); + } + + 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 + ? 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( + controller: scrollController, // attach controller + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + 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); + }, + ), + ); + }); + } + + 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.bodySmall(item.payee, fontWeight: 600), + ], + ), + const SizedBox(height: 6), + Row( + children: [ + Row( + children: [ + MyText.bodySmall("Due Date: ", color: Colors.grey[600]), + MyText.bodySmall(dueDate, fontWeight: 600), + ], + ), + 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.name, + color: Colors.white, + fontWeight: 500, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index f8f9ba4..538d0e8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -80,6 +80,9 @@ dependencies: googleapis_auth: ^2.0.0 device_info_plus: ^11.3.0 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: