added payment request screens

This commit is contained in:
Vaibhav Surve 2025-11-08 16:18:26 +05:30
parent 1070f04d1a
commit 92f7fec083
27 changed files with 5192 additions and 506 deletions

View 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();
}
}

View 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();
}
}

View 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;
}
}
}

View File

@ -3,9 +3,28 @@ class ApiEndpoints {
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories =
"/Master/expenses-categories";
static const String getExpensePaymentRequestPayee =
"/Expense/payment-request/payee";
// Dashboard Module API Endpoints // Dashboard Module API Endpoints
static const String getDashboardAttendanceOverview = static const String getDashboardAttendanceOverview =
"/dashboard/attendance-overview"; "/dashboard/attendance-overview";
static const String createExpensePaymentRequest =
"/expense/payment-request/create";
static const String getExpensePaymentRequestList =
"/Expense/get/payment-requests/list";
static const String getExpensePaymentRequestDetails =
"/Expense/get/payment-request/details";
static const String getExpensePaymentRequestFilter =
"/Expense/payment-request/filter";
static const String updateExpensePaymentRequestStatus =
"/Expense/payment-request/action";
static const String createExpenseforPR =
"/Expense/payment-request/expense/create";
static const String getDashboardProjectProgress = "/dashboard/progression"; static const String getDashboardProjectProgress = "/dashboard/progression";
static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardTeams = "/dashboard/teams";

View File

@ -26,6 +26,12 @@ import 'package:marco/model/all_organization_model.dart';
import 'package:marco/model/dashboard/pending_expenses_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart';
import 'package:marco/model/dashboard/expense_type_report_model.dart'; import 'package:marco/model/dashboard/expense_type_report_model.dart';
import 'package:marco/model/dashboard/monthly_expence_model.dart'; import 'package:marco/model/dashboard/monthly_expence_model.dart';
import 'package:marco/model/finance/expense_category_model.dart';
import 'package:marco/model/finance/currency_list_model.dart';
import 'package:marco/model/finance/payment_payee_request_model.dart';
import 'package:marco/model/finance/payment_request_list_model.dart';
import 'package:marco/model/finance/payment_request_filter.dart';
import 'package:marco/model/finance/payment_request_details_model.dart';
import 'package:marco/model/finance/advance_payment_model.dart'; import 'package:marco/model/finance/advance_payment_model.dart';
class ApiService { class ApiService {
@ -296,6 +302,378 @@ class ApiService {
} }
} }
/// Create Expense for Payment Request
static Future<bool> createExpenseForPRApi({
required String paymentModeId,
required String location,
required String gstNumber,
required String paymentRequestId,
List<Map<String, dynamic>> billAttachments = const [],
}) async {
const endpoint = ApiEndpoints.createExpenseforPR;
final body = {
"paymentModeId": paymentModeId,
"location": location,
"gstNumber": gstNumber,
"paymentRequestId": paymentRequestId,
"billAttachments": billAttachments,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Expense for PR failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Create Expense for PR response status: ${response.statusCode}");
logSafe("Create Expense for PR response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe(
"Expense for Payment Request created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Expense for Payment Request: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createExpenseForPRApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Update Expense Payment Request Status
static Future<bool> updateExpensePaymentRequestStatusApi({
required String paymentRequestId,
required String statusId,
required String comment,
String? paidTransactionId,
String? paidById,
DateTime? paidAt,
double? baseAmount,
double? taxAmount,
String? tdsPercentage,
}) async {
const endpoint = ApiEndpoints.updateExpensePaymentRequestStatus;
logSafe("Updating Payment Request Status for ID: $paymentRequestId");
final body = {
"paymentRequestId": paymentRequestId,
"statusId": statusId,
"comment": comment,
"paidTransactionId": paidTransactionId,
"paidById": paidById,
"paidAt": paidAt?.toIso8601String(),
"baseAmount": baseAmount,
"taxAmount": taxAmount,
"tdsPercentage": tdsPercentage ?? "0",
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Update Payment Request Status failed: null response",
level: LogLevel.error);
return false;
}
logSafe(
"Update Payment Request Status response: ${response.statusCode} -> ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Payment Request status updated successfully!");
return true;
} else {
logSafe(
"Failed to update Payment Request Status: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during updateExpensePaymentRequestStatusApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Expense Payment Request Detail by ID
static Future<PaymentRequestDetail?> getExpensePaymentRequestDetailApi(
String paymentRequestId) async {
final endpoint =
"${ApiEndpoints.getExpensePaymentRequestDetails}/$paymentRequestId";
logSafe(
"Fetching Expense Payment Request Detail for ID: $paymentRequestId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Detail request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request Detail",
);
if (jsonResponse != null) {
return PaymentRequestDetail.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestDetailApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
static Future<PaymentRequestFilter?>
getExpensePaymentRequestFilterApi() async {
const endpoint = ApiEndpoints.getExpensePaymentRequestFilter;
logSafe("Fetching Expense Payment Request Filter");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Filter request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request Filter",
);
if (jsonResponse != null) {
return PaymentRequestFilter.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestFilterApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Expense Payment Request List
static Future<PaymentRequestResponse?> getExpensePaymentRequestListApi({
bool isActive = true,
int pageSize = 20,
int pageNumber = 1,
Map<String, dynamic>? filter,
String searchString = '',
}) async {
const endpoint = ApiEndpoints.getExpensePaymentRequestList;
logSafe("Fetching Expense Payment Request List");
try {
final queryParams = {
'isActive': isActive.toString(),
'pageSize': pageSize.toString(),
'pageNumber': pageNumber.toString(),
'filter': jsonEncode(filter ??
{
"projectIds": [],
"statusIds": [],
"createdByIds": [],
"currencyIds": [],
"expenseCategoryIds": [],
"payees": [],
"startDate": null,
"endDate": null
}),
'searchString': searchString,
};
final response = await _getRequest(endpoint, queryParams: queryParams);
if (response == null) {
logSafe("Expense Payment Request List request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(
response,
label: "Expense Payment Request List",
);
if (jsonResponse != null) {
return PaymentRequestResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestListApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Create Expense Payment Request (Project API style)
static Future<bool> createExpensePaymentRequestApi({
required String title,
required String projectId,
required String expenseCategoryId,
required String currencyId,
required String payee,
required double amount,
DateTime? dueDate,
required String description,
required bool isAdvancePayment,
List<Map<String, dynamic>> billAttachments = const [],
}) async {
const endpoint = ApiEndpoints.createExpensePaymentRequest;
final body = {
"title": title,
"projectId": projectId,
"expenseCategoryId": expenseCategoryId,
"currencyId": currencyId,
"payee": payee,
"amount": amount,
"dueDate": dueDate?.toIso8601String(),
"description": description,
"isAdvancePayment": isAdvancePayment,
"billAttachments": billAttachments,
};
try {
final response = await _postRequest(endpoint, body);
if (response == null) {
logSafe("Create Payment Request failed: null response",
level: LogLevel.error);
return false;
}
logSafe("Create Payment Request response status: ${response.statusCode}");
logSafe("Create Payment Request response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Payment Request created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create Payment Request: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
return false;
}
} catch (e, stack) {
logSafe("Exception during createExpensePaymentRequestApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
}
}
/// Get Master Currencies
static Future<CurrencyListResponse?> getMasterCurrenciesApi() async {
const endpoint = ApiEndpoints.getMasterCurrencies;
logSafe("Fetching Master Currencies");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Master Currencies request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Master Currencies");
if (jsonResponse != null) {
return CurrencyListResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterCurrenciesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Master Expense Categories
static Future<ExpenseCategoryResponse?>
getMasterExpenseCategoriesApi() async {
const endpoint = ApiEndpoints.getMasterExpensesCategories;
logSafe("Fetching Master Expense Categories");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Master Expense Categories request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Master Expense Categories");
if (jsonResponse != null) {
return ExpenseCategoryResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getMasterExpenseCategoriesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Expense Payment Request Payee
static Future<PaymentRequestPayeeResponse?>
getExpensePaymentRequestPayeeApi() async {
const endpoint = ApiEndpoints.getExpensePaymentRequestPayee;
logSafe("Fetching Expense Payment Request Payees");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense Payment Request Payee request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse = _parseResponseForAllData(response,
label: "Expense Payment Request Payee");
if (jsonResponse != null) {
return PaymentRequestPayeeResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getExpensePaymentRequestPayeeApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Get Monthly Expense Report (categoryId is optional) /// Get Monthly Expense Report (categoryId is optional)
static Future<DashboardMonthlyExpenseResponse?> static Future<DashboardMonthlyExpenseResponse?>
getDashboardMonthlyExpensesApi({ getDashboardMonthlyExpensesApi({
@ -411,6 +789,58 @@ class ApiService {
return null; return null;
} }
/// Create Project API
static Future<bool> createProjectApi({
required String name,
required String projectAddress,
required String shortName,
required String contactPerson,
required DateTime startDate,
required DateTime endDate,
required String projectStatusId,
}) async {
const endpoint = ApiEndpoints.createProject;
logSafe("Creating project: $name");
final Map<String, dynamic> payload = {
"name": name,
"projectAddress": projectAddress,
"shortName": shortName,
"contactPerson": contactPerson,
"startDate": startDate.toIso8601String(),
"endDate": endDate.toIso8601String(),
"projectStatusId": projectStatusId,
};
try {
final response =
await _postRequest(endpoint, payload, customTimeout: extendedTimeout);
if (response == null) {
logSafe("Create project failed: null response", level: LogLevel.error);
return false;
}
logSafe("Create project response status: ${response.statusCode}");
logSafe("Create project response body: ${response.body}");
final json = jsonDecode(response.body);
if (json['success'] == true) {
logSafe("Project created successfully: ${json['data']}");
return true;
} else {
logSafe(
"Failed to create project: ${json['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} catch (e, stack) {
logSafe("Exception during createProjectApi: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return false;
}
/// Get Organizations assigned to a Project /// Get Organizations assigned to a Project
static Future<OrganizationListResponse?> getAssignedOrganizations( static Future<OrganizationListResponse?> getAssignedOrganizations(

View File

@ -1,6 +1,9 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
class DateTimeUtils { class DateTimeUtils {
/// Default date format
static const String defaultFormat = 'dd MMM yyyy';
/// Converts a UTC datetime string to local time and formats it. /// Converts a UTC datetime string to local time and formats it.
static String convertUtcToLocal(String utcTimeString, static String convertUtcToLocal(String utcTimeString,
{String format = 'dd-MM-yyyy'}) { {String format = 'dd-MM-yyyy'}) {

View File

@ -1,4 +1,4 @@
// expense_form_widgets.dart // form_widgets.dart
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
@ -6,7 +6,6 @@ import 'package:url_launcher/url_launcher_string.dart';
import 'package:marco/helpers/widgets/image_viewer_dialog.dart'; import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/controller/expense/add_expense_controller.dart';
/// 🔹 Common Colors & Styles /// 🔹 Common Colors & Styles
final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]); final _hintStyle = TextStyle(fontSize: 14, color: Colors.grey[600]);
@ -68,6 +67,7 @@ class CustomTextField extends StatelessWidget {
final int maxLines; final int maxLines;
final TextInputType keyboardType; final TextInputType keyboardType;
final String? Function(String?)? validator; final String? Function(String?)? validator;
final Widget? suffixIcon;
const CustomTextField({ const CustomTextField({
required this.controller, required this.controller,
@ -75,8 +75,9 @@ class CustomTextField extends StatelessWidget {
this.maxLines = 1, this.maxLines = 1,
this.keyboardType = TextInputType.text, this.keyboardType = TextInputType.text,
this.validator, this.validator,
this.suffixIcon,
Key? key, Key? key,
}) : super(key: key); }) ;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -92,6 +93,7 @@ class CustomTextField extends StatelessWidget {
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14), const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
suffixIcon: suffixIcon,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
@ -161,9 +163,10 @@ class TileContainer extends StatelessWidget {
} }
/// ========================== /// ==========================
/// Attachments Section /// Attachments Section (Reusable)
/// ========================== /// ==========================
class AttachmentsSection extends StatelessWidget { class AttachmentsSection<T extends GetxController> extends StatelessWidget {
final T controller; // 🔹 Now any controller can be passed
final RxList<File> attachments; final RxList<File> attachments;
final RxList<Map<String, dynamic>> existingAttachments; final RxList<Map<String, dynamic>> existingAttachments;
final ValueChanged<File> onRemoveNew; final ValueChanged<File> onRemoveNew;
@ -171,6 +174,7 @@ class AttachmentsSection extends StatelessWidget {
final VoidCallback onAdd; final VoidCallback onAdd;
const AttachmentsSection({ const AttachmentsSection({
required this.controller,
required this.attachments, required this.attachments,
required this.existingAttachments, required this.existingAttachments,
required this.onRemoveNew, required this.onRemoveNew,
@ -239,8 +243,20 @@ class AttachmentsSection extends StatelessWidget {
), ),
)), )),
_buildActionTile(Icons.attach_file, onAdd), _buildActionTile(Icons.attach_file, onAdd),
_buildActionTile(Icons.camera_alt, _buildActionTile(Icons.camera_alt, () {
() => Get.find<AddExpenseController>().pickFromCamera()), // 🔹 Dynamically call pickFromCamera if it exists
final fn = controller as dynamic;
if (fn.pickFromCamera != null) {
fn.pickFromCamera();
} else {
showAppSnackbar(
title: 'Error',
message:
'This controller does not support camera attachments.',
type: SnackbarType.error,
);
}
}),
], ],
), ),
], ],
@ -402,7 +418,6 @@ class _AttachmentTile extends StatelessWidget {
); );
} }
/// map extensions to icons/colors
static (IconData, Color) _fileIcon(String ext) { static (IconData, Color) _fileIcon(String ext) {
switch (ext) { switch (ext) {
case 'pdf': case 'pdf':

View File

@ -32,7 +32,7 @@ class ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20), color: Colors.black, size: 20),
onPressed: () => Get.offNamed('/dashboard'), onPressed: () => Get.offNamed('/dashboard/finance'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(

View File

@ -33,6 +33,229 @@ class SkeletonLoaders {
); );
} }
// Inside SkeletonLoaders class
static Widget paymentRequestListSkeletonLoader() {
return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: 6,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Category name placeholder
Container(
height: 14,
width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 6),
// Payee placeholder
Row(
children: [
Container(
height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 8),
Expanded(
child: Container(
height: 12,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
),
],
),
const SizedBox(height: 6),
// Due date and status placeholders
Row(
children: [
// Due date label + value
Row(
children: [
Container(
height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(width: 6),
Container(
height: 12,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
),
const Spacer(),
// Status chip placeholder
Container(
height: 20,
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
),
],
),
);
},
);
}
// Add this inside SkeletonLoaders class
static Widget paymentRequestDetailSkeletonLoader() {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 30),
child: Center(
child: Container(
constraints: const BoxConstraints(maxWidth: 520),
child: MyCard.bordered(
paddingAll: 16,
borderRadiusAll: 8,
shadow: MyShadow(elevation: 3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header (Created At + Status)
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
width: 140,
height: 16,
color: Colors.grey.shade300,
),
Container(
width: 80,
height: 20,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
],
),
MySpacing.height(24),
// Parties Section
...List.generate(
4,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 14,
width: double.infinity,
color: Colors.grey.shade300,
),
)),
MySpacing.height(24),
// Details Table
...List.generate(
6,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 14,
width: double.infinity,
color: Colors.grey.shade300,
),
)),
MySpacing.height(24),
// Documents Section
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Container(
height: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
),
),
)),
),
MySpacing.height(24),
// Logs / Timeline
Column(
children: List.generate(
3,
(index) => Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14,
width: 120,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 12,
width: double.infinity,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 12,
width: 80,
color: Colors.grey.shade300,
),
MySpacing.height(16),
],
),
),
],
)),
),
],
),
),
),
),
);
}
// Employee Detail Skeleton Loader // Employee Detail Skeleton Loader
static Widget employeeDetailSkeletonLoader() { static Widget employeeDetailSkeletonLoader() {
return SingleChildScrollView( return SingleChildScrollView(

View File

@ -457,6 +457,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
attachments: controller.attachments, attachments: controller.attachments,
existingAttachments: controller.existingAttachments, existingAttachments: controller.existingAttachments,
onRemoveNew: controller.removeAttachment, onRemoveNew: controller.removeAttachment,
controller: controller,
onRemoveExisting: (item) async { onRemoveExisting: (item) async {
await showDialog( await showDialog(
context: context, context: context,

View 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;
}
}

View 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,
};
}
}

View 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,
};
}
}

View 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;
}
}

View 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,
};
}

View 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(),
};
}
}

View 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,
};
}

View 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,
};
}

View 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)),
],
),
),
),
],
);
}
}

View 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,
};
}

View 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"),
),
],
),
);
});
}
}

View File

@ -23,6 +23,7 @@ import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/tenant/tenant_selection_screen.dart'; import 'package:marco/view/tenant/tenant_selection_screen.dart';
import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/finance_screen.dart';
import 'package:marco/view/finance/advance_payment_screen.dart'; import 'package:marco/view/finance/advance_payment_screen.dart';
import 'package:marco/view/finance/payment_request_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
RouteSettings? redirect(String? route) { RouteSettings? redirect(String? route) {
@ -91,6 +92,15 @@ getPageRoute() {
name: '/dashboard/document-main-page', name: '/dashboard/document-main-page',
page: () => UserDocumentsPage(), page: () => UserDocumentsPage(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
// Finance
GetPage(
name: '/dashboard/finance',
page: () => FinanceScreen(),
middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]),
// Authentication // Authentication
GetPage(name: '/auth/login', page: () => LoginScreen()), GetPage(name: '/auth/login', page: () => LoginScreen()),
GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()), GetPage(name: '/auth/login-option', page: () => LoginOptionScreen()),
@ -124,7 +134,7 @@ getPageRoute() {
), ),
// Advance Payment // Advance Payment
GetPage( GetPage(
name: '/dashboard/finance/advance-payment', name: '/dashboard/advance-payment',
page: () => AdvancePaymentScreen(), page: () => AdvancePaymentScreen(),
middlewares: [AuthMiddleware()], middlewares: [AuthMiddleware()],
), ),

View File

@ -20,6 +20,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
late final AdvancePaymentController controller; late final AdvancePaymentController controller;
late final TextEditingController _searchCtrl; late final TextEditingController _searchCtrl;
final FocusNode _searchFocus = FocusNode(); final FocusNode _searchFocus = FocusNode();
final projectController = Get.find<ProjectController>();
@override @override
void initState() { void initState() {
@ -48,12 +49,11 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final projectController = Get.find<ProjectController>();
return Scaffold( return Scaffold(
backgroundColor: const Color( backgroundColor: const Color(
0xFFF5F5F5), // light grey background (Expense screen style) 0xFFF5F5F5),
appBar: _buildAppBar(projectController), appBar: _buildAppBar(),
body: GestureDetector( body: GestureDetector(
onTap: () => FocusScope.of(context).unfocus(), onTap: () => FocusScope.of(context).unfocus(),
child: RefreshIndicator( child: RefreshIndicator(
@ -63,15 +63,15 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
await controller.fetchAdvancePayments(emp.id.toString()); await controller.fetchAdvancePayments(emp.id.toString());
} }
}, },
color: Colors.white, // spinner color color: Colors.white,
backgroundColor: Colors.blue, // circle background color backgroundColor: contentTheme.primary,
strokeWidth: 2.5, strokeWidth: 2.5,
displacement: 60, displacement: 60,
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
child: Container( child: Container(
color: color:
const Color(0xFFF5F5F5), // match background inside scroll const Color(0xFFF5F5F5),
child: Column( child: Column(
children: [ children: [
_buildSearchBar(), _buildSearchBar(),
@ -88,54 +88,62 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
} }
// ---------------- AppBar ---------------- // ---------------- AppBar ----------------
PreferredSizeWidget _buildAppBar(ProjectController projectController) { PreferredSizeWidget _buildAppBar() {
return AppBar( return PreferredSize(
backgroundColor: Colors.grey[100], preferredSize: const Size.fromHeight(72),
elevation: 0.5, child: AppBar(
automaticallyImplyLeading: false, backgroundColor: const Color(0xFFF5F5F5),
titleSpacing: 0, elevation: 0.5,
title: Padding( automaticallyImplyLeading: false,
padding: const EdgeInsets.symmetric(horizontal: 12), titleSpacing: 0,
child: Row( title: Padding(
children: [ padding: MySpacing.xy(16, 0),
IconButton( child: Row(
icon: const Icon(Icons.arrow_back_ios_new, crossAxisAlignment: CrossAxisAlignment.center,
color: Colors.black, size: 20), children: [
onPressed: () => Get.offNamed('/dashboard/finance'), IconButton(
), icon: const Icon(Icons.arrow_back_ios_new,
Expanded( color: Colors.black, size: 20),
child: Column( onPressed: () => Get.offNamed('/dashboard/finance'),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge("Advance Payment",
fontWeight: 700, color: Colors.black),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final projectName =
projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
projectName,
overflow: TextOverflow.ellipsis,
fontWeight: 600,
color: Colors.grey[700],
),
),
],
);
},
),
],
), ),
), MySpacing.width(8),
], Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
MyText.titleLarge(
'Advance Payments',
fontWeight: 700,
color: Colors.black,
),
MySpacing.height(2),
GetBuilder<ProjectController>(
builder: (_) {
final name = projectController.selectedProject?.name ??
'Select Project';
return Row(
children: [
const Icon(Icons.work_outline,
size: 14, color: Colors.grey),
MySpacing.width(4),
Expanded(
child: MyText.bodySmall(
name,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
color: Colors.grey[700],
),
),
],
);
},
),
],
),
),
],
),
), ),
), ),
); );
@ -285,8 +293,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
color: Colors.grey[100], color: Colors.grey[100],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment: MainAxisAlignment.end,
MainAxisAlignment.end,
children: [ children: [
const Text( const Text(
"Current Balance : ", "Current Balance : ",

View File

@ -3,6 +3,7 @@ import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:marco/controller/project_controller.dart';
import 'package:marco/helpers/utils/mixins/ui_mixin.dart'; import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_card.dart';
import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.dart';
@ -106,484 +107,116 @@ class _FinanceScreenState extends State<FinanceScreen>
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: _buildFinanceModulesCompact(),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeSection(),
MySpacing.height(24),
_buildFinanceModules(),
MySpacing.height(24),
_buildQuickStatsSection(),
],
),
), ),
), ),
); );
} }
Widget _buildWelcomeSection() { // --- Finance Modules (Compact Dashboard-style) ---
final projectSelected = projectController.selectedProject != null; Widget _buildFinanceModulesCompact() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
contentTheme.primary.withValues(alpha: 0.1),
contentTheme.info.withValues(alpha: 0.05),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: contentTheme.primary.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: contentTheme.primary.withValues(alpha: 0.1),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Icon(
LucideIcons.landmark,
color: contentTheme.primary,
size: 24,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Financial Management',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(2),
MyText.bodySmall(
projectSelected
? 'Manage your project finances'
: 'Select a project to get started',
color: Colors.grey[600],
),
],
),
),
],
),
if (!projectSelected) ...[
MySpacing.height(12),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.3)),
),
child: Row(
children: [
Icon(
LucideIcons.badge_alert,
size: 16,
color: Colors.orange[700],
),
MySpacing.width(8),
Expanded(
child: MyText.bodySmall(
'Please select a project to access finance modules',
color: Colors.orange[700],
fontWeight: 500,
),
),
],
),
),
],
],
),
);
}
Widget _buildFinanceModules() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Finance Modules',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(4),
MyText.bodySmall(
'Select a module to manage',
color: Colors.grey[600],
),
MySpacing.height(16),
_buildModuleGrid(),
],
);
}
Widget _buildModuleGrid() {
final stats = [ final stats = [
_FinanceStatItem( _FinanceStatItem(LucideIcons.badge_dollar_sign, "Expense",
LucideIcons.badge_dollar_sign, contentTheme.info, "/dashboard/expense-main-page"),
"Expense", _FinanceStatItem(LucideIcons.receipt_text, "Payment Request",
"Track and manage expenses", contentTheme.primary, "/dashboard/payment-request"),
contentTheme.info, _FinanceStatItem(LucideIcons.wallet, "Advance Payment",
"/dashboard/expense-main-page", contentTheme.warning, "/dashboard/advance-payment"),
),
_FinanceStatItem(
LucideIcons.receipt_text,
"Payment Request",
"Submit payment requests",
contentTheme.primary,
"/dashboard/payment-request",
),
_FinanceStatItem(
LucideIcons.wallet,
"Advance Payment",
"Manage advance payments",
contentTheme.warning,
"/dashboard/finance/advance-payment",
),
]; ];
final projectSelected = projectController.selectedProject != null; final projectSelected = projectController.selectedProject != null;
return GridView.builder( return LayoutBuilder(builder: (context, constraints) {
shrinkWrap: true, int crossAxisCount = (constraints.maxWidth ~/ 80).clamp(2, 4);
physics: const NeverScrollableScrollPhysics(), double cardWidth =
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( (constraints.maxWidth - (crossAxisCount - 1) * 6) / crossAxisCount;
crossAxisCount: 2,
crossAxisSpacing: 12, return Wrap(
mainAxisSpacing: 12, spacing: 6,
childAspectRatio: 1.1, runSpacing: 6,
), alignment: WrapAlignment.end,
itemCount: stats.length, children: stats
itemBuilder: (context, index) { .map((stat) =>
return _buildModernFinanceCard( _buildFinanceModuleCard(stat, projectSelected, cardWidth))
stats[index], .toList(),
projectSelected, );
index, });
);
},
);
} }
Widget _buildModernFinanceCard( Widget _buildFinanceModuleCard(
_FinanceStatItem statItem, _FinanceStatItem stat, bool isProjectSelected, double width) {
bool isProjectSelected,
int index,
) {
final bool isEnabled = isProjectSelected; final bool isEnabled = isProjectSelected;
return TweenAnimationBuilder<double>( return Opacity(
duration: Duration(milliseconds: 400 + (index * 100)), opacity: isEnabled ? 1.0 : 0.4,
tween: Tween(begin: 0.0, end: 1.0), child: IgnorePointer(
curve: Curves.easeOutCubic, ignoring: !isEnabled,
builder: (context, value, child) { child: InkWell(
return Transform.scale( onTap: () => _onCardTap(stat, isEnabled),
scale: value, borderRadius: BorderRadius.circular(5),
child: Opacity( child: MyCard.bordered(
opacity: isEnabled ? 1.0 : 0.5, width: width,
child: Material( height: 60,
color: Colors.transparent, paddingAll: 4,
child: InkWell( borderRadiusAll: 5,
onTap: () => _onCardTap(statItem, isEnabled), border: Border.all(color: Colors.grey.withOpacity(0.15)),
borderRadius: BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isEnabled
? statItem.color.withValues(alpha: 0.2)
: Colors.grey.withValues(alpha: 0.2),
width: 1.5,
),
),
child: Stack(
children: [
// Content
Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
child: Icon(
statItem.icon,
size: 28,
color: statItem.color,
),
),
MySpacing.height(12),
MyText.titleSmall(
statItem.title,
fontWeight: 700,
color: Colors.black87,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
MySpacing.height(4),
if (isEnabled)
Row(
children: [
MyText.bodySmall(
'View Details',
color: statItem.color,
fontWeight: 600,
fontSize: 11,
),
MySpacing.width(4),
Icon(
LucideIcons.arrow_right,
size: 14,
color: statItem.color,
),
],
),
],
),
),
// Lock icon for disabled state
if (!isEnabled)
Positioned(
top: 12,
right: 12,
child: Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(6),
),
child: Icon(
LucideIcons.lock,
size: 14,
color: Colors.grey[600],
),
),
),
],
),
),
),
),
),
);
},
);
}
Widget _buildQuickStatsSection() {
final projectSelected = projectController.selectedProject != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
'Quick Stats',
fontWeight: 700,
color: Colors.black87,
),
MySpacing.height(4),
MyText.bodySmall(
'Overview of your finances',
color: Colors.grey[600],
),
MySpacing.height(16),
_buildStatsRow(projectSelected),
],
);
}
Widget _buildStatsRow(bool projectSelected) {
final stats = [
_QuickStat(
icon: LucideIcons.trending_up,
label: 'Total Expenses',
value: projectSelected ? '₹0' : '--',
color: contentTheme.danger,
),
_QuickStat(
icon: LucideIcons.clock,
label: 'Pending',
value: projectSelected ? '0' : '--',
color: contentTheme.warning,
),
];
return Row(
children: stats
.map((stat) => Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: _buildStatCard(stat, projectSelected),
),
))
.toList(),
);
}
Widget _buildStatCard(_QuickStat stat, bool isEnabled) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: stat.color.withValues(alpha: 0.2),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: stat.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
stat.icon,
size: 20,
color: stat.color,
),
),
MySpacing.height(12),
MyText.bodySmall(
stat.label,
color: Colors.grey[600],
fontSize: 11,
),
MySpacing.height(4),
MyText.titleLarge(
stat.value,
fontWeight: 700,
color: isEnabled ? Colors.black87 : Colors.grey[400],
),
],
),
);
}
void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1), color: stat.color.withOpacity(0.1),
shape: BoxShape.circle, borderRadius: BorderRadius.circular(4),
), ),
child: Icon( child: Icon(
LucideIcons.badge_alert, stat.icon,
color: Colors.orange[700], size: 16,
size: 32, color: stat.color,
), ),
), ),
MySpacing.height(16), MySpacing.height(4),
MyText.titleMedium( Flexible(
"No Project Selected", child: Text(
fontWeight: 700, stat.title,
color: Colors.black87, textAlign: TextAlign.center,
textAlign: TextAlign.center, style: const TextStyle(
), fontSize: 10,
MySpacing.height(8), overflow: TextOverflow.ellipsis,
MyText.bodyMedium(
"Please select a project before accessing this section.",
color: Colors.grey[600],
textAlign: TextAlign.center,
),
MySpacing.height(24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Get.back(),
style: ElevatedButton.styleFrom(
backgroundColor: contentTheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: 0,
),
child: MyText.bodyMedium(
"OK",
color: Colors.white,
fontWeight: 600,
), ),
maxLines: 2,
softWrap: true,
), ),
), ),
], ],
), ),
), ),
), ),
); ),
return; );
} }
Get.toNamed(statItem.route); void _onCardTap(_FinanceStatItem statItem, bool isEnabled) {
if (!isEnabled) {
Get.defaultDialog(
title: "No Project Selected",
middleText: "Please select a project before accessing this section.",
confirm: ElevatedButton(
onPressed: () => Get.back(),
child: const Text("OK"),
),
);
} else {
Get.toNamed(statItem.route);
}
} }
} }
class _FinanceStatItem { class _FinanceStatItem {
final IconData icon; final IconData icon;
final String title; final String title;
final String subtitle;
final Color color; final Color color;
final String route; final String route;
_FinanceStatItem( _FinanceStatItem(this.icon, this.title, this.color, this.route);
this.icon,
this.title,
this.subtitle,
this.color,
this.route,
);
}
class _QuickStat {
final IconData icon;
final String label;
final String value;
final Color color;
_QuickStat({
required this.icon,
required this.label,
required this.value,
required this.color,
});
} }

View 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),
),
],
),
);

View 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,
),
),
],
),
],
),
),
),
);
}
}

View File

@ -80,6 +80,9 @@ dependencies:
googleapis_auth: ^2.0.0 googleapis_auth: ^2.0.0
device_info_plus: ^11.3.0 device_info_plus: ^11.3.0
flutter_local_notifications: 19.4.0 flutter_local_notifications: 19.4.0
equatable: ^2.0.7
mime: ^2.0.0
timeago: ^3.7.1
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
dev_dependencies: dev_dependencies: