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://devapi.marcoaiot.com/api";
|
||||
|
||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||
static const String getMasterExpensesCategories =
|
||||
"/Master/expenses-categories";
|
||||
static const String getExpensePaymentRequestPayee =
|
||||
"/Expense/payment-request/payee";
|
||||
// Dashboard Module API Endpoints
|
||||
static const String getDashboardAttendanceOverview =
|
||||
"/dashboard/attendance-overview";
|
||||
static const String createExpensePaymentRequest =
|
||||
"/expense/payment-request/create";
|
||||
static const String getExpensePaymentRequestList =
|
||||
"/Expense/get/payment-requests/list";
|
||||
static const String getExpensePaymentRequestDetails =
|
||||
"/Expense/get/payment-request/details";
|
||||
static const String getExpensePaymentRequestFilter =
|
||||
"/Expense/payment-request/filter";
|
||||
static const String updateExpensePaymentRequestStatus =
|
||||
"/Expense/payment-request/action";
|
||||
static const String createExpenseforPR =
|
||||
"/Expense/payment-request/expense/create";
|
||||
|
||||
|
||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||
static const String getDashboardTasks = "/dashboard/tasks";
|
||||
static const String getDashboardTeams = "/dashboard/teams";
|
||||
|
||||
@ -26,6 +26,12 @@ import 'package:marco/model/all_organization_model.dart';
|
||||
import 'package:marco/model/dashboard/pending_expenses_model.dart';
|
||||
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
||||
import 'package:marco/model/dashboard/monthly_expence_model.dart';
|
||||
import 'package:marco/model/finance/expense_category_model.dart';
|
||||
import 'package:marco/model/finance/currency_list_model.dart';
|
||||
import 'package:marco/model/finance/payment_payee_request_model.dart';
|
||||
import 'package:marco/model/finance/payment_request_list_model.dart';
|
||||
import 'package:marco/model/finance/payment_request_filter.dart';
|
||||
import 'package:marco/model/finance/payment_request_details_model.dart';
|
||||
import 'package:marco/model/finance/advance_payment_model.dart';
|
||||
|
||||
class ApiService {
|
||||
@ -296,6 +302,378 @@ class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create Expense for Payment Request
|
||||
static Future<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)
|
||||
static Future<DashboardMonthlyExpenseResponse?>
|
||||
getDashboardMonthlyExpensesApi({
|
||||
@ -411,6 +789,58 @@ class ApiService {
|
||||
|
||||
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
|
||||
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateTimeUtils {
|
||||
/// Default date format
|
||||
static const String defaultFormat = 'dd MMM yyyy';
|
||||
|
||||
/// Converts a UTC datetime string to local time and formats it.
|
||||
static String convertUtcToLocal(String utcTimeString,
|
||||
{String format = 'dd-MM-yyyy'}) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
// expense_form_widgets.dart
|
||||
// form_widgets.dart
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher_string.dart';
|
||||
|
||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
|
||||
/// 🔹 Common Colors & Styles
|
||||
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
|
||||
@ -68,6 +67,7 @@ class CustomTextField extends StatelessWidget {
|
||||
final int maxLines;
|
||||
final TextInputType keyboardType;
|
||||
final String? Function(String?)? validator;
|
||||
final Widget? suffixIcon;
|
||||
|
||||
const CustomTextField({
|
||||
required this.controller,
|
||||
@ -75,8 +75,9 @@ class CustomTextField extends StatelessWidget {
|
||||
this.maxLines = 1,
|
||||
this.keyboardType = TextInputType.text,
|
||||
this.validator,
|
||||
this.suffixIcon,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
}) ;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -92,6 +93,7 @@ class CustomTextField extends StatelessWidget {
|
||||
fillColor: Colors.grey.shade100,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||
suffixIcon: suffixIcon,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
@ -161,9 +163,10 @@ class TileContainer extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// ==========================
|
||||
/// Attachments Section
|
||||
/// Attachments Section (Reusable)
|
||||
/// ==========================
|
||||
class AttachmentsSection extends StatelessWidget {
|
||||
class AttachmentsSection<T extends GetxController> extends StatelessWidget {
|
||||
final T controller; // 🔹 Now any controller can be passed
|
||||
final RxList<File> attachments;
|
||||
final RxList<Map<String, dynamic>> existingAttachments;
|
||||
final ValueChanged<File> onRemoveNew;
|
||||
@ -171,6 +174,7 @@ class AttachmentsSection extends StatelessWidget {
|
||||
final VoidCallback onAdd;
|
||||
|
||||
const AttachmentsSection({
|
||||
required this.controller,
|
||||
required this.attachments,
|
||||
required this.existingAttachments,
|
||||
required this.onRemoveNew,
|
||||
@ -239,8 +243,20 @@ class AttachmentsSection extends StatelessWidget {
|
||||
),
|
||||
)),
|
||||
_buildActionTile(Icons.attach_file, onAdd),
|
||||
_buildActionTile(Icons.camera_alt,
|
||||
() => Get.find<AddExpenseController>().pickFromCamera()),
|
||||
_buildActionTile(Icons.camera_alt, () {
|
||||
// 🔹 Dynamically call pickFromCamera if it exists
|
||||
final fn = controller as dynamic;
|
||||
if (fn.pickFromCamera != null) {
|
||||
fn.pickFromCamera();
|
||||
} else {
|
||||
showAppSnackbar(
|
||||
title: 'Error',
|
||||
message:
|
||||
'This controller does not support camera attachments.',
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
@ -402,7 +418,6 @@ class _AttachmentTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// map extensions to icons/colors
|
||||
static (IconData, Color) _fileIcon(String ext) {
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
|
||||
@ -32,7 +32,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offNamed('/dashboard'),
|
||||
onPressed: () => Get.offNamed('/dashboard/finance'),
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
|
||||
@ -33,6 +33,229 @@ class SkeletonLoaders {
|
||||
);
|
||||
}
|
||||
|
||||
// Inside SkeletonLoaders class
|
||||
static Widget paymentRequestListSkeletonLoader() {
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||
itemCount: 6,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(color: Colors.grey.shade300, height: 20),
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Category name placeholder
|
||||
Container(
|
||||
height: 14,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Payee placeholder
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 12,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Due date and status placeholders
|
||||
Row(
|
||||
children: [
|
||||
// Due date label + value
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
height: 12,
|
||||
width: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade200,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
// Status chip placeholder
|
||||
Container(
|
||||
height: 20,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Add this inside SkeletonLoaders class
|
||||
static Widget paymentRequestDetailSkeletonLoader() {
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 30),
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: MyCard.bordered(
|
||||
paddingAll: 16,
|
||||
borderRadiusAll: 8,
|
||||
shadow: MyShadow(elevation: 3),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header (Created At + Status)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Container(
|
||||
width: 140,
|
||||
height: 16,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 20,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
MySpacing.height(24),
|
||||
|
||||
// Parties Section
|
||||
...List.generate(
|
||||
4,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Container(
|
||||
height: 14,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
)),
|
||||
MySpacing.height(24),
|
||||
|
||||
// Details Table
|
||||
...List.generate(
|
||||
6,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Container(
|
||||
height: 14,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
)),
|
||||
MySpacing.height(24),
|
||||
|
||||
// Documents Section
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Container(
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
)),
|
||||
),
|
||||
MySpacing.height(24),
|
||||
|
||||
// Logs / Timeline
|
||||
Column(
|
||||
children: List.generate(
|
||||
3,
|
||||
(index) => Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 120,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: double.infinity,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(6),
|
||||
Container(
|
||||
height: 12,
|
||||
width: 80,
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
MySpacing.height(16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
// Employee Detail Skeleton Loader
|
||||
static Widget employeeDetailSkeletonLoader() {
|
||||
return SingleChildScrollView(
|
||||
|
||||
@ -457,6 +457,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
||||
attachments: controller.attachments,
|
||||
existingAttachments: controller.existingAttachments,
|
||||
onRemoveNew: controller.removeAttachment,
|
||||
controller: controller,
|
||||
onRemoveExisting: (item) async {
|
||||
await showDialog(
|
||||
context: context,
|
||||
|
||||
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/finance/finance_screen.dart';
|
||||
import 'package:marco/view/finance/advance_payment_screen.dart';
|
||||
import 'package:marco/view/finance/payment_request_screen.dart';
|
||||
class AuthMiddleware extends GetMiddleware {
|
||||
@override
|
||||
RouteSettings? redirect(String? route) {
|
||||
@ -91,6 +92,15 @@ getPageRoute() {
|
||||
name: '/dashboard/document-main-page',
|
||||
page: () => UserDocumentsPage(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Finance
|
||||
GetPage(
|
||||
name: '/dashboard/finance',
|
||||
page: () => FinanceScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
GetPage(
|
||||
name: '/dashboard/payment-request',
|
||||
page: () => PaymentRequestMainScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Authentication
|
||||
GetPage(name: '/auth/login', page: () => LoginScreen()),
|
||||
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
|
||||
@ -124,7 +134,7 @@ getPageRoute() {
|
||||
),
|
||||
// Advance Payment
|
||||
GetPage(
|
||||
name: '/dashboard/finance/advance-payment',
|
||||
name: '/dashboard/advance-payment',
|
||||
page: () => AdvancePaymentScreen(),
|
||||
middlewares: [AuthMiddleware()],
|
||||
),
|
||||
|
||||
@ -20,6 +20,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||
late final AdvancePaymentController controller;
|
||||
late final TextEditingController _searchCtrl;
|
||||
final FocusNode _searchFocus = FocusNode();
|
||||
final projectController = Get.find<ProjectController>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -48,12 +49,11 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final projectController = Get.find<ProjectController>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(
|
||||
0xFFF5F5F5), // ✅ light grey background (Expense screen style)
|
||||
appBar: _buildAppBar(projectController),
|
||||
0xFFF5F5F5),
|
||||
appBar: _buildAppBar(),
|
||||
body: GestureDetector(
|
||||
onTap: () => FocusScope.of(context).unfocus(),
|
||||
child: RefreshIndicator(
|
||||
@ -63,15 +63,15 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||
await controller.fetchAdvancePayments(emp.id.toString());
|
||||
}
|
||||
},
|
||||
color: Colors.white, // spinner color
|
||||
backgroundColor: Colors.blue, // circle background color
|
||||
color: Colors.white,
|
||||
backgroundColor: contentTheme.primary,
|
||||
strokeWidth: 2.5,
|
||||
displacement: 60,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Container(
|
||||
color:
|
||||
const Color(0xFFF5F5F5), // ✅ match background inside scroll
|
||||
const Color(0xFFF5F5F5),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSearchBar(),
|
||||
@ -88,54 +88,62 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
||||
}
|
||||
|
||||
// ---------------- AppBar ----------------
|
||||
PreferredSizeWidget _buildAppBar(ProjectController projectController) {
|
||||
return AppBar(
|
||||
backgroundColor: Colors.grey[100],
|
||||
elevation: 0.5,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offNamed('/dashboard/finance'),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleLarge("Advance Payment",
|
||||
fontWeight: 700, color: Colors.black),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<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],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
PreferredSizeWidget _buildAppBar() {
|
||||
return PreferredSize(
|
||||
preferredSize: const Size.fromHeight(72),
|
||||
child: AppBar(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
elevation: 0.5,
|
||||
automaticallyImplyLeading: false,
|
||||
titleSpacing: 0,
|
||||
title: Padding(
|
||||
padding: MySpacing.xy(16, 0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.black, size: 20),
|
||||
onPressed: () => Get.offNamed('/dashboard/finance'),
|
||||
),
|
||||
),
|
||||
],
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
MyText.titleLarge(
|
||||
'Advance Payments',
|
||||
fontWeight: 700,
|
||||
color: Colors.black,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
GetBuilder<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],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.end,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
const Text(
|
||||
"Current Balance : ",
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/project_controller.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/my_card.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
|
||||
@ -106,484 +107,116 @@ class _FinanceScreenState extends State<FinanceScreen>
|
||||
opacity: _fadeAnimation,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildWelcomeSection(),
|
||||
MySpacing.height(24),
|
||||
_buildFinanceModules(),
|
||||
MySpacing.height(24),
|
||||
_buildQuickStatsSection(),
|
||||
],
|
||||
),
|
||||
child: _buildFinanceModulesCompact(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWelcomeSection() {
|
||||
final projectSelected = projectController.selectedProject != null;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
contentTheme.primary.withValues(alpha: 0.1),
|
||||
contentTheme.info.withValues(alpha: 0.05),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: contentTheme.primary.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: contentTheme.primary.withValues(alpha: 0.1),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.landmark,
|
||||
color: contentTheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleMedium(
|
||||
'Financial Management',
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
MySpacing.height(2),
|
||||
MyText.bodySmall(
|
||||
projectSelected
|
||||
? 'Manage your project finances'
|
||||
: 'Select a project to get started',
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!projectSelected) ...[
|
||||
MySpacing.height(12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.badge_alert,
|
||||
size: 16,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: MyText.bodySmall(
|
||||
'Please select a project to access finance modules',
|
||||
color: Colors.orange[700],
|
||||
fontWeight: 500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFinanceModules() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleMedium(
|
||||
'Finance Modules',
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.bodySmall(
|
||||
'Select a module to manage',
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_buildModuleGrid(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModuleGrid() {
|
||||
// --- Finance Modules (Compact Dashboard-style) ---
|
||||
Widget _buildFinanceModulesCompact() {
|
||||
final stats = [
|
||||
_FinanceStatItem(
|
||||
LucideIcons.badge_dollar_sign,
|
||||
"Expense",
|
||||
"Track and manage expenses",
|
||||
contentTheme.info,
|
||||
"/dashboard/expense-main-page",
|
||||
),
|
||||
_FinanceStatItem(
|
||||
LucideIcons.receipt_text,
|
||||
"Payment Request",
|
||||
"Submit payment requests",
|
||||
contentTheme.primary,
|
||||
"/dashboard/payment-request",
|
||||
),
|
||||
_FinanceStatItem(
|
||||
LucideIcons.wallet,
|
||||
"Advance Payment",
|
||||
"Manage advance payments",
|
||||
contentTheme.warning,
|
||||
"/dashboard/finance/advance-payment",
|
||||
),
|
||||
_FinanceStatItem(LucideIcons.badge_dollar_sign, "Expense",
|
||||
contentTheme.info, "/dashboard/expense-main-page"),
|
||||
_FinanceStatItem(LucideIcons.receipt_text, "Payment Request",
|
||||
contentTheme.primary, "/dashboard/payment-request"),
|
||||
_FinanceStatItem(LucideIcons.wallet, "Advance Payment",
|
||||
contentTheme.warning, "/dashboard/advance-payment"),
|
||||
];
|
||||
|
||||
final projectSelected = projectController.selectedProject != null;
|
||||
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
childAspectRatio: 1.1,
|
||||
),
|
||||
itemCount: stats.length,
|
||||
itemBuilder: (context, index) {
|
||||
return _buildModernFinanceCard(
|
||||
stats[index],
|
||||
projectSelected,
|
||||
index,
|
||||
);
|
||||
},
|
||||
);
|
||||
return LayoutBuilder(builder: (context, constraints) {
|
||||
int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
|
||||
double cardWidth =
|
||||
(constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
|
||||
|
||||
return Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.end,
|
||||
children: stats
|
||||
.map((stat) =>
|
||||
_buildFinanceModuleCard(stat, projectSelected, cardWidth))
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildModernFinanceCard(
|
||||
_FinanceStatItem statItem,
|
||||
bool isProjectSelected,
|
||||
int index,
|
||||
) {
|
||||
Widget _buildFinanceModuleCard(
|
||||
_FinanceStatItem stat, bool isProjectSelected, double width) {
|
||||
final bool isEnabled = isProjectSelected;
|
||||
|
||||
return TweenAnimationBuilder<double>(
|
||||
duration: Duration(milliseconds: 400 + (index * 100)),
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) {
|
||||
return Transform.scale(
|
||||
scale: value,
|
||||
child: Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.5,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => _onCardTap(statItem, isEnabled),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isEnabled
|
||||
? statItem.color.withValues(alpha: 0.2)
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Content
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Icon(
|
||||
statItem.icon,
|
||||
size: 28,
|
||||
color: statItem.color,
|
||||
),
|
||||
),
|
||||
MySpacing.height(12),
|
||||
MyText.titleSmall(
|
||||
statItem.title,
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
if (isEnabled)
|
||||
Row(
|
||||
children: [
|
||||
MyText.bodySmall(
|
||||
'View Details',
|
||||
color: statItem.color,
|
||||
fontWeight: 600,
|
||||
fontSize: 11,
|
||||
),
|
||||
MySpacing.width(4),
|
||||
Icon(
|
||||
LucideIcons.arrow_right,
|
||||
size: 14,
|
||||
color: statItem.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Lock icon for disabled state
|
||||
if (!isEnabled)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.lock,
|
||||
size: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickStatsSection() {
|
||||
final projectSelected = projectController.selectedProject != null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.titleMedium(
|
||||
'Quick Stats',
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.bodySmall(
|
||||
'Overview of your finances',
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
MySpacing.height(16),
|
||||
_buildStatsRow(projectSelected),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatsRow(bool projectSelected) {
|
||||
final stats = [
|
||||
_QuickStat(
|
||||
icon: LucideIcons.trending_up,
|
||||
label: 'Total Expenses',
|
||||
value: projectSelected ? '₹0' : '--',
|
||||
color: contentTheme.danger,
|
||||
),
|
||||
_QuickStat(
|
||||
icon: LucideIcons.clock,
|
||||
label: 'Pending',
|
||||
value: projectSelected ? '0' : '--',
|
||||
color: contentTheme.warning,
|
||||
),
|
||||
];
|
||||
|
||||
return Row(
|
||||
children: stats
|
||||
.map((stat) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildStatCard(stat, projectSelected),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatCard(_QuickStat stat, bool isEnabled) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: stat.color.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: stat.color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
stat.icon,
|
||||
size: 20,
|
||||
color: stat.color,
|
||||
),
|
||||
),
|
||||
MySpacing.height(12),
|
||||
MyText.bodySmall(
|
||||
stat.label,
|
||||
color: Colors.grey[600],
|
||||
fontSize: 11,
|
||||
),
|
||||
MySpacing.height(4),
|
||||
MyText.titleLarge(
|
||||
stat.value,
|
||||
fontWeight: 700,
|
||||
color: isEnabled ? Colors.black87 : Colors.grey[400],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
|
||||
if (!isEnabled) {
|
||||
Get.dialog(
|
||||
Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
return Opacity(
|
||||
opacity: isEnabled ? 1.0 : 0.4,
|
||||
child: IgnorePointer(
|
||||
ignoring: !isEnabled,
|
||||
child: InkWell(
|
||||
onTap: () => _onCardTap(stat, isEnabled),
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: MyCard.bordered(
|
||||
width: width,
|
||||
height: 60,
|
||||
paddingAll: 4,
|
||||
borderRadiusAll: 5,
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
color: stat.color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Icon(
|
||||
LucideIcons.badge_alert,
|
||||
color: Colors.orange[700],
|
||||
size: 32,
|
||||
stat.icon,
|
||||
size: 16,
|
||||
color: stat.color,
|
||||
),
|
||||
),
|
||||
MySpacing.height(16),
|
||||
MyText.titleMedium(
|
||||
"No Project Selected",
|
||||
fontWeight: 700,
|
||||
color: Colors.black87,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
MySpacing.height(8),
|
||||
MyText.bodyMedium(
|
||||
"Please select a project before accessing this section.",
|
||||
color: Colors.grey[600],
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
MySpacing.height(24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: contentTheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: MyText.bodyMedium(
|
||||
"OK",
|
||||
color: Colors.white,
|
||||
fontWeight: 600,
|
||||
MySpacing.height(4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
stat.title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 10,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
maxLines: 2,
|
||||
softWrap: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Get.toNamed(statItem.route);
|
||||
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText: "Please select a project before accessing this section.",
|
||||
confirm: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text("OK"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
Get.toNamed(statItem.route);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _FinanceStatItem {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final String route;
|
||||
|
||||
_FinanceStatItem(
|
||||
this.icon,
|
||||
this.title,
|
||||
this.subtitle,
|
||||
this.color,
|
||||
this.route,
|
||||
);
|
||||
}
|
||||
|
||||
class _QuickStat {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
_QuickStat({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
_FinanceStatItem(this.icon, this.title, this.color, this.route);
|
||||
}
|
||||
|
||||
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
|
||||
device_info_plus: ^11.3.0
|
||||
flutter_local_notifications: 19.4.0
|
||||
equatable: ^2.0.7
|
||||
mime: ^2.0.0
|
||||
timeago: ^3.7.1
|
||||
|
||||
timeline_tile: ^2.0.0
|
||||
dev_dependencies:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user