added code for payement request and payment request details screen

This commit is contained in:
Vaibhav Surve 2025-11-06 17:44:18 +05:30
parent 1a6ad4edfc
commit f55cf343fb
17 changed files with 2578 additions and 15 deletions

View File

@ -15,7 +15,7 @@ import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
import 'package:marco/model/finance/expense_category_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/currency_list_model.dart';
class PaymentRequestController extends GetxController { class AddPaymentRequestController extends GetxController {
// Loading States // Loading States
final isLoadingPayees = false.obs; final isLoadingPayees = false.obs;
final isLoadingCategories = false.obs; final isLoadingCategories = false.obs;

View File

@ -0,0 +1,123 @@
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) {
paymentRequests.assignAll(response.data.data);
} else {
paymentRequests.addAll(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,31 @@
import 'package:get/get.dart';
import 'package:marco/helpers/services/api_service.dart';
class PaymentRequestDetailController extends GetxController {
final Rx<dynamic> paymentRequest = Rx<dynamic>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
late String _requestId;
void init(String requestId) {
_requestId = requestId;
fetchPaymentRequestDetail();
}
Future<void> fetchPaymentRequestDetail() async {
try {
isLoading.value = true;
final response = await ApiService.getExpensePaymentRequestDetailApi(_requestId);
if (response != null) {
paymentRequest.value = response.data; // adapt to your API model
} else {
errorMessage.value = "Failed to fetch payment request details";
}
} catch (e) {
errorMessage.value = "Error fetching payment request details: $e";
} finally {
isLoading.value = false;
}
}
}

View File

@ -17,6 +17,12 @@ class ApiEndpoints {
"/dashboard/attendance-overview"; "/dashboard/attendance-overview";
static const String createExpensePaymentRequest = static const String createExpensePaymentRequest =
"/expense/payment-request/create"; "/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/get/payment-request/details";
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

@ -25,6 +25,9 @@ import 'package:marco/model/dashboard/monthly_expence_model.dart';
import 'package:marco/model/finance/expense_category_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/currency_list_model.dart';
import 'package:marco/model/finance/payment_payee_request_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';
class ApiService { class ApiService {
static const bool enableLogs = true; static const bool enableLogs = true;
@ -294,6 +297,126 @@ class ApiService {
} }
} }
/// 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) /// Create Expense Payment Request (Project API style)
static Future<bool> createExpensePaymentRequestApi({ static Future<bool> createExpensePaymentRequestApi({
required String title, required String title,

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

@ -33,6 +33,137 @@ class SkeletonLoaders {
); );
} }
// 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),
],
),
),
],
)),
),
],
),
),
),
),
);
}
// Chart Skeleton Loader (Donut Chart) // Chart Skeleton Loader (Donut Chart)
static Widget chartSkeletonLoader() { static Widget chartSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(

View File

@ -28,7 +28,7 @@ class _PaymentRequestBottomSheet extends StatefulWidget {
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet> class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
with UIMixin { with UIMixin {
final controller = Get.put(PaymentRequestController()); final controller = Get.put(AddPaymentRequestController());
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final _projectDropdownKey = GlobalKey(); final _projectDropdownKey = GlobalKey();

View File

@ -0,0 +1,364 @@
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;
String? paidBy;
bool isAdvancePayment;
DateTime createdAt;
CreatedBy createdBy;
DateTime updatedAt;
dynamic updatedBy;
List<NextStatus> nextStatus;
List<dynamic> updateLogs;
List<dynamic> 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'],
isAdvancePayment: json['isAdvancePayment'],
createdAt: DateTime.parse(json['createdAt']),
createdBy: CreatedBy.fromJson(json['createdBy']),
updatedAt: DateTime.parse(json['updatedAt']),
updatedBy: json['updatedBy'],
nextStatus: (json['nextStatus'] as List<dynamic>)
.map((e) => NextStatus.fromJson(e))
.toList(),
updateLogs: json['updateLogs'] ?? [],
attachments: json['attachments'] ?? [],
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,
'isAdvancePayment': isAdvancePayment,
'createdAt': createdAt.toIso8601String(),
'createdBy': createdBy.toJson(),
'updatedAt': updatedAt.toIso8601String(),
'updatedBy': updatedBy,
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
'updateLogs': updateLogs,
'attachments': attachments,
'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 CreatedBy {
String id;
String firstName;
String lastName;
String email;
String photo;
String jobRoleId;
String jobRoleName;
CreatedBy({
required this.id,
required this.firstName,
required this.lastName,
required this.email,
required this.photo,
required this.jobRoleId,
required this.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,
};
}
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,
};
}

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

@ -19,6 +19,8 @@ import 'package:marco/view/directory/directory_main_screen.dart';
import 'package:marco/view/expense/expense_screen.dart'; import 'package:marco/view/expense/expense_screen.dart';
import 'package:marco/view/document/user_document_screen.dart'; import 'package:marco/view/document/user_document_screen.dart';
import 'package:marco/view/finance/finance_screen.dart'; import 'package:marco/view/finance/finance_screen.dart';
import 'package:marco/view/finance/payment_request_screen.dart';
class AuthMiddleware extends GetMiddleware { class AuthMiddleware extends GetMiddleware {
@override @override
@ -65,6 +67,10 @@ getPageRoute() {
name: '/dashboard/finance', name: '/dashboard/finance',
page: () => FinanceScreen(), page: () => FinanceScreen(),
middlewares: [AuthMiddleware()]), middlewares: [AuthMiddleware()]),
GetPage(
name: '/dashboard/payment-request',
page: () => PaymentRequestMainScreen(),
middlewares: [AuthMiddleware()]),
// Expense // Expense
GetPage( GetPage(
name: '/dashboard/expense-main-page', name: '/dashboard/expense-main-page',

View File

@ -6,7 +6,6 @@ import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
import 'package:marco/helpers/widgets/my_card.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';
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
class FinanceScreen extends StatefulWidget { class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@ -111,14 +110,6 @@ class _FinanceScreenState extends State<FinanceScreen>
child: _buildFinanceModulesCompact(), child: _buildFinanceModulesCompact(),
), ),
), ),
floatingActionButton: FloatingActionButton(
onPressed: () {
showPaymentRequestBottomSheet();
},
backgroundColor: contentTheme.primary,
child: Icon(Icons.add),
tooltip: "Create Payment Request",
),
); );
} }
@ -129,8 +120,8 @@ class _FinanceScreenState extends State<FinanceScreen>
contentTheme.info, "/dashboard/expense-main-page"), contentTheme.info, "/dashboard/expense-main-page"),
_FinanceStatItem(LucideIcons.receipt_text, "Payment Request", _FinanceStatItem(LucideIcons.receipt_text, "Payment Request",
contentTheme.primary, "/dashboard/payment-request"), contentTheme.primary, "/dashboard/payment-request"),
_FinanceStatItem( _FinanceStatItem(LucideIcons.wallet, "Advance Payment",
LucideIcons.wallet, "Advance Payment", contentTheme.warning, "/dashboard/advance-payment"), contentTheme.warning, "/dashboard/advance-payment"),
]; ];
final projectSelected = projectController.selectedProject != null; final projectSelected = projectController.selectedProject != null;
@ -209,8 +200,7 @@ class _FinanceScreenState extends State<FinanceScreen>
if (!isEnabled) { if (!isEnabled) {
Get.defaultDialog( Get.defaultDialog(
title: "No Project Selected", title: "No Project Selected",
middleText: middleText: "Please select a project before accessing this section.",
"Please select a project before accessing this section.",
confirm: ElevatedButton( confirm: ElevatedButton(
onPressed: () => Get.back(), onPressed: () => Get.back(),
child: const Text("OK"), child: const Text("OK"),

View File

@ -0,0 +1,535 @@
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;
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>();
@override
void initState() {
super.initState();
controller.init(widget.paymentRequestId);
}
@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 as PaymentRequestData?;
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, 30 + 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),
const Divider(height: 30, thickness: 1.2),
// Move Logs here, right after header
_Logs(logs: request.updateLogs),
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),
],
),
),
),
),
),
),
);
}),
),
);
}
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],
),
),
],
);
},
),
],
),
),
],
),
),
),
);
}
}
// Header Row
class _Header extends StatelessWidget {
final PaymentRequestData request;
const _Header({required this.request});
// Helper to parse hex color string to Color
Color parseColorFromHex(String hexColor) {
hexColor = hexColor.toUpperCase().replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF" + hexColor; // Add alpha if missing
}
return Color(int.parse(hexColor, radix: 16));
}
@override
Widget build(BuildContext context) {
final statusColor = parseColorFromHex(request.expenseStatus.color);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Left side: wrap in Expanded to prevent overflow
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,
),
),
],
),
),
// Right side: Status Chip
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(
// Prevent overflow of long status text
width: 100,
child: MyText.labelSmall(
request.expenseStatus.displayName,
color: statusColor,
fontWeight: 600,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
);
}
}
// Horizontal label-value row
Widget labelValueRow(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: MyText.bodySmall(
label,
fontWeight: 600,
),
),
Expanded(
child: MyText.bodySmall(
value,
fontWeight: 500,
softWrap: true,
),
),
],
),
);
// Parties Section
class _Parties extends StatelessWidget {
final PaymentRequestData request;
const _Parties({required this.request});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
labelValueRow('Project', request.project.name),
labelValueRow('Payee', request.payee),
labelValueRow('Created By',
'${request.createdBy.firstName} ${request.createdBy.lastName}'),
labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'),
],
);
}
}
// Details Table
class _DetailsTable extends StatelessWidget {
final PaymentRequestData request;
const _DetailsTable({required this.request});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
labelValueRow("Payment Request ID:", request.paymentRequestUID),
labelValueRow("Expense Category:", request.expenseCategory.name),
labelValueRow("Amount:",
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
labelValueRow(
"Due Date:",
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
format: 'dd MMM yyyy'),
),
labelValueRow("Description:", request.description),
labelValueRow(
"Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"),
],
);
}
}
// Documents Section
class _Documents extends StatelessWidget {
final List<dynamic> documents;
const _Documents({required this.documents});
@override
Widget build(BuildContext context) {
if (documents.isEmpty)
return MyText.bodyMedium('No Documents', color: Colors.grey);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodySmall("Documents:", fontWeight: 600),
const SizedBox(height: 12),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: documents.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final doc = documents[index] as Map<String, dynamic>;
return GestureDetector(
onTap: () async {
final imageDocs = documents
.where((d) =>
(d['contentType'] as String).startsWith('image/'))
.toList();
final initialIndex =
imageDocs.indexWhere((d) => d['id'] == doc['id']);
if (imageDocs.isNotEmpty && initialIndex != -1) {
showDialog(
context: context,
builder: (_) => ImageViewerDialog(
imageSources:
imageDocs.map((e) => e['url'] as String).toList(),
initialIndex: initialIndex,
),
);
} else {
final Uri url = Uri.parse(doc['url'] as String);
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Could not open document.')),
);
}
}
},
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(5),
color: Colors.grey.shade100,
),
child: Row(
children: [
Icon(
(doc['contentType'] as String).startsWith('image/')
? Icons.image
: Icons.insert_drive_file,
size: 20,
color: Colors.grey[600],
),
const SizedBox(width: 7),
Expanded(
child: MyText.labelSmall(
doc['fileName'] ?? '',
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
],
);
}
}
class _Logs extends StatelessWidget {
final List<dynamic> logs;
const _Logs({required this.logs});
// Helper to parse hex color string to Color
Color parseColorFromHex(String hexColor) {
hexColor = hexColor.toUpperCase().replaceAll("#", "");
if (hexColor.length == 6) {
hexColor = "FF" + hexColor; // Add alpha for opacity if missing
}
return Color(int.parse(hexColor, radix: 16));
}
DateTime parseTimestamp(String ts) => DateTime.parse(ts);
@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] as Map<String, dynamic>;
final statusMap = log['status'] ?? {};
final status = statusMap['name'] ?? '';
final description = statusMap['description'] ?? '';
final comment = log['comment'] ?? '';
final nextStatusMap = log['nextStatus'] ?? {};
final nextStatusName = nextStatusMap['name'] ?? '';
final updatedBy = log['updatedBy'] ?? {};
final initials =
"${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}"
"${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}";
final name =
"${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}";
final timestamp = parseTimestamp(log['updatedAt']);
final timeAgo = timeago.format(timestamp);
final statusColor = statusMap['color'] != null
? parseColorFromHex(statusMap['color'])
: Colors.black;
final nextStatusColor = nextStatusMap['color'] != null
? parseColorFromHex(nextStatusMap['color'])
: Colors.blue.shade700;
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: [
// Status and time in one row
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.bodyMedium(
status,
fontWeight: 600,
color: statusColor,
),
MyText.bodySmall(
timeAgo,
color: Colors.grey[600],
textAlign: TextAlign.right,
),
],
),
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,
),
),
],
),
],
),
),
);
},
)
],
);
}
}

View File

@ -0,0 +1,365 @@
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';
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 const Center(child: CircularProgressIndicator());
}
final list = filteredList(isHistory: isHistory);
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(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: list.length,
separatorBuilder: (_, __) =>
Divider(color: Colors.grey.shade300, height: 20),
itemBuilder: (context, index) {
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.bodyMedium(item.payee, fontWeight: 600),
],
),
const SizedBox(height: 6),
Row(
children: [
Row(
children: [
MyText.bodySmall("Due Date: ", color: Colors.grey[600]),
MyText.bodySmall(dueDate, fontWeight: 500),
],
),
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.displayName,
color: Colors.white,
fontWeight: 500,
),
),
],
),
],
),
),
),
);
}
}

View File

@ -82,6 +82,7 @@ dependencies:
flutter_local_notifications: 19.4.0 flutter_local_notifications: 19.4.0
equatable: ^2.0.7 equatable: ^2.0.7
mime: ^2.0.0 mime: ^2.0.0
timeago: ^3.7.1
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
dev_dependencies: dev_dependencies: