added code for payement request and payment request details screen
This commit is contained in:
parent
1a6ad4edfc
commit
f55cf343fb
@ -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/currency_list_model.dart';
|
||||
|
||||
class PaymentRequestController extends GetxController {
|
||||
class AddPaymentRequestController extends GetxController {
|
||||
// Loading States
|
||||
final isLoadingPayees = false.obs;
|
||||
final isLoadingCategories = false.obs;
|
||||
|
||||
123
lib/controller/finance/payment_request_controller.dart
Normal file
123
lib/controller/finance/payment_request_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,12 @@ class ApiEndpoints {
|
||||
"/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/get/payment-request/details";
|
||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||
static const String getDashboardTasks = "/dashboard/tasks";
|
||||
static const String getDashboardTeams = "/dashboard/teams";
|
||||
|
||||
@ -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/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';
|
||||
|
||||
class ApiService {
|
||||
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)
|
||||
static Future<bool> createExpensePaymentRequestApi({
|
||||
required String title,
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateTimeUtils {
|
||||
/// Default date format
|
||||
static const String defaultFormat = 'dd MMM yyyy';
|
||||
|
||||
/// Converts a UTC datetime string to local time and formats it.
|
||||
static String convertUtcToLocal(String utcTimeString,
|
||||
{String format = 'dd-MM-yyyy'}) {
|
||||
|
||||
@ -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)
|
||||
static Widget chartSkeletonLoader() {
|
||||
return MyCard.bordered(
|
||||
|
||||
@ -28,7 +28,7 @@ class _PaymentRequestBottomSheet extends StatefulWidget {
|
||||
|
||||
class _PaymentRequestBottomSheetState extends State<_PaymentRequestBottomSheet>
|
||||
with UIMixin {
|
||||
final controller = Get.put(PaymentRequestController());
|
||||
final controller = Get.put(AddPaymentRequestController());
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
final _projectDropdownKey = GlobalKey();
|
||||
|
||||
364
lib/model/finance/payment_request_details_model.dart
Normal file
364
lib/model/finance/payment_request_details_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
108
lib/model/finance/payment_request_filter.dart
Normal file
108
lib/model/finance/payment_request_filter.dart
Normal file
@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
PaymentRequestFilter paymentRequestFilterFromJson(String str) =>
|
||||
PaymentRequestFilter.fromJson(json.decode(str));
|
||||
|
||||
String paymentRequestFilterToJson(PaymentRequestFilter data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class PaymentRequestFilter {
|
||||
PaymentRequestFilter({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
this.errors,
|
||||
required this.statusCode,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
bool success;
|
||||
String message;
|
||||
PaymentRequestFilterData data;
|
||||
dynamic errors;
|
||||
int statusCode;
|
||||
DateTime timestamp;
|
||||
|
||||
factory PaymentRequestFilter.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentRequestFilter(
|
||||
success: json["success"],
|
||||
message: json["message"],
|
||||
data: PaymentRequestFilterData.fromJson(json["data"]),
|
||||
errors: json["errors"],
|
||||
statusCode: json["statusCode"],
|
||||
timestamp: DateTime.parse(json["timestamp"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"data": data.toJson(),
|
||||
"errors": errors,
|
||||
"statusCode": statusCode,
|
||||
"timestamp": timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
class PaymentRequestFilterData {
|
||||
PaymentRequestFilterData({
|
||||
required this.projects,
|
||||
required this.currency,
|
||||
required this.createdBy,
|
||||
required this.status,
|
||||
required this.expenseCategory,
|
||||
required this.payees,
|
||||
});
|
||||
|
||||
List<IdNameModel> projects;
|
||||
List<IdNameModel> currency;
|
||||
List<IdNameModel> createdBy;
|
||||
List<IdNameModel> status;
|
||||
List<IdNameModel> expenseCategory;
|
||||
List<IdNameModel> payees;
|
||||
|
||||
factory PaymentRequestFilterData.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentRequestFilterData(
|
||||
projects: List<IdNameModel>.from(
|
||||
json["projects"].map((x) => IdNameModel.fromJson(x))),
|
||||
currency: List<IdNameModel>.from(
|
||||
json["currency"].map((x) => IdNameModel.fromJson(x))),
|
||||
createdBy: List<IdNameModel>.from(
|
||||
json["createdBy"].map((x) => IdNameModel.fromJson(x))),
|
||||
status: List<IdNameModel>.from(
|
||||
json["status"].map((x) => IdNameModel.fromJson(x))),
|
||||
expenseCategory: List<IdNameModel>.from(
|
||||
json["expenseCategory"].map((x) => IdNameModel.fromJson(x))),
|
||||
payees: List<IdNameModel>.from(
|
||||
json["payees"].map((x) => IdNameModel.fromJson(x))),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"projects": List<dynamic>.from(projects.map((x) => x.toJson())),
|
||||
"currency": List<dynamic>.from(currency.map((x) => x.toJson())),
|
||||
"createdBy": List<dynamic>.from(createdBy.map((x) => x.toJson())),
|
||||
"status": List<dynamic>.from(status.map((x) => x.toJson())),
|
||||
"expenseCategory":
|
||||
List<dynamic>.from(expenseCategory.map((x) => x.toJson())),
|
||||
"payees": List<dynamic>.from(payees.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
class IdNameModel {
|
||||
IdNameModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
String id;
|
||||
String name;
|
||||
|
||||
factory IdNameModel.fromJson(Map<String, dynamic> json) => IdNameModel(
|
||||
id: json["id"].toString(),
|
||||
name: json["name"] ?? "",
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
};
|
||||
}
|
||||
471
lib/model/finance/payment_request_filter_bottom_sheet.dart
Normal file
471
lib/model/finance/payment_request_filter_bottom_sheet.dart
Normal file
@ -0,0 +1,471 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/finance/payment_request_controller.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||
import 'package:marco/helpers/utils/mixins/ui_mixin.dart';
|
||||
import 'package:marco/helpers/widgets/date_range_picker.dart';
|
||||
import 'package:marco/model/employees/employee_model.dart';
|
||||
import 'package:marco/model/expense/employee_selector_for_filter_bottom_sheet.dart';
|
||||
|
||||
class PaymentRequestFilterBottomSheet extends StatefulWidget {
|
||||
final PaymentRequestController controller;
|
||||
final ScrollController scrollController;
|
||||
|
||||
const PaymentRequestFilterBottomSheet({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PaymentRequestFilterBottomSheet> createState() =>
|
||||
_PaymentRequestFilterBottomSheetState();
|
||||
}
|
||||
|
||||
class _PaymentRequestFilterBottomSheetState
|
||||
extends State<PaymentRequestFilterBottomSheet> with UIMixin {
|
||||
// ---------------- Date Range ----------------
|
||||
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
|
||||
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
|
||||
|
||||
// ---------------- Selected Filters (store IDs internally) ----------------
|
||||
final RxString selectedProjectId = ''.obs;
|
||||
final RxList<EmployeeModel> selectedSubmittedBy = <EmployeeModel>[].obs;
|
||||
final RxList<EmployeeModel> selectedPayees = <EmployeeModel>[].obs;
|
||||
final RxString selectedCategoryId = ''.obs;
|
||||
final RxString selectedCurrencyId = ''.obs;
|
||||
final RxString selectedStatusId = ''.obs;
|
||||
|
||||
// Computed display names
|
||||
String get selectedProjectName =>
|
||||
widget.controller.projects
|
||||
.firstWhereOrNull((e) => e.id == selectedProjectId.value)
|
||||
?.name ??
|
||||
'Please select...';
|
||||
|
||||
String get selectedCategoryName =>
|
||||
widget.controller.categories
|
||||
.firstWhereOrNull((e) => e.id == selectedCategoryId.value)
|
||||
?.name ??
|
||||
'Please select...';
|
||||
|
||||
String get selectedCurrencyName =>
|
||||
widget.controller.currencies
|
||||
.firstWhereOrNull((e) => e.id == selectedCurrencyId.value)
|
||||
?.name ??
|
||||
'Please select...';
|
||||
|
||||
String get selectedStatusName =>
|
||||
widget.controller.statuses
|
||||
.firstWhereOrNull((e) => e.id == selectedStatusId.value)
|
||||
?.name ??
|
||||
'Please select...';
|
||||
|
||||
// ---------------- Filter Data ----------------
|
||||
final RxBool isFilterLoading = true.obs;
|
||||
|
||||
// Individual RxLists for safe Obx usage
|
||||
final RxList<String> projectNames = <String>[].obs;
|
||||
final RxList<String> submittedByNames = <String>[].obs;
|
||||
final RxList<String> payeeNames = <String>[].obs;
|
||||
final RxList<String> categoryNames = <String>[].obs;
|
||||
final RxList<String> currencyNames = <String>[].obs;
|
||||
final RxList<String> statusNames = <String>[].obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadFilterData();
|
||||
}
|
||||
|
||||
Future<void> _loadFilterData() async {
|
||||
isFilterLoading.value = true;
|
||||
await widget.controller.fetchPaymentRequestFilterOptions();
|
||||
|
||||
projectNames.assignAll(widget.controller.projects.map((e) => e.name));
|
||||
submittedByNames.assignAll(widget.controller.createdBy.map((e) => e.name));
|
||||
payeeNames.assignAll(widget.controller.payees.map((e) => e.name));
|
||||
categoryNames.assignAll(widget.controller.categories.map((e) => e.name));
|
||||
currencyNames.assignAll(widget.controller.currencies.map((e) => e.name));
|
||||
statusNames.assignAll(widget.controller.statuses.map((e) => e.name));
|
||||
|
||||
// 🔹 Prefill existing applied filter (if any)
|
||||
final existing = widget.controller.appliedFilter;
|
||||
|
||||
if (existing.isNotEmpty) {
|
||||
// Project
|
||||
if (existing['projectIds'] != null &&
|
||||
(existing['projectIds'] as List).isNotEmpty) {
|
||||
selectedProjectId.value = (existing['projectIds'] as List).first;
|
||||
}
|
||||
|
||||
// Submitted By
|
||||
if (existing['createdByIds'] != null &&
|
||||
existing['createdByIds'] is List) {
|
||||
selectedSubmittedBy.assignAll(
|
||||
(existing['createdByIds'] as List)
|
||||
.map((id) => widget.controller.createdBy
|
||||
.firstWhereOrNull((e) => e.id == id))
|
||||
.whereType<EmployeeModel>()
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// Payees
|
||||
if (existing['payees'] != null && existing['payees'] is List) {
|
||||
selectedPayees.assignAll(
|
||||
(existing['payees'] as List)
|
||||
.map((id) =>
|
||||
widget.controller.payees.firstWhereOrNull((e) => e.id == id))
|
||||
.whereType<EmployeeModel>()
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// Category
|
||||
if (existing['expenseCategoryIds'] != null &&
|
||||
(existing['expenseCategoryIds'] as List).isNotEmpty) {
|
||||
selectedCategoryId.value =
|
||||
(existing['expenseCategoryIds'] as List).first;
|
||||
}
|
||||
|
||||
// Currency
|
||||
if (existing['currencyIds'] != null &&
|
||||
(existing['currencyIds'] as List).isNotEmpty) {
|
||||
selectedCurrencyId.value = (existing['currencyIds'] as List).first;
|
||||
}
|
||||
|
||||
// Status
|
||||
if (existing['statusIds'] != null &&
|
||||
(existing['statusIds'] as List).isNotEmpty) {
|
||||
selectedStatusId.value = (existing['statusIds'] as List).first;
|
||||
}
|
||||
|
||||
// Dates
|
||||
if (existing['startDate'] != null && existing['endDate'] != null) {
|
||||
startDate.value = DateTime.tryParse(existing['startDate']);
|
||||
endDate.value = DateTime.tryParse(existing['endDate']);
|
||||
}
|
||||
}
|
||||
|
||||
isFilterLoading.value = false;
|
||||
}
|
||||
|
||||
Future<List<EmployeeModel>> searchEmployees(
|
||||
String query, List<String> items) async {
|
||||
final allEmployees = items
|
||||
.map((e) => EmployeeModel(
|
||||
id: e,
|
||||
name: e,
|
||||
firstName: e,
|
||||
lastName: '',
|
||||
jobRoleID: '',
|
||||
employeeId: e,
|
||||
designation: '',
|
||||
activity: 0,
|
||||
action: 0,
|
||||
jobRole: '',
|
||||
email: '-',
|
||||
phoneNumber: '-',
|
||||
))
|
||||
.toList();
|
||||
|
||||
if (query.trim().isEmpty) return allEmployees;
|
||||
|
||||
return allEmployees
|
||||
.where((e) => e.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: 'Filter Payment Requests',
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: () {
|
||||
_applyFilters();
|
||||
Get.back();
|
||||
},
|
||||
submitText: 'Apply',
|
||||
submitColor: contentTheme.primary,
|
||||
submitIcon: Icons.check_circle_outline,
|
||||
child: SingleChildScrollView(
|
||||
controller: widget.scrollController,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: clearFilters,
|
||||
child: MyText(
|
||||
"Reset Filters",
|
||||
style: MyTextStyle.labelMedium(
|
||||
color: Colors.red,
|
||||
fontWeight: 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.height(8),
|
||||
_buildDateRangeFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildProjectFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildSubmittedByFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildPayeeFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildCategoryFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildCurrencyFilter(),
|
||||
MySpacing.height(16),
|
||||
_buildStatusFilter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
startDate.value = null;
|
||||
endDate.value = null;
|
||||
selectedProjectId.value = '';
|
||||
selectedSubmittedBy.clear();
|
||||
selectedPayees.clear();
|
||||
selectedCategoryId.value = '';
|
||||
selectedCurrencyId.value = '';
|
||||
selectedStatusId.value = '';
|
||||
widget.controller.setFilterApplied(false);
|
||||
}
|
||||
|
||||
void _applyFilters() {
|
||||
final Map<String, dynamic> filter = {
|
||||
"projectIds":
|
||||
selectedProjectId.value.isEmpty ? [] : [selectedProjectId.value],
|
||||
"createdByIds": selectedSubmittedBy.map((e) => e.id).toList(),
|
||||
"payees": selectedPayees.map((e) => e.id).toList(),
|
||||
"expenseCategoryIds":
|
||||
selectedCategoryId.value.isEmpty ? [] : [selectedCategoryId.value],
|
||||
"currencyIds":
|
||||
selectedCurrencyId.value.isEmpty ? [] : [selectedCurrencyId.value],
|
||||
"statusIds":
|
||||
selectedStatusId.value.isEmpty ? [] : [selectedStatusId.value],
|
||||
"startDate": startDate.value?.toIso8601String(),
|
||||
"endDate": endDate.value?.toIso8601String(),
|
||||
};
|
||||
|
||||
widget.controller.applyFilter(filter);
|
||||
}
|
||||
|
||||
Widget _buildField(String label, Widget child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
MyText.labelMedium(label),
|
||||
MySpacing.height(8),
|
||||
child,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDateRangeFilter() {
|
||||
return _buildField(
|
||||
"Filter By Date",
|
||||
DateRangePickerWidget(
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startLabel: "Start Date",
|
||||
endLabel: "End Date",
|
||||
onDateRangeSelected: (start, end) {
|
||||
startDate.value = start;
|
||||
endDate.value = end;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectFilter() {
|
||||
return _buildField(
|
||||
"Project",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _popupSelector(
|
||||
currentValue: selectedProjectName,
|
||||
items: projectNames,
|
||||
onSelected: (value) {
|
||||
final proj = widget.controller.projects
|
||||
.firstWhereOrNull((e) => e.name == value);
|
||||
if (proj != null) selectedProjectId.value = proj.id;
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSubmittedByFilter() {
|
||||
return _buildField(
|
||||
"Submitted By",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _employeeSelector(
|
||||
selectedSubmittedBy, "Search Submitted By", submittedByNames);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPayeeFilter() {
|
||||
return _buildField(
|
||||
"Payee",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _employeeSelector(selectedPayees, "Search Payee", payeeNames);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryFilter() {
|
||||
return _buildField(
|
||||
"Category",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _popupSelector(
|
||||
currentValue: selectedCategoryName,
|
||||
items: categoryNames,
|
||||
onSelected: (value) {
|
||||
final cat = widget.controller.categories
|
||||
.firstWhereOrNull((e) => e.name == value);
|
||||
if (cat != null) selectedCategoryId.value = cat.id;
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCurrencyFilter() {
|
||||
return _buildField(
|
||||
"Currency",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _popupSelector(
|
||||
currentValue: selectedCurrencyName,
|
||||
items: currencyNames,
|
||||
onSelected: (value) {
|
||||
final cur = widget.controller.currencies
|
||||
.firstWhereOrNull((e) => e.name == value);
|
||||
if (cur != null) selectedCurrencyId.value = cur.id;
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusFilter() {
|
||||
return _buildField(
|
||||
"Status",
|
||||
Obx(() {
|
||||
if (isFilterLoading.value) return const CircularProgressIndicator();
|
||||
return _popupSelector(
|
||||
currentValue: selectedStatusName,
|
||||
items: statusNames,
|
||||
onSelected: (value) {
|
||||
final st = widget.controller.statuses
|
||||
.firstWhereOrNull((e) => e.name == value);
|
||||
if (st != null) selectedStatusId.value = st.id;
|
||||
},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _popupSelector({
|
||||
required String currentValue,
|
||||
required List<String> items,
|
||||
required ValueChanged<String> onSelected,
|
||||
}) {
|
||||
return PopupMenuButton<String>(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: onSelected,
|
||||
itemBuilder: (context) =>
|
||||
items.map((e) => PopupMenuItem(value: e, child: MyText(e))).toList(),
|
||||
child: Container(
|
||||
padding: MySpacing.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: MyText(
|
||||
currentValue,
|
||||
style: const TextStyle(color: Colors.black87),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _employeeSelector(RxList<EmployeeModel> selectedEmployees,
|
||||
String title, List<String> items) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Obx(() {
|
||||
if (selectedEmployees.isEmpty) return const SizedBox.shrink();
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
children: selectedEmployees
|
||||
.map((emp) => Chip(
|
||||
label: MyText(emp.name),
|
||||
onDeleted: () => selectedEmployees.remove(emp),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}),
|
||||
MySpacing.height(8),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final result = await showModalBottomSheet<List<EmployeeModel>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => EmployeeSelectorBottomSheet(
|
||||
selectedEmployees: selectedEmployees,
|
||||
searchEmployees: (query) => searchEmployees(query, items),
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
if (result != null) selectedEmployees.assignAll(result);
|
||||
},
|
||||
child: Container(
|
||||
padding: MySpacing.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, color: Colors.grey),
|
||||
MySpacing.width(8),
|
||||
Expanded(child: MyText(title)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
306
lib/model/finance/payment_request_list_model.dart
Normal file
306
lib/model/finance/payment_request_list_model.dart
Normal file
@ -0,0 +1,306 @@
|
||||
import 'dart:convert';
|
||||
|
||||
PaymentRequestResponse paymentRequestResponseFromJson(String str) =>
|
||||
PaymentRequestResponse.fromJson(json.decode(str));
|
||||
|
||||
String paymentRequestResponseToJson(PaymentRequestResponse data) =>
|
||||
json.encode(data.toJson());
|
||||
|
||||
class PaymentRequestResponse {
|
||||
PaymentRequestResponse({
|
||||
required this.success,
|
||||
required this.message,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
bool success;
|
||||
String message;
|
||||
PaymentRequestData data;
|
||||
|
||||
factory PaymentRequestResponse.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentRequestResponse(
|
||||
success: json["success"],
|
||||
message: json["message"],
|
||||
data: PaymentRequestData.fromJson(json["data"]),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"success": success,
|
||||
"message": message,
|
||||
"data": data.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
class PaymentRequestData {
|
||||
PaymentRequestData({
|
||||
required this.currentPage,
|
||||
required this.totalPages,
|
||||
required this.totalEntities,
|
||||
required this.data,
|
||||
});
|
||||
|
||||
int currentPage;
|
||||
int totalPages;
|
||||
int totalEntities;
|
||||
List<PaymentRequest> data;
|
||||
|
||||
factory PaymentRequestData.fromJson(Map<String, dynamic> json) =>
|
||||
PaymentRequestData(
|
||||
currentPage: json["currentPage"],
|
||||
totalPages: json["totalPages"],
|
||||
totalEntities: json["totalEntities"],
|
||||
data: List<PaymentRequest>.from(
|
||||
json["data"].map((x) => PaymentRequest.fromJson(x))),
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"currentPage": currentPage,
|
||||
"totalPages": totalPages,
|
||||
"totalEntities": totalEntities,
|
||||
"data": List<dynamic>.from(data.map((x) => x.toJson())),
|
||||
};
|
||||
}
|
||||
|
||||
class PaymentRequest {
|
||||
PaymentRequest({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.description,
|
||||
this.recurringPayment,
|
||||
required this.paymentRequestUID,
|
||||
required this.payee,
|
||||
required this.currency,
|
||||
required this.amount,
|
||||
required this.dueDate,
|
||||
required this.project,
|
||||
required this.expenseCategory,
|
||||
required this.expenseStatus,
|
||||
required this.isAdvancePayment,
|
||||
required this.createdAt,
|
||||
required this.createdBy,
|
||||
required this.isActive,
|
||||
required this.isExpenseCreated,
|
||||
});
|
||||
|
||||
String id;
|
||||
String title;
|
||||
String description;
|
||||
dynamic recurringPayment;
|
||||
String paymentRequestUID;
|
||||
String payee;
|
||||
Currency currency;
|
||||
num amount;
|
||||
DateTime dueDate;
|
||||
Project project;
|
||||
ExpenseCategory expenseCategory;
|
||||
ExpenseStatus expenseStatus;
|
||||
bool isAdvancePayment;
|
||||
DateTime createdAt;
|
||||
CreatedBy createdBy;
|
||||
bool isActive;
|
||||
bool isExpenseCreated;
|
||||
|
||||
factory PaymentRequest.fromJson(Map<String, dynamic> json) => PaymentRequest(
|
||||
id: json["id"],
|
||||
title: json["title"],
|
||||
description: json["description"],
|
||||
recurringPayment: json["recurringPayment"],
|
||||
paymentRequestUID: json["paymentRequestUID"],
|
||||
payee: json["payee"],
|
||||
currency: Currency.fromJson(json["currency"]),
|
||||
amount: json["amount"],
|
||||
dueDate: DateTime.parse(json["dueDate"]),
|
||||
project: Project.fromJson(json["project"]),
|
||||
expenseCategory: ExpenseCategory.fromJson(json["expenseCategory"]),
|
||||
expenseStatus: ExpenseStatus.fromJson(json["expenseStatus"]),
|
||||
isAdvancePayment: json["isAdvancePayment"],
|
||||
createdAt: DateTime.parse(json["createdAt"]),
|
||||
createdBy: CreatedBy.fromJson(json["createdBy"]),
|
||||
isActive: json["isActive"],
|
||||
isExpenseCreated: json["isExpenseCreated"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"recurringPayment": recurringPayment,
|
||||
"paymentRequestUID": paymentRequestUID,
|
||||
"payee": payee,
|
||||
"currency": currency.toJson(),
|
||||
"amount": amount,
|
||||
"dueDate": dueDate.toIso8601String(),
|
||||
"project": project.toJson(),
|
||||
"expenseCategory": expenseCategory.toJson(),
|
||||
"expenseStatus": expenseStatus.toJson(),
|
||||
"isAdvancePayment": isAdvancePayment,
|
||||
"createdAt": createdAt.toIso8601String(),
|
||||
"createdBy": createdBy.toJson(),
|
||||
"isActive": isActive,
|
||||
"isExpenseCreated": isExpenseCreated,
|
||||
};
|
||||
}
|
||||
|
||||
class Currency {
|
||||
Currency({
|
||||
required this.id,
|
||||
required this.currencyCode,
|
||||
required this.currencyName,
|
||||
required this.symbol,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
String id;
|
||||
String currencyCode;
|
||||
String currencyName;
|
||||
String symbol;
|
||||
bool isActive;
|
||||
|
||||
factory Currency.fromJson(Map<String, dynamic> json) => Currency(
|
||||
id: json["id"],
|
||||
currencyCode: json["currencyCode"],
|
||||
currencyName: json["currencyName"],
|
||||
symbol: json["symbol"],
|
||||
isActive: json["isActive"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"currencyCode": currencyCode,
|
||||
"currencyName": currencyName,
|
||||
"symbol": symbol,
|
||||
"isActive": isActive,
|
||||
};
|
||||
}
|
||||
|
||||
class Project {
|
||||
Project({
|
||||
required this.id,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
String id;
|
||||
String name;
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
||||
id: json["id"],
|
||||
name: json["name"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
};
|
||||
}
|
||||
|
||||
class ExpenseCategory {
|
||||
ExpenseCategory({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.noOfPersonsRequired,
|
||||
required this.isAttachmentRequried,
|
||||
required this.description,
|
||||
});
|
||||
|
||||
String id;
|
||||
String name;
|
||||
bool noOfPersonsRequired;
|
||||
bool isAttachmentRequried;
|
||||
String description;
|
||||
|
||||
factory ExpenseCategory.fromJson(Map<String, dynamic> json) => ExpenseCategory(
|
||||
id: json["id"],
|
||||
name: json["name"],
|
||||
noOfPersonsRequired: json["noOfPersonsRequired"],
|
||||
isAttachmentRequried: json["isAttachmentRequried"],
|
||||
description: json["description"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"noOfPersonsRequired": noOfPersonsRequired,
|
||||
"isAttachmentRequried": isAttachmentRequried,
|
||||
"description": description,
|
||||
};
|
||||
}
|
||||
|
||||
class ExpenseStatus {
|
||||
ExpenseStatus({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.displayName,
|
||||
required this.description,
|
||||
this.permissionIds,
|
||||
required this.color,
|
||||
required this.isSystem,
|
||||
});
|
||||
|
||||
String id;
|
||||
String name;
|
||||
String displayName;
|
||||
String description;
|
||||
dynamic permissionIds;
|
||||
String color;
|
||||
bool isSystem;
|
||||
|
||||
factory ExpenseStatus.fromJson(Map<String, dynamic> json) => ExpenseStatus(
|
||||
id: json["id"],
|
||||
name: json["name"],
|
||||
displayName: json["displayName"],
|
||||
description: json["description"],
|
||||
permissionIds: json["permissionIds"],
|
||||
color: json["color"],
|
||||
isSystem: json["isSystem"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"name": name,
|
||||
"displayName": displayName,
|
||||
"description": description,
|
||||
"permissionIds": permissionIds,
|
||||
"color": color,
|
||||
"isSystem": isSystem,
|
||||
};
|
||||
}
|
||||
|
||||
class CreatedBy {
|
||||
CreatedBy({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.email,
|
||||
required this.photo,
|
||||
required this.jobRoleId,
|
||||
required this.jobRoleName,
|
||||
});
|
||||
|
||||
String id;
|
||||
String firstName;
|
||||
String lastName;
|
||||
String email;
|
||||
String photo;
|
||||
String jobRoleId;
|
||||
String jobRoleName;
|
||||
|
||||
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
|
||||
id: json["id"],
|
||||
firstName: json["firstName"],
|
||||
lastName: json["lastName"],
|
||||
email: json["email"],
|
||||
photo: json["photo"],
|
||||
jobRoleId: json["jobRoleId"],
|
||||
jobRoleName: json["jobRoleName"],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
"id": id,
|
||||
"firstName": firstName,
|
||||
"lastName": lastName,
|
||||
"email": email,
|
||||
"photo": photo,
|
||||
"jobRoleId": jobRoleId,
|
||||
"jobRoleName": jobRoleName,
|
||||
};
|
||||
}
|
||||
@ -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/document/user_document_screen.dart';
|
||||
import 'package:marco/view/finance/finance_screen.dart';
|
||||
import 'package:marco/view/finance/payment_request_screen.dart';
|
||||
|
||||
|
||||
class AuthMiddleware extends GetMiddleware {
|
||||
@override
|
||||
@ -65,6 +67,10 @@ getPageRoute() {
|
||||
name: '/dashboard/finance',
|
||||
page: () => FinanceScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
GetPage(
|
||||
name: '/dashboard/payment-request',
|
||||
page: () => PaymentRequestMainScreen(),
|
||||
middlewares: [AuthMiddleware()]),
|
||||
// Expense
|
||||
GetPage(
|
||||
name: '/dashboard/expense-main-page',
|
||||
|
||||
@ -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_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/model/finance/add_payment_request_bottom_sheet.dart';
|
||||
|
||||
class FinanceScreen extends StatefulWidget {
|
||||
const FinanceScreen({super.key});
|
||||
@ -111,14 +110,6 @@ class _FinanceScreenState extends State<FinanceScreen>
|
||||
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"),
|
||||
_FinanceStatItem(LucideIcons.receipt_text, "Payment Request",
|
||||
contentTheme.primary, "/dashboard/payment-request"),
|
||||
_FinanceStatItem(
|
||||
LucideIcons.wallet, "Advance Payment", contentTheme.warning, "/dashboard/advance-payment"),
|
||||
_FinanceStatItem(LucideIcons.wallet, "Advance Payment",
|
||||
contentTheme.warning, "/dashboard/advance-payment"),
|
||||
];
|
||||
|
||||
final projectSelected = projectController.selectedProject != null;
|
||||
@ -209,8 +200,7 @@ class _FinanceScreenState extends State<FinanceScreen>
|
||||
if (!isEnabled) {
|
||||
Get.defaultDialog(
|
||||
title: "No Project Selected",
|
||||
middleText:
|
||||
"Please select a project before accessing this section.",
|
||||
middleText: "Please select a project before accessing this section.",
|
||||
confirm: ElevatedButton(
|
||||
onPressed: () => Get.back(),
|
||||
child: const Text("OK"),
|
||||
|
||||
535
lib/view/finance/payment_request_detail_screen.dart
Normal file
535
lib/view/finance/payment_request_detail_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
365
lib/view/finance/payment_request_screen.dart
Normal file
365
lib/view/finance/payment_request_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -82,6 +82,7 @@ dependencies:
|
||||
flutter_local_notifications: 19.4.0
|
||||
equatable: ^2.0.7
|
||||
mime: ^2.0.0
|
||||
timeago: ^3.7.1
|
||||
|
||||
timeline_tile: ^2.0.0
|
||||
dev_dependencies:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user