added payment request screens
This commit is contained in:
parent
1070f04d1a
commit
92f7fec083
296
lib/controller/finance/add_payment_request_controller.dart
Normal file
296
lib/controller/finance/add_payment_request_controller.dart
Normal file
@ -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 = <String>[].obs;
|
||||||
|
final categories = <ExpenseCategory>[].obs;
|
||||||
|
final currencies = <Currency>[].obs;
|
||||||
|
final globalProjects = <Map<String, dynamic>>[].obs;
|
||||||
|
|
||||||
|
// Selected Values
|
||||||
|
final selectedProject = Rx<Map<String, dynamic>?>(null);
|
||||||
|
final selectedCategory = Rx<ExpenseCategory?>(null);
|
||||||
|
final selectedPayee = ''.obs;
|
||||||
|
final selectedCurrency = Rx<Currency?>(null);
|
||||||
|
final isAdvancePayment = false.obs;
|
||||||
|
final selectedDueDate = Rx<DateTime?>(null);
|
||||||
|
|
||||||
|
// Text Controllers
|
||||||
|
final titleController = TextEditingController();
|
||||||
|
final dueDateController = TextEditingController();
|
||||||
|
final amountController = TextEditingController();
|
||||||
|
final descriptionController = TextEditingController();
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
final attachments = <File>[].obs;
|
||||||
|
final existingAttachments = <Map<String, dynamic>>[].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<void> fetchAllMasterData() async {
|
||||||
|
await Future.wait([
|
||||||
|
_fetchData(
|
||||||
|
payees, ApiService.getExpensePaymentRequestPayeeApi, isLoadingPayees),
|
||||||
|
_fetchData(categories, ApiService.getMasterExpenseCategoriesApi,
|
||||||
|
isLoadingCategories),
|
||||||
|
_fetchData(
|
||||||
|
currencies, ApiService.getMasterCurrenciesApi, isLoadingCurrencies),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic fetch handler
|
||||||
|
Future<void> _fetchData<T>(
|
||||||
|
RxList<T> list, Future<dynamic> 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<void> fetchGlobalProjects() async {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.getGlobalProjects();
|
||||||
|
globalProjects.value = (response ?? [])
|
||||||
|
.map<Map<String, dynamic>>((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<void> 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<void> 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<String>().map(File.new));
|
||||||
|
}
|
||||||
|
attachments.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Attachment error: $e");
|
||||||
|
} finally {
|
||||||
|
isProcessingAttachment.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selection handlers
|
||||||
|
void selectProject(Map<String, dynamic> 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<List<Map<String, dynamic>>> 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<bool> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
128
lib/controller/finance/payment_request_controller.dart
Normal file
128
lib/controller/finance/payment_request_controller.dart
Normal file
@ -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<PaymentRequest> paymentRequests = <PaymentRequest>[].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<String, dynamic> appliedFilter = <String, dynamic>{}.obs;
|
||||||
|
RxString searchString = ''.obs;
|
||||||
|
|
||||||
|
// ---------------- Filter Options ----------------
|
||||||
|
RxList<IdNameModel> projects = <IdNameModel>[].obs;
|
||||||
|
RxList<IdNameModel> payees = <IdNameModel>[].obs;
|
||||||
|
RxList<IdNameModel> categories = <IdNameModel>[].obs;
|
||||||
|
RxList<IdNameModel> currencies = <IdNameModel>[].obs;
|
||||||
|
RxList<IdNameModel> statuses = <IdNameModel>[].obs;
|
||||||
|
RxList<IdNameModel> createdBy = <IdNameModel>[].obs;
|
||||||
|
|
||||||
|
// ---------------- Fetch Filter Options ----------------
|
||||||
|
Future<void> 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<void> 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<void> loadMorePaymentRequests() async {
|
||||||
|
if (isLoading.value || !_hasMoreData) return;
|
||||||
|
|
||||||
|
_pageNumber += 1;
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
await _fetchPaymentRequestsFromApi();
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Internal API Call ----------------
|
||||||
|
Future<void> _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<String, dynamic> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
363
lib/controller/finance/payment_request_detail_controller.dart
Normal file
363
lib/controller/finance/payment_request_detail_controller.dart
Normal file
@ -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<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
|
||||||
|
final RxBool isLoading = false.obs;
|
||||||
|
final RxString errorMessage = ''.obs;
|
||||||
|
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
||||||
|
|
||||||
|
// Employee selection
|
||||||
|
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
||||||
|
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
final TextEditingController employeeSearchController =
|
||||||
|
TextEditingController();
|
||||||
|
final RxBool isSearchingEmployees = false.obs;
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
final RxList<File> attachments = <File>[].obs;
|
||||||
|
final RxList<Map<String, dynamic>> existingAttachments =
|
||||||
|
<Map<String, dynamic>>[].obs;
|
||||||
|
final isProcessingAttachment = false.obs;
|
||||||
|
|
||||||
|
// Payment mode
|
||||||
|
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
||||||
|
|
||||||
|
// 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<T?> _apiCallWrapper<T>(
|
||||||
|
Future<T?> 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<void> 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<void> 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<String>().map(File.new),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Attachment error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
|
|
||||||
|
Future<void> 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<void> 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<bool> _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<void> 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<void> 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<void> 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<bool> 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<bool> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,28 @@ class ApiEndpoints {
|
|||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.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
|
// Dashboard Module API Endpoints
|
||||||
static const String getDashboardAttendanceOverview =
|
static const String getDashboardAttendanceOverview =
|
||||||
"/dashboard/attendance-overview";
|
"/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 getDashboardProjectProgress = "/dashboard/progression";
|
||||||
static const String getDashboardTasks = "/dashboard/tasks";
|
static const String getDashboardTasks = "/dashboard/tasks";
|
||||||
static const String getDashboardTeams = "/dashboard/teams";
|
static const String getDashboardTeams = "/dashboard/teams";
|
||||||
|
|||||||
@ -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/pending_expenses_model.dart';
|
||||||
import 'package:marco/model/dashboard/expense_type_report_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/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';
|
import 'package:marco/model/finance/advance_payment_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
@ -296,6 +302,378 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create Expense for Payment Request
|
||||||
|
static Future<bool> createExpenseForPRApi({
|
||||||
|
required String paymentModeId,
|
||||||
|
required String location,
|
||||||
|
required String gstNumber,
|
||||||
|
required String paymentRequestId,
|
||||||
|
List<Map<String, dynamic>> 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<bool> 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<PaymentRequestDetail?> 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<PaymentRequestFilter?>
|
||||||
|
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<PaymentRequestResponse?> getExpensePaymentRequestListApi({
|
||||||
|
bool isActive = true,
|
||||||
|
int pageSize = 20,
|
||||||
|
int pageNumber = 1,
|
||||||
|
Map<String, dynamic>? 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<bool> 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<Map<String, dynamic>> 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<CurrencyListResponse?> 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<ExpenseCategoryResponse?>
|
||||||
|
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<PaymentRequestPayeeResponse?>
|
||||||
|
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)
|
/// Get Monthly Expense Report (categoryId is optional)
|
||||||
static Future<DashboardMonthlyExpenseResponse?>
|
static Future<DashboardMonthlyExpenseResponse?>
|
||||||
getDashboardMonthlyExpensesApi({
|
getDashboardMonthlyExpensesApi({
|
||||||
@ -411,6 +789,58 @@ class ApiService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
/// Create Project API
|
||||||
|
static Future<bool> 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<String, dynamic> 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
|
/// Get Organizations assigned to a Project
|
||||||
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
class DateTimeUtils {
|
class DateTimeUtils {
|
||||||
|
/// Default date format
|
||||||
|
static const String defaultFormat = 'dd MMM yyyy';
|
||||||
|
|
||||||
/// Converts a UTC datetime string to local time and formats it.
|
/// Converts a UTC datetime string to local time and formats it.
|
||||||
static String convertUtcToLocal(String utcTimeString,
|
static String convertUtcToLocal(String utcTimeString,
|
||||||
{String format = 'dd-MM-yyyy'}) {
|
{String format = 'dd-MM-yyyy'}) {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
// expense_form_widgets.dart
|
// form_widgets.dart
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.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/image_viewer_dialog.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
|
||||||
|
|
||||||
/// 🔹 Common Colors & Styles
|
/// 🔹 Common Colors & Styles
|
||||||
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
|
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
|
||||||
@ -68,6 +67,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
final int maxLines;
|
final int maxLines;
|
||||||
final TextInputType keyboardType;
|
final TextInputType keyboardType;
|
||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
|
final Widget? suffixIcon;
|
||||||
|
|
||||||
const CustomTextField({
|
const CustomTextField({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
@ -75,8 +75,9 @@ class CustomTextField extends StatelessWidget {
|
|||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
this.keyboardType = TextInputType.text,
|
this.keyboardType = TextInputType.text,
|
||||||
this.validator,
|
this.validator,
|
||||||
|
this.suffixIcon,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) ;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -92,6 +93,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
fillColor: Colors.grey.shade100,
|
fillColor: Colors.grey.shade100,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
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<T extends GetxController> extends StatelessWidget {
|
||||||
|
final T controller; // 🔹 Now any controller can be passed
|
||||||
final RxList<File> attachments;
|
final RxList<File> attachments;
|
||||||
final RxList<Map<String, dynamic>> existingAttachments;
|
final RxList<Map<String, dynamic>> existingAttachments;
|
||||||
final ValueChanged<File> onRemoveNew;
|
final ValueChanged<File> onRemoveNew;
|
||||||
@ -171,6 +174,7 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
final VoidCallback onAdd;
|
final VoidCallback onAdd;
|
||||||
|
|
||||||
const AttachmentsSection({
|
const AttachmentsSection({
|
||||||
|
required this.controller,
|
||||||
required this.attachments,
|
required this.attachments,
|
||||||
required this.existingAttachments,
|
required this.existingAttachments,
|
||||||
required this.onRemoveNew,
|
required this.onRemoveNew,
|
||||||
@ -239,8 +243,20 @@ class AttachmentsSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
_buildActionTile(Icons.attach_file, onAdd),
|
_buildActionTile(Icons.attach_file, onAdd),
|
||||||
_buildActionTile(Icons.camera_alt,
|
_buildActionTile(Icons.camera_alt, () {
|
||||||
() => Get.find<AddExpenseController>().pickFromCamera()),
|
// 🔹 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) {
|
static (IconData, Color) _fileIcon(String ext) {
|
||||||
switch (ext) {
|
switch (ext) {
|
||||||
case 'pdf':
|
case 'pdf':
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
color: Colors.black, size: 20),
|
color: Colors.black, size: 20),
|
||||||
onPressed: () => Get.offNamed('/dashboard'),
|
onPressed: () => Get.offNamed('/dashboard/finance'),
|
||||||
),
|
),
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@ -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
|
// Employee Detail Skeleton Loader
|
||||||
static Widget employeeDetailSkeletonLoader() {
|
static Widget employeeDetailSkeletonLoader() {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
|
|||||||
@ -457,6 +457,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
attachments: controller.attachments,
|
attachments: controller.attachments,
|
||||||
existingAttachments: controller.existingAttachments,
|
existingAttachments: controller.existingAttachments,
|
||||||
onRemoveNew: controller.removeAttachment,
|
onRemoveNew: controller.removeAttachment,
|
||||||
|
controller: controller,
|
||||||
onRemoveExisting: (item) async {
|
onRemoveExisting: (item) async {
|
||||||
await showDialog(
|
await showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
|
|||||||
441
lib/model/finance/add_payment_request_bottom_sheet.dart
Normal file
441
lib/model/finance/add_payment_request_bottom_sheet.dart
Normal file
@ -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<T?> showPaymentRequestBottomSheet<T>({bool isEdit = false}) {
|
||||||
|
return Get.bottomSheet<T>(
|
||||||
|
_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<FormState>();
|
||||||
|
|
||||||
|
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<T>(String title, IconData icon, String value,
|
||||||
|
List<T> options, String Function(T) getLabel, ValueChanged<T> 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<String>? 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<String> 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<bool>(
|
||||||
|
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<String>(
|
||||||
|
optionsBuilder: (textEditingValue) {
|
||||||
|
final query = textEditingValue.text.toLowerCase();
|
||||||
|
return query.isEmpty
|
||||||
|
? const Iterable<String>.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<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
||||||
|
ValueChanged<T> onSelected, GlobalKey key) async {
|
||||||
|
if (options.isEmpty) {
|
||||||
|
_showError("No options available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key.currentContext == null) {
|
||||||
|
final selected = await showDialog<T>(
|
||||||
|
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<T>(
|
||||||
|
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<T>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lib/model/finance/currency_list_model.dart
Normal file
77
lib/model/finance/currency_list_model.dart
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
class CurrencyListResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<Currency> 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<String, dynamic> json) {
|
||||||
|
return CurrencyListResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: (json['data'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => Currency.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
return Currency(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
currencyCode: json['currencyCode'] ?? '',
|
||||||
|
currencyName: json['currencyName'] ?? '',
|
||||||
|
symbol: json['symbol'] ?? '',
|
||||||
|
isActive: json['isActive'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'currencyCode': currencyCode,
|
||||||
|
'currencyName': currencyName,
|
||||||
|
'symbol': symbol,
|
||||||
|
'isActive': isActive,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
77
lib/model/finance/expense_category_model.dart
Normal file
77
lib/model/finance/expense_category_model.dart
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
class ExpenseCategoryResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<ExpenseCategory> 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<String, dynamic> json) {
|
||||||
|
return ExpenseCategoryResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: (json['data'] as List<dynamic>? ?? [])
|
||||||
|
.map((e) => ExpenseCategory.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
return ExpenseCategory(
|
||||||
|
id: json['id'] ?? '',
|
||||||
|
name: json['name'] ?? '',
|
||||||
|
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
|
||||||
|
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
|
||||||
|
description: json['description'] ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'noOfPersonsRequired': noOfPersonsRequired,
|
||||||
|
'isAttachmentRequried': isAttachmentRequried,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal file
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal file
@ -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<T?> showCreateExpenseBottomSheet<T>() {
|
||||||
|
return Get.bottomSheet<T>(
|
||||||
|
_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<FormState>();
|
||||||
|
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<T>(String title, IconData icon, String value,
|
||||||
|
List<T> options, String Function(T) getLabel, ValueChanged<T> 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<String>? 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<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
||||||
|
ValueChanged<T> 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<T>(
|
||||||
|
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<T>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/model/finance/payment_mode_response_model.dart
Normal file
65
lib/model/finance/payment_mode_response_model.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
class PaymentModeResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<PaymentModeData> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) {
|
||||||
|
return PaymentModeData(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
39
lib/model/finance/payment_payee_request_model.dart
Normal file
39
lib/model/finance/payment_payee_request_model.dart
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
class PaymentRequestPayeeResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<String> 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<String, dynamic> json) {
|
||||||
|
return PaymentRequestPayeeResponse(
|
||||||
|
success: json['success'] ?? false,
|
||||||
|
message: json['message'] ?? '',
|
||||||
|
data: List<String>.from(json['data'] ?? []),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] ?? 0,
|
||||||
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data,
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
444
lib/model/finance/payment_request_details_model.dart
Normal file
444
lib/model/finance/payment_request_details_model.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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> nextStatus;
|
||||||
|
List<UpdateLog> updateLogs;
|
||||||
|
List<Attachment> 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<String, dynamic> 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<dynamic>)
|
||||||
|
.map((e) => NextStatus.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
updateLogs: (json['updateLogs'] as List<dynamic>)
|
||||||
|
.map((e) => UpdateLog.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
attachments: (json['attachments'] as List<dynamic>)
|
||||||
|
.map((e) => Attachment.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
isActive: json['isActive'],
|
||||||
|
isExpenseCreated: json['isExpenseCreated'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => Currency(
|
||||||
|
id: json['id'],
|
||||||
|
currencyCode: json['currencyCode'],
|
||||||
|
currencyName: json['currencyName'],
|
||||||
|
symbol: json['symbol'],
|
||||||
|
isActive: json['isActive'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
Project(id: json['id'], name: json['name']);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) =>
|
||||||
|
ExpenseCategory(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
noOfPersonsRequired: json['noOfPersonsRequired'],
|
||||||
|
isAttachmentRequried: json['isAttachmentRequried'],
|
||||||
|
description: json['description'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'noOfPersonsRequired': noOfPersonsRequired,
|
||||||
|
'isAttachmentRequried': isAttachmentRequried,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpenseStatus {
|
||||||
|
String id;
|
||||||
|
String name;
|
||||||
|
String displayName;
|
||||||
|
String description;
|
||||||
|
List<String>? 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<String, dynamic> json) => ExpenseStatus(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
displayName: json['displayName'],
|
||||||
|
description: json['description'],
|
||||||
|
permissionIds: json['permissionIds'] != null
|
||||||
|
? List<String>.from(json['permissionIds'])
|
||||||
|
: null,
|
||||||
|
color: json['color'],
|
||||||
|
isSystem: json['isSystem'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => User(
|
||||||
|
id: json['id'],
|
||||||
|
firstName: json['firstName'],
|
||||||
|
lastName: json['lastName'],
|
||||||
|
email: json['email'],
|
||||||
|
photo: json['photo'],
|
||||||
|
jobRoleId: json['jobRoleId'],
|
||||||
|
jobRoleName: json['jobRoleName'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String>? 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<String, dynamic> json) => NextStatus(
|
||||||
|
id: json['id'],
|
||||||
|
name: json['name'],
|
||||||
|
displayName: json['displayName'],
|
||||||
|
description: json['description'],
|
||||||
|
permissionIds: json['permissionIds'] != null
|
||||||
|
? List<String>.from(json['permissionIds'])
|
||||||
|
: null,
|
||||||
|
color: json['color'],
|
||||||
|
isSystem: json['isSystem'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => Attachment(
|
||||||
|
id: json['id'],
|
||||||
|
fileName: json['fileName'],
|
||||||
|
url: json['url'],
|
||||||
|
thumbUrl: json['thumbUrl'],
|
||||||
|
fileSize: json['fileSize'],
|
||||||
|
contentType: json['contentType'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'fileName': fileName,
|
||||||
|
'url': url,
|
||||||
|
'thumbUrl': thumbUrl,
|
||||||
|
'fileSize': fileSize,
|
||||||
|
'contentType': contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
108
lib/model/finance/payment_request_filter.dart
Normal file
108
lib/model/finance/payment_request_filter.dart
Normal file
@ -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<String, dynamic> 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<String, dynamic> 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<IdNameModel> projects;
|
||||||
|
List<IdNameModel> currency;
|
||||||
|
List<IdNameModel> createdBy;
|
||||||
|
List<IdNameModel> status;
|
||||||
|
List<IdNameModel> expenseCategory;
|
||||||
|
List<IdNameModel> payees;
|
||||||
|
|
||||||
|
factory PaymentRequestFilterData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
PaymentRequestFilterData(
|
||||||
|
projects: List<IdNameModel>.from(
|
||||||
|
json["projects"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
currency: List<IdNameModel>.from(
|
||||||
|
json["currency"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
createdBy: List<IdNameModel>.from(
|
||||||
|
json["createdBy"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
status: List<IdNameModel>.from(
|
||||||
|
json["status"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
expenseCategory: List<IdNameModel>.from(
|
||||||
|
json["expenseCategory"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
payees: List<IdNameModel>.from(
|
||||||
|
json["payees"].map((x) => IdNameModel.fromJson(x))),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"projects": List<dynamic>.from(projects.map((x) => x.toJson())),
|
||||||
|
"currency": List<dynamic>.from(currency.map((x) => x.toJson())),
|
||||||
|
"createdBy": List<dynamic>.from(createdBy.map((x) => x.toJson())),
|
||||||
|
"status": List<dynamic>.from(status.map((x) => x.toJson())),
|
||||||
|
"expenseCategory":
|
||||||
|
List<dynamic>.from(expenseCategory.map((x) => x.toJson())),
|
||||||
|
"payees": List<dynamic>.from(payees.map((x) => x.toJson())),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class IdNameModel {
|
||||||
|
IdNameModel({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
String id;
|
||||||
|
String name;
|
||||||
|
|
||||||
|
factory IdNameModel.fromJson(Map<String, dynamic> json) => IdNameModel(
|
||||||
|
id: json["id"].toString(),
|
||||||
|
name: json["name"] ?? "",
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"name": name,
|
||||||
|
};
|
||||||
|
}
|
||||||
471
lib/model/finance/payment_request_filter_bottom_sheet.dart
Normal file
471
lib/model/finance/payment_request_filter_bottom_sheet.dart
Normal file
@ -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<PaymentRequestFilterBottomSheet> createState() =>
|
||||||
|
_PaymentRequestFilterBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentRequestFilterBottomSheetState
|
||||||
|
extends State<PaymentRequestFilterBottomSheet> with UIMixin {
|
||||||
|
// ---------------- Date Range ----------------
|
||||||
|
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
|
||||||
|
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
|
||||||
|
|
||||||
|
// ---------------- Selected Filters (store IDs internally) ----------------
|
||||||
|
final RxString selectedProjectId = ''.obs;
|
||||||
|
final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].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<String> projectNames = <String>[].obs;
|
||||||
|
final RxList<String> submittedByNames = <String>[].obs;
|
||||||
|
final RxList<String> payeeNames = <String>[].obs;
|
||||||
|
final RxList<String> categoryNames = <String>[].obs;
|
||||||
|
final RxList<String> currencyNames = <String>[].obs;
|
||||||
|
final RxList<String> statusNames = <String>[].obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadFilterData();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<EmployeeModel>()
|
||||||
|
.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<EmployeeModel>()
|
||||||
|
.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<List<EmployeeModel>> searchEmployees(
|
||||||
|
String query, List<String> 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<String, dynamic> 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<String> items,
|
||||||
|
required ValueChanged<String> onSelected,
|
||||||
|
}) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
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<EmployeeModel> selectedEmployees,
|
||||||
|
String title, List<String> 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<List<EmployeeModel>>(
|
||||||
|
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)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
306
lib/model/finance/payment_request_list_model.dart
Normal file
306
lib/model/finance/payment_request_list_model.dart
Normal file
@ -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<String, dynamic> json) =>
|
||||||
|
PaymentRequestResponse(
|
||||||
|
success: json["success"],
|
||||||
|
message: json["message"],
|
||||||
|
data: PaymentRequestData.fromJson(json["data"]),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<PaymentRequest> data;
|
||||||
|
|
||||||
|
factory PaymentRequestData.fromJson(Map<String, dynamic> json) =>
|
||||||
|
PaymentRequestData(
|
||||||
|
currentPage: json["currentPage"],
|
||||||
|
totalPages: json["totalPages"],
|
||||||
|
totalEntities: json["totalEntities"],
|
||||||
|
data: List<PaymentRequest>.from(
|
||||||
|
json["data"].map((x) => PaymentRequest.fromJson(x))),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"currentPage": currentPage,
|
||||||
|
"totalPages": totalPages,
|
||||||
|
"totalEntities": totalEntities,
|
||||||
|
"data": List<dynamic>.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<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) => Currency(
|
||||||
|
id: json["id"],
|
||||||
|
currencyCode: json["currencyCode"],
|
||||||
|
currencyName: json["currencyName"],
|
||||||
|
symbol: json["symbol"],
|
||||||
|
isActive: json["isActive"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => Project(
|
||||||
|
id: json["id"],
|
||||||
|
name: json["name"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => ExpenseCategory(
|
||||||
|
id: json["id"],
|
||||||
|
name: json["name"],
|
||||||
|
noOfPersonsRequired: json["noOfPersonsRequired"],
|
||||||
|
isAttachmentRequried: json["isAttachmentRequried"],
|
||||||
|
description: json["description"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => ExpenseStatus(
|
||||||
|
id: json["id"],
|
||||||
|
name: json["name"],
|
||||||
|
displayName: json["displayName"],
|
||||||
|
description: json["description"],
|
||||||
|
permissionIds: json["permissionIds"],
|
||||||
|
color: json["color"],
|
||||||
|
isSystem: json["isSystem"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> 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<String, dynamic> json) => CreatedBy(
|
||||||
|
id: json["id"],
|
||||||
|
firstName: json["firstName"],
|
||||||
|
lastName: json["lastName"],
|
||||||
|
email: json["email"],
|
||||||
|
photo: json["photo"],
|
||||||
|
jobRoleId: json["jobRoleId"],
|
||||||
|
jobRoleName: json["jobRoleName"],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
"id": id,
|
||||||
|
"firstName": firstName,
|
||||||
|
"lastName": lastName,
|
||||||
|
"email": email,
|
||||||
|
"photo": photo,
|
||||||
|
"jobRoleId": jobRoleId,
|
||||||
|
"jobRoleName": jobRoleName,
|
||||||
|
};
|
||||||
|
}
|
||||||
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal file
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal file
@ -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<UpdatePaymentRequestWithReimbursement> createState() =>
|
||||||
|
_UpdatePaymentRequestWithReimbursement();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdatePaymentRequestWithReimbursement
|
||||||
|
extends State<UpdatePaymentRequestWithReimbursement> {
|
||||||
|
final PaymentRequestDetailController controller =
|
||||||
|
Get.find<PaymentRequestDetailController>();
|
||||||
|
|
||||||
|
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"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/tenant/tenant_selection_screen.dart';
|
||||||
import 'package:marco/view/finance/finance_screen.dart';
|
import 'package:marco/view/finance/finance_screen.dart';
|
||||||
import 'package:marco/view/finance/advance_payment_screen.dart';
|
import 'package:marco/view/finance/advance_payment_screen.dart';
|
||||||
|
import 'package:marco/view/finance/payment_request_screen.dart';
|
||||||
class AuthMiddleware extends GetMiddleware {
|
class AuthMiddleware extends GetMiddleware {
|
||||||
@override
|
@override
|
||||||
RouteSettings? redirect(String? route) {
|
RouteSettings? redirect(String? route) {
|
||||||
@ -91,6 +92,15 @@ getPageRoute() {
|
|||||||
name: '/dashboard/document-main-page',
|
name: '/dashboard/document-main-page',
|
||||||
page: () => UserDocumentsPage(),
|
page: () => UserDocumentsPage(),
|
||||||
middlewares: [AuthMiddleware()]),
|
middlewares: [AuthMiddleware()]),
|
||||||
|
// Finance
|
||||||
|
GetPage(
|
||||||
|
name: '/dashboard/finance',
|
||||||
|
page: () => FinanceScreen(),
|
||||||
|
middlewares: [AuthMiddleware()]),
|
||||||
|
GetPage(
|
||||||
|
name: '/dashboard/payment-request',
|
||||||
|
page: () => PaymentRequestMainScreen(),
|
||||||
|
middlewares: [AuthMiddleware()]),
|
||||||
// Authentication
|
// Authentication
|
||||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||||
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
||||||
@ -124,7 +134,7 @@ getPageRoute() {
|
|||||||
),
|
),
|
||||||
// Advance Payment
|
// Advance Payment
|
||||||
GetPage(
|
GetPage(
|
||||||
name: '/dashboard/finance/advance-payment',
|
name: '/dashboard/advance-payment',
|
||||||
page: () => AdvancePaymentScreen(),
|
page: () => AdvancePaymentScreen(),
|
||||||
middlewares: [AuthMiddleware()],
|
middlewares: [AuthMiddleware()],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -20,6 +20,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
late final AdvancePaymentController controller;
|
late final AdvancePaymentController controller;
|
||||||
late final TextEditingController _searchCtrl;
|
late final TextEditingController _searchCtrl;
|
||||||
final FocusNode _searchFocus = FocusNode();
|
final FocusNode _searchFocus = FocusNode();
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -48,12 +49,11 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final projectController = Get.find<ProjectController>();
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(
|
backgroundColor: const Color(
|
||||||
0xFFF5F5F5), // ✅ light grey background (Expense screen style)
|
0xFFF5F5F5),
|
||||||
appBar: _buildAppBar(projectController),
|
appBar: _buildAppBar(),
|
||||||
body: GestureDetector(
|
body: GestureDetector(
|
||||||
onTap: () => FocusScope.of(context).unfocus(),
|
onTap: () => FocusScope.of(context).unfocus(),
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
@ -63,15 +63,15 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
await controller.fetchAdvancePayments(emp.id.toString());
|
await controller.fetchAdvancePayments(emp.id.toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
color: Colors.white, // spinner color
|
color: Colors.white,
|
||||||
backgroundColor: Colors.blue, // circle background color
|
backgroundColor: contentTheme.primary,
|
||||||
strokeWidth: 2.5,
|
strokeWidth: 2.5,
|
||||||
displacement: 60,
|
displacement: 60,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
child: Container(
|
child: Container(
|
||||||
color:
|
color:
|
||||||
const Color(0xFFF5F5F5), // ✅ match background inside scroll
|
const Color(0xFFF5F5F5),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildSearchBar(),
|
_buildSearchBar(),
|
||||||
@ -88,54 +88,62 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- AppBar ----------------
|
// ---------------- AppBar ----------------
|
||||||
PreferredSizeWidget _buildAppBar(ProjectController projectController) {
|
PreferredSizeWidget _buildAppBar() {
|
||||||
return AppBar(
|
return PreferredSize(
|
||||||
backgroundColor: Colors.grey[100],
|
preferredSize: const Size.fromHeight(72),
|
||||||
elevation: 0.5,
|
child: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
titleSpacing: 0,
|
elevation: 0.5,
|
||||||
title: Padding(
|
automaticallyImplyLeading: false,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
titleSpacing: 0,
|
||||||
child: Row(
|
title: Padding(
|
||||||
children: [
|
padding: MySpacing.xy(16, 0),
|
||||||
IconButton(
|
child: Row(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
color: Colors.black, size: 20),
|
children: [
|
||||||
onPressed: () => Get.offNamed('/dashboard/finance'),
|
IconButton(
|
||||||
),
|
icon: const Icon(Icons.arrow_back_ios_new,
|
||||||
Expanded(
|
color: Colors.black, size: 20),
|
||||||
child: Column(
|
onPressed: () => Get.offNamed('/dashboard/finance'),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.titleLarge("Advance Payment",
|
|
||||||
fontWeight: 700, color: Colors.black),
|
|
||||||
MySpacing.height(2),
|
|
||||||
GetBuilder<ProjectController>(
|
|
||||||
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],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
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<ProjectController>(
|
||||||
|
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<AdvancePaymentScreen>
|
|||||||
color: Colors.grey[100],
|
color: Colors.grey[100],
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment:
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
MainAxisAlignment.end,
|
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
"Current Balance : ",
|
"Current Balance : ",
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_lucide/flutter_lucide.dart';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/helpers/utils/mixins/ui_mixin.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_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
@ -106,484 +107,116 @@ class _FinanceScreenState extends State<FinanceScreen>
|
|||||||
opacity: _fadeAnimation,
|
opacity: _fadeAnimation,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: _buildFinanceModulesCompact(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_buildWelcomeSection(),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_buildFinanceModules(),
|
|
||||||
MySpacing.height(24),
|
|
||||||
_buildQuickStatsSection(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildWelcomeSection() {
|
// --- Finance Modules (Compact Dashboard-style) ---
|
||||||
final projectSelected = projectController.selectedProject != null;
|
Widget _buildFinanceModulesCompact() {
|
||||||
|
|
||||||
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() {
|
|
||||||
final stats = [
|
final stats = [
|
||||||
_FinanceStatItem(
|
_FinanceStatItem(LucideIcons.badge_dollar_sign, "Expense",
|
||||||
LucideIcons.badge_dollar_sign,
|
contentTheme.info, "/dashboard/expense-main-page"),
|
||||||
"Expense",
|
_FinanceStatItem(LucideIcons.receipt_text, "Payment Request",
|
||||||
"Track and manage expenses",
|
contentTheme.primary, "/dashboard/payment-request"),
|
||||||
contentTheme.info,
|
_FinanceStatItem(LucideIcons.wallet, "Advance Payment",
|
||||||
"/dashboard/expense-main-page",
|
contentTheme.warning, "/dashboard/advance-payment"),
|
||||||
),
|
|
||||||
_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",
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
final projectSelected = projectController.selectedProject != null;
|
final projectSelected = projectController.selectedProject != null;
|
||||||
|
|
||||||
return GridView.builder(
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
shrinkWrap: true,
|
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
double cardWidth =
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
|
||||||
crossAxisCount: 2,
|
|
||||||
crossAxisSpacing: 12,
|
return Wrap(
|
||||||
mainAxisSpacing: 12,
|
spacing: 6,
|
||||||
childAspectRatio: 1.1,
|
runSpacing: 6,
|
||||||
),
|
alignment: WrapAlignment.end,
|
||||||
itemCount: stats.length,
|
children: stats
|
||||||
itemBuilder: (context, index) {
|
.map((stat) =>
|
||||||
return _buildModernFinanceCard(
|
_buildFinanceModuleCard(stat, projectSelected, cardWidth))
|
||||||
stats[index],
|
.toList(),
|
||||||
projectSelected,
|
);
|
||||||
index,
|
});
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildModernFinanceCard(
|
Widget _buildFinanceModuleCard(
|
||||||
_FinanceStatItem statItem,
|
_FinanceStatItem stat, bool isProjectSelected, double width) {
|
||||||
bool isProjectSelected,
|
|
||||||
int index,
|
|
||||||
) {
|
|
||||||
final bool isEnabled = isProjectSelected;
|
final bool isEnabled = isProjectSelected;
|
||||||
|
|
||||||
return TweenAnimationBuilder<double>(
|
return Opacity(
|
||||||
duration: Duration(milliseconds: 400 + (index * 100)),
|
opacity: isEnabled ? 1.0 : 0.4,
|
||||||
tween: Tween(begin: 0.0, end: 1.0),
|
child: IgnorePointer(
|
||||||
curve: Curves.easeOutCubic,
|
ignoring: !isEnabled,
|
||||||
builder: (context, value, child) {
|
child: InkWell(
|
||||||
return Transform.scale(
|
onTap: () => _onCardTap(stat, isEnabled),
|
||||||
scale: value,
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: Opacity(
|
child: MyCard.bordered(
|
||||||
opacity: isEnabled ? 1.0 : 0.5,
|
width: width,
|
||||||
child: Material(
|
height: 60,
|
||||||
color: Colors.transparent,
|
paddingAll: 4,
|
||||||
child: InkWell(
|
borderRadiusAll: 5,
|
||||||
onTap: () => _onCardTap(statItem, isEnabled),
|
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||||
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),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.1),
|
color: stat.color.withOpacity(0.1),
|
||||||
shape: BoxShape.circle,
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
LucideIcons.badge_alert,
|
stat.icon,
|
||||||
color: Colors.orange[700],
|
size: 16,
|
||||||
size: 32,
|
color: stat.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(16),
|
MySpacing.height(4),
|
||||||
MyText.titleMedium(
|
Flexible(
|
||||||
"No Project Selected",
|
child: Text(
|
||||||
fontWeight: 700,
|
stat.title,
|
||||||
color: Colors.black87,
|
textAlign: TextAlign.center,
|
||||||
textAlign: TextAlign.center,
|
style: const TextStyle(
|
||||||
),
|
fontSize: 10,
|
||||||
MySpacing.height(8),
|
overflow: TextOverflow.ellipsis,
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
|
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 {
|
class _FinanceStatItem {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
|
||||||
final Color color;
|
final Color color;
|
||||||
final String route;
|
final String route;
|
||||||
|
|
||||||
_FinanceStatItem(
|
_FinanceStatItem(this.icon, this.title, this.color, this.route);
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
645
lib/view/finance/payment_request_detail_screen.dart
Normal file
645
lib/view/finance/payment_request_detail_screen.dart
Normal file
@ -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<PaymentRequestDetailScreen> createState() =>
|
||||||
|
_PaymentRequestDetailScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
||||||
|
with UIMixin {
|
||||||
|
final controller = Get.put(PaymentRequestDetailController());
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
final permissionController = Get.find<PermissionController>();
|
||||||
|
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<void> _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<ProjectController>(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<UpdateLog> 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<Attachment> 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
389
lib/view/finance/payment_request_screen.dart
Normal file
389
lib/view/finance/payment_request_screen.dart
Normal file
@ -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<PaymentRequestMainScreen> createState() =>
|
||||||
|
_PaymentRequestMainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
||||||
|
with SingleTickerProviderStateMixin, UIMixin {
|
||||||
|
late TabController _tabController;
|
||||||
|
final searchController = TextEditingController();
|
||||||
|
final paymentController = Get.put(PaymentRequestController());
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
|
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
paymentController.fetchPaymentRequests();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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<ProjectController>(
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -80,6 +80,9 @@ dependencies:
|
|||||||
googleapis_auth: ^2.0.0
|
googleapis_auth: ^2.0.0
|
||||||
device_info_plus: ^11.3.0
|
device_info_plus: ^11.3.0
|
||||||
flutter_local_notifications: 19.4.0
|
flutter_local_notifications: 19.4.0
|
||||||
|
equatable: ^2.0.7
|
||||||
|
mime: ^2.0.0
|
||||||
|
timeago: ^3.7.1
|
||||||
|
|
||||||
timeline_tile: ^2.0.0
|
timeline_tile: ^2.0.0
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user