added process flow and make payment functionallity
This commit is contained in:
parent
a2cf65fb86
commit
44674da8ac
@ -3,6 +3,7 @@ import 'package:get/get.dart';
|
|||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/model/document/document_filter_model.dart';
|
import 'package:marco/model/document/document_filter_model.dart';
|
||||||
import 'package:marco/model/document/documents_list_model.dart';
|
import 'package:marco/model/document/documents_list_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
|
||||||
class DocumentController extends GetxController {
|
class DocumentController extends GetxController {
|
||||||
// ==================== Observables ====================
|
// ==================== Observables ====================
|
||||||
@ -38,7 +39,6 @@ class DocumentController extends GetxController {
|
|||||||
final endDate = Rxn<DateTime>();
|
final endDate = Rxn<DateTime>();
|
||||||
|
|
||||||
// ==================== Lifecycle ====================
|
// ==================== Lifecycle ====================
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onClose() {
|
void onClose() {
|
||||||
// Don't dispose searchController here - it's managed by the page
|
// Don't dispose searchController here - it's managed by the page
|
||||||
@ -87,13 +87,22 @@ class DocumentController extends GetxController {
|
|||||||
entityId: entityId,
|
entityId: entityId,
|
||||||
reset: true,
|
reset: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Document state updated successfully',
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = 'Failed to update document state';
|
errorMessage.value = 'Failed to update document state';
|
||||||
|
_showError('Failed to update document state');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = 'Error updating document: $e';
|
errorMessage.value = 'Error updating document: $e';
|
||||||
|
_showError('Error updating document: $e');
|
||||||
debugPrint('❌ Error toggling document state: $e');
|
debugPrint('❌ Error toggling document state: $e');
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -110,17 +119,13 @@ class DocumentController extends GetxController {
|
|||||||
bool reset = false,
|
bool reset = false,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
// Reset pagination if needed
|
|
||||||
if (reset) {
|
if (reset) {
|
||||||
pageNumber.value = 1;
|
pageNumber.value = 1;
|
||||||
documents.clear();
|
documents.clear();
|
||||||
hasMore.value = true;
|
hasMore.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't fetch if no more data
|
|
||||||
if (!hasMore.value && !reset) return;
|
if (!hasMore.value && !reset) return;
|
||||||
|
|
||||||
// Prevent duplicate requests
|
|
||||||
if (isLoading.value) return;
|
if (isLoading.value) return;
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
@ -187,15 +192,10 @@ class DocumentController extends GetxController {
|
|||||||
|
|
||||||
/// Show error message
|
/// Show error message
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
Get.snackbar(
|
showAppSnackbar(
|
||||||
'Error',
|
title: 'Error',
|
||||||
message,
|
message: message,
|
||||||
snackPosition: SnackPosition.BOTTOM,
|
type: SnackbarType.error,
|
||||||
backgroundColor: Colors.red.shade100,
|
|
||||||
colorText: Colors.red.shade900,
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
borderRadius: 8,
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,10 +40,12 @@ class PaymentRequestController extends GetxController {
|
|||||||
statuses.assignAll(response.data.status);
|
statuses.assignAll(response.data.status);
|
||||||
createdBy.assignAll(response.data.createdBy);
|
createdBy.assignAll(response.data.createdBy);
|
||||||
} else {
|
} else {
|
||||||
logSafe("Payment request filter API returned null", level: LogLevel.warning);
|
logSafe("Payment request filter API returned null",
|
||||||
|
level: LogLevel.warning);
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Exception in fetchPaymentRequestFilterOptions: $e", level: LogLevel.error);
|
logSafe("Exception in fetchPaymentRequestFilterOptions: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,9 +86,11 @@ class PaymentRequestController extends GetxController {
|
|||||||
|
|
||||||
if (response != null && response.data.data.isNotEmpty) {
|
if (response != null && response.data.data.isNotEmpty) {
|
||||||
if (_pageNumber == 1) {
|
if (_pageNumber == 1) {
|
||||||
|
// First page, replace the list
|
||||||
paymentRequests.assignAll(response.data.data);
|
paymentRequests.assignAll(response.data.data);
|
||||||
} else {
|
} else {
|
||||||
paymentRequests.addAll(response.data.data);
|
// Insert new data at the top for latest first
|
||||||
|
paymentRequests.insertAll(0, response.data.data);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (_pageNumber == 1) {
|
if (_pageNumber == 1) {
|
||||||
@ -97,7 +101,8 @@ class PaymentRequestController extends GetxController {
|
|||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
errorMessage.value = 'Failed to fetch payment requests.';
|
errorMessage.value = 'Failed to fetch payment requests.';
|
||||||
logSafe("Exception in _fetchPaymentRequestsFromApi: $e", level: LogLevel.error);
|
logSafe("Exception in _fetchPaymentRequestsFromApi: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,31 +1,363 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/employees/employee_model.dart';
|
||||||
|
import 'package:marco/model/finance/payment_request_details_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:geocoding/geocoding.dart';
|
||||||
|
import 'package:geolocator/geolocator.dart';
|
||||||
|
import 'package:mime/mime.dart';
|
||||||
|
|
||||||
class PaymentRequestDetailController extends GetxController {
|
class PaymentRequestDetailController extends GetxController {
|
||||||
final Rx<dynamic> paymentRequest = Rx<dynamic>(null);
|
final Rx<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
|
||||||
final RxBool isLoading = false.obs;
|
final RxBool isLoading = false.obs;
|
||||||
final RxString errorMessage = ''.obs;
|
final RxString errorMessage = ''.obs;
|
||||||
|
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
||||||
|
|
||||||
|
// Employee selection
|
||||||
|
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
||||||
|
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
|
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
|
||||||
|
final TextEditingController employeeSearchController =
|
||||||
|
TextEditingController();
|
||||||
|
final RxBool isSearchingEmployees = false.obs;
|
||||||
|
|
||||||
|
// Attachments
|
||||||
|
final RxList<File> attachments = <File>[].obs;
|
||||||
|
final RxList<Map<String, dynamic>> existingAttachments =
|
||||||
|
<Map<String, dynamic>>[].obs;
|
||||||
|
final isProcessingAttachment = false.obs;
|
||||||
|
|
||||||
|
// Payment mode
|
||||||
|
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
||||||
|
|
||||||
|
// Text controllers for form
|
||||||
|
final TextEditingController locationController = TextEditingController();
|
||||||
|
final TextEditingController gstNumberController = TextEditingController();
|
||||||
|
|
||||||
|
// Form submission state
|
||||||
|
final RxBool isSubmitting = false.obs;
|
||||||
|
|
||||||
late String _requestId;
|
late String _requestId;
|
||||||
|
bool _isInitialized = false;
|
||||||
|
RxBool paymentSheetOpened = false.obs;
|
||||||
|
final ImagePicker _picker = ImagePicker();
|
||||||
|
|
||||||
|
/// Initialize controller
|
||||||
void init(String requestId) {
|
void init(String requestId) {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
_isInitialized = true;
|
||||||
|
|
||||||
_requestId = requestId;
|
_requestId = requestId;
|
||||||
fetchPaymentRequestDetail();
|
|
||||||
|
// Fetch payment request details + employees concurrently
|
||||||
|
Future.wait([
|
||||||
|
fetchPaymentRequestDetail(),
|
||||||
|
fetchAllEmployees(),
|
||||||
|
fetchPaymentModes(),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchPaymentRequestDetail() async {
|
/// Generic API wrapper for error handling
|
||||||
try {
|
Future<T?> _apiCallWrapper<T>(
|
||||||
|
Future<T?> Function() apiCall, String operationName) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
final response = await ApiService.getExpensePaymentRequestDetailApi(_requestId);
|
errorMessage.value = '';
|
||||||
if (response != null) {
|
try {
|
||||||
paymentRequest.value = response.data; // adapt to your API model
|
final result = await apiCall();
|
||||||
} else {
|
return result;
|
||||||
errorMessage.value = "Failed to fetch payment request details";
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = "Error fetching payment request details: $e";
|
errorMessage.value = 'Error during $operationName: $e';
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: errorMessage.value,
|
||||||
|
type: SnackbarType.error);
|
||||||
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch payment request details
|
||||||
|
Future<void> fetchPaymentRequestDetail() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final response =
|
||||||
|
await ApiService.getExpensePaymentRequestDetailApi(_requestId);
|
||||||
|
if (response != null) {
|
||||||
|
paymentRequest.value = response.data;
|
||||||
|
} else {
|
||||||
|
errorMessage.value = "Failed to fetch payment request details";
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: errorMessage.value,
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = "Error fetching payment request details: $e";
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: errorMessage.value,
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick files from gallery or file picker
|
||||||
|
Future<void> pickAttachments() async {
|
||||||
|
try {
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
||||||
|
allowMultiple: true,
|
||||||
|
);
|
||||||
|
if (result != null) {
|
||||||
|
attachments.addAll(
|
||||||
|
result.paths.whereType<String>().map(File.new),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Attachment error: $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAttachment(File file) => attachments.remove(file);
|
||||||
|
|
||||||
|
Future<void> pickFromCamera() async {
|
||||||
|
try {
|
||||||
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
||||||
|
if (pickedFile != null) {
|
||||||
|
isProcessingAttachment.value = true;
|
||||||
|
File imageFile = File(pickedFile.path);
|
||||||
|
|
||||||
|
File timestampedFile = await TimestampImageHelper.addTimestamp(
|
||||||
|
imageFile: imageFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
attachments.add(timestampedFile);
|
||||||
|
attachments.refresh();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Camera error: $e");
|
||||||
|
} finally {
|
||||||
|
isProcessingAttachment.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Location ---
|
||||||
|
final RxBool isFetchingLocation = false.obs;
|
||||||
|
|
||||||
|
Future<void> fetchCurrentLocation() async {
|
||||||
|
isFetchingLocation.value = true;
|
||||||
|
try {
|
||||||
|
if (!await _ensureLocationPermission()) return;
|
||||||
|
|
||||||
|
final position = await Geolocator.getCurrentPosition();
|
||||||
|
final placemarks =
|
||||||
|
await placemarkFromCoordinates(position.latitude, position.longitude);
|
||||||
|
|
||||||
|
locationController.text = placemarks.isNotEmpty
|
||||||
|
? [
|
||||||
|
placemarks.first.name,
|
||||||
|
placemarks.first.street,
|
||||||
|
placemarks.first.locality,
|
||||||
|
placemarks.first.administrativeArea,
|
||||||
|
placemarks.first.country,
|
||||||
|
].where((e) => e?.isNotEmpty == true).join(", ")
|
||||||
|
: "${position.latitude}, ${position.longitude}";
|
||||||
|
} catch (e) {
|
||||||
|
_errorSnackbar("Location error: $e");
|
||||||
|
} finally {
|
||||||
|
isFetchingLocation.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureLocationPermission() async {
|
||||||
|
var permission = await Geolocator.checkPermission();
|
||||||
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
|
permission = await Geolocator.requestPermission();
|
||||||
|
if (permission == LocationPermission.denied ||
|
||||||
|
permission == LocationPermission.deniedForever) {
|
||||||
|
_errorSnackbar("Location permission denied.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||||
|
_errorSnackbar("Location service disabled.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all employees
|
||||||
|
Future<void> fetchAllEmployees() async {
|
||||||
|
final response = await _apiCallWrapper(
|
||||||
|
() => ApiService.getAllEmployees(), "fetch all employees");
|
||||||
|
|
||||||
|
if (response != null && response.isNotEmpty) {
|
||||||
|
try {
|
||||||
|
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage.value = 'Failed to parse employee data: $e';
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: errorMessage.value,
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
allEmployees.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch payment modes
|
||||||
|
Future<void> fetchPaymentModes() async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final paymentModesData = await ApiService.getMasterPaymentModes();
|
||||||
|
if (paymentModesData is List) {
|
||||||
|
paymentModes.value =
|
||||||
|
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||||
|
} else {
|
||||||
|
paymentModes.clear();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to fetch payment modes',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
paymentModes.clear();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Error fetching payment modes: $e',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Search employees
|
||||||
|
Future<void> searchEmployees(String query) async {
|
||||||
|
if (query.trim().isEmpty) {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSearchingEmployees.value = true;
|
||||||
|
try {
|
||||||
|
final data =
|
||||||
|
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
||||||
|
employeeSearchResults.assignAll(
|
||||||
|
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
employeeSearchResults.clear();
|
||||||
|
} finally {
|
||||||
|
isSearchingEmployees.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update payment request status
|
||||||
|
Future<bool> updatePaymentRequestStatus({
|
||||||
|
required String statusId,
|
||||||
|
required String comment,
|
||||||
|
String? paidTransactionId,
|
||||||
|
String? paidById,
|
||||||
|
DateTime? paidAt,
|
||||||
|
double? baseAmount,
|
||||||
|
double? taxAmount,
|
||||||
|
String? tdsPercentage,
|
||||||
|
}) async {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
final success = await ApiService.updateExpensePaymentRequestStatusApi(
|
||||||
|
paymentRequestId: _requestId,
|
||||||
|
statusId: statusId,
|
||||||
|
comment: comment,
|
||||||
|
paidTransactionId: paidTransactionId,
|
||||||
|
paidById: paidById,
|
||||||
|
paidAt: paidAt,
|
||||||
|
baseAmount: baseAmount,
|
||||||
|
taxAmount: taxAmount,
|
||||||
|
tdsPercentage: tdsPercentage,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Payment submitted successfully',
|
||||||
|
type: SnackbarType.success);
|
||||||
|
await fetchPaymentRequestDetail();
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Failed to update status. Please try again.',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return success;
|
||||||
|
} catch (e) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong: $e',
|
||||||
|
type: SnackbarType.error);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Snackbar Helper ---
|
||||||
|
void _errorSnackbar(String msg, [String title = "Error"]) {
|
||||||
|
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payment Mode Selection ---
|
||||||
|
void selectPaymentMode(PaymentModeModel mode) {
|
||||||
|
selectedPaymentMode.value = mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Submit Expense ---
|
||||||
|
Future<bool> submitExpense() async {
|
||||||
|
if (selectedPaymentMode.value == null) return false;
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
// Prepare attachments with all required fields
|
||||||
|
final attachmentsPayload = attachments.map((file) {
|
||||||
|
final bytes = file.readAsBytesSync();
|
||||||
|
final mimeType =
|
||||||
|
lookupMimeType(file.path) ?? 'application/octet-stream';
|
||||||
|
|
||||||
|
return {
|
||||||
|
"fileName": file.path.split('/').last,
|
||||||
|
"base64Data": base64Encode(bytes),
|
||||||
|
"contentType": mimeType,
|
||||||
|
"description": "",
|
||||||
|
"fileSize": bytes.length,
|
||||||
|
"isActive": true,
|
||||||
|
};
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// Call API
|
||||||
|
return await ApiService.createExpenseForPRApi(
|
||||||
|
paymentModeId: selectedPaymentMode.value!.id,
|
||||||
|
location: locationController.text,
|
||||||
|
gstNumber: gstNumberController.text,
|
||||||
|
paymentRequestId: _requestId,
|
||||||
|
billAttachments: attachmentsPayload,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,13 @@ class ApiEndpoints {
|
|||||||
static const String getExpensePaymentRequestDetails =
|
static const String getExpensePaymentRequestDetails =
|
||||||
"/Expense/get/payment-request/details";
|
"/Expense/get/payment-request/details";
|
||||||
static const String getExpensePaymentRequestFilter =
|
static const String getExpensePaymentRequestFilter =
|
||||||
"/Expense/get/payment-request/details";
|
"/Expense/payment-request/filter";
|
||||||
|
static const String updateExpensePaymentRequestStatus =
|
||||||
|
"/Expense/payment-request/action";
|
||||||
|
static const String createExpenseforPR =
|
||||||
|
"/Expense/payment-request/expense/create";
|
||||||
|
|
||||||
|
|
||||||
static const String getDashboardProjectProgress = "/dashboard/progression";
|
static const String getDashboardProjectProgress = "/dashboard/progression";
|
||||||
static const String getDashboardTasks = "/dashboard/tasks";
|
static const String getDashboardTasks = "/dashboard/tasks";
|
||||||
static const String getDashboardTeams = "/dashboard/teams";
|
static const String getDashboardTeams = "/dashboard/teams";
|
||||||
|
|||||||
@ -297,6 +297,114 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create Expense for Payment Request
|
||||||
|
static Future<bool> createExpenseForPRApi({
|
||||||
|
required String paymentModeId,
|
||||||
|
required String location,
|
||||||
|
required String gstNumber,
|
||||||
|
required String paymentRequestId,
|
||||||
|
List<Map<String, dynamic>> billAttachments = const [],
|
||||||
|
}) async {
|
||||||
|
const endpoint = ApiEndpoints.createExpenseforPR;
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
"paymentModeId": paymentModeId,
|
||||||
|
"location": location,
|
||||||
|
"gstNumber": gstNumber,
|
||||||
|
"paymentRequestId": paymentRequestId,
|
||||||
|
"billAttachments": billAttachments,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(endpoint, body);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Create Expense for PR failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Create Expense for PR response status: ${response.statusCode}");
|
||||||
|
logSafe("Create Expense for PR response body: ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe(
|
||||||
|
"Expense for Payment Request created successfully: ${json['data']}");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to create Expense for Payment Request: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during createExpenseForPRApi: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update Expense Payment Request Status
|
||||||
|
static Future<bool> updateExpensePaymentRequestStatusApi({
|
||||||
|
required String paymentRequestId,
|
||||||
|
required String statusId,
|
||||||
|
required String comment,
|
||||||
|
String? paidTransactionId,
|
||||||
|
String? paidById,
|
||||||
|
DateTime? paidAt,
|
||||||
|
double? baseAmount,
|
||||||
|
double? taxAmount,
|
||||||
|
String? tdsPercentage,
|
||||||
|
}) async {
|
||||||
|
const endpoint = ApiEndpoints.updateExpensePaymentRequestStatus;
|
||||||
|
logSafe("Updating Payment Request Status for ID: $paymentRequestId");
|
||||||
|
|
||||||
|
final body = {
|
||||||
|
"paymentRequestId": paymentRequestId,
|
||||||
|
"statusId": statusId,
|
||||||
|
"comment": comment,
|
||||||
|
"paidTransactionId": paidTransactionId,
|
||||||
|
"paidById": paidById,
|
||||||
|
"paidAt": paidAt?.toIso8601String(),
|
||||||
|
"baseAmount": baseAmount,
|
||||||
|
"taxAmount": taxAmount,
|
||||||
|
"tdsPercentage": tdsPercentage ?? "0",
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _postRequest(endpoint, body);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Update Payment Request Status failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
"Update Payment Request Status response: ${response.statusCode} -> ${response.body}");
|
||||||
|
|
||||||
|
final json = jsonDecode(response.body);
|
||||||
|
if (json['success'] == true) {
|
||||||
|
logSafe("Payment Request status updated successfully!");
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
logSafe(
|
||||||
|
"Failed to update Payment Request Status: ${json['message'] ?? 'Unknown error'}",
|
||||||
|
level: LogLevel.warning,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during updateExpensePaymentRequestStatusApi: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Expense Payment Request Detail by ID
|
/// Get Expense Payment Request Detail by ID
|
||||||
static Future<PaymentRequestDetail?> getExpensePaymentRequestDetailApi(
|
static Future<PaymentRequestDetail?> getExpensePaymentRequestDetailApi(
|
||||||
String paymentRequestId) async {
|
String paymentRequestId) async {
|
||||||
|
|||||||
@ -67,6 +67,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
final int maxLines;
|
final int maxLines;
|
||||||
final TextInputType keyboardType;
|
final TextInputType keyboardType;
|
||||||
final String? Function(String?)? validator;
|
final String? Function(String?)? validator;
|
||||||
|
final Widget? suffixIcon;
|
||||||
|
|
||||||
const CustomTextField({
|
const CustomTextField({
|
||||||
required this.controller,
|
required this.controller,
|
||||||
@ -74,8 +75,9 @@ class CustomTextField extends StatelessWidget {
|
|||||||
this.maxLines = 1,
|
this.maxLines = 1,
|
||||||
this.keyboardType = TextInputType.text,
|
this.keyboardType = TextInputType.text,
|
||||||
this.validator,
|
this.validator,
|
||||||
|
this.suffixIcon,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) ;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -91,6 +93,7 @@ class CustomTextField extends StatelessWidget {
|
|||||||
fillColor: Colors.grey.shade100,
|
fillColor: Colors.grey.shade100,
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
|||||||
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal file
222
lib/model/finance/make_expense_bottom_sheet.dart
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
// create_expense_bottom_sheet.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/helpers/widgets/expense/expense_form_widgets.dart';
|
||||||
|
import 'package:marco/helpers/utils/validators.dart';
|
||||||
|
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
|
||||||
|
|
||||||
|
Future<T?> showCreateExpenseBottomSheet<T>() {
|
||||||
|
return Get.bottomSheet<T>(
|
||||||
|
_CreateExpenseBottomSheet(),
|
||||||
|
isScrollControlled: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateExpenseBottomSheet extends StatefulWidget {
|
||||||
|
@override
|
||||||
|
State<_CreateExpenseBottomSheet> createState() =>
|
||||||
|
_CreateExpenseBottomSheetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateExpenseBottomSheetState extends State<_CreateExpenseBottomSheet> {
|
||||||
|
final controller = Get.put(PaymentRequestDetailController());
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _paymentModeDropdownKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(
|
||||||
|
() => Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: BaseBottomSheet(
|
||||||
|
title: "Create New Expense",
|
||||||
|
isSubmitting: controller.isSubmitting.value,
|
||||||
|
onCancel: Get.back,
|
||||||
|
onSubmit: () async {
|
||||||
|
if (_formKey.currentState!.validate() && _validateSelections()) {
|
||||||
|
final success = await controller.submitExpense();
|
||||||
|
if (success) {
|
||||||
|
Get.back();
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Success",
|
||||||
|
message: "Expense created successfully!",
|
||||||
|
type: SnackbarType.success,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildDropdown(
|
||||||
|
"Payment Mode*",
|
||||||
|
Icons.payment_outlined,
|
||||||
|
controller.selectedPaymentMode.value?.name ?? "Select Mode",
|
||||||
|
controller.paymentModes,
|
||||||
|
(p) => p.name,
|
||||||
|
controller.selectPaymentMode,
|
||||||
|
key: _paymentModeDropdownKey,
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
_buildTextField(
|
||||||
|
"GST Number",
|
||||||
|
Icons.receipt_outlined,
|
||||||
|
controller.gstNumberController,
|
||||||
|
hint: "Enter GST Number",
|
||||||
|
validator: null, // optional field
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
_buildTextField(
|
||||||
|
"Location*",
|
||||||
|
Icons.location_on_outlined,
|
||||||
|
controller.locationController,
|
||||||
|
hint: "Enter location",
|
||||||
|
validator: Validators.requiredField,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
suffixIcon: IconButton(
|
||||||
|
icon: const Icon(Icons.my_location_outlined),
|
||||||
|
onPressed: () async {
|
||||||
|
await controller.fetchCurrentLocation();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
_buildAttachmentField(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDropdown<T>(String title, IconData icon, String value,
|
||||||
|
List<T> options, String Function(T) getLabel, ValueChanged<T> onSelected,
|
||||||
|
{required GlobalKey key}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SectionTitle(icon: icon, title: title, requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
DropdownTile(
|
||||||
|
key: key,
|
||||||
|
title: value,
|
||||||
|
onTap: () => _showOptionList(options, getLabel, onSelected, key),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTextField(
|
||||||
|
String title,
|
||||||
|
IconData icon,
|
||||||
|
TextEditingController controller, {
|
||||||
|
String? hint,
|
||||||
|
FormFieldValidator<String>? validator,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
Widget? suffixIcon, // add this
|
||||||
|
}) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SectionTitle(
|
||||||
|
icon: icon, title: title, requiredField: validator != null),
|
||||||
|
MySpacing.height(6),
|
||||||
|
CustomTextField(
|
||||||
|
controller: controller,
|
||||||
|
hint: hint ?? "",
|
||||||
|
validator: validator,
|
||||||
|
keyboardType: keyboardType ?? TextInputType.text,
|
||||||
|
suffixIcon: suffixIcon,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttachmentField() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const SectionTitle(
|
||||||
|
icon: Icons.attach_file,
|
||||||
|
title: "Upload Bill*",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
Obx(() {
|
||||||
|
if (controller.isProcessingAttachment.value) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
children: const [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 8),
|
||||||
|
Text("Processing file, please wait..."),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return AttachmentsSection(
|
||||||
|
attachments: controller.attachments,
|
||||||
|
existingAttachments: controller.existingAttachments,
|
||||||
|
onRemoveNew: controller.removeAttachment,
|
||||||
|
controller: controller,
|
||||||
|
onAdd: controller.pickAttachments,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _gap([double h = 16]) => MySpacing.height(h);
|
||||||
|
|
||||||
|
Future<void> _showOptionList<T>(List<T> options, String Function(T) getLabel,
|
||||||
|
ValueChanged<T> onSelected, GlobalKey key) async {
|
||||||
|
if (options.isEmpty) {
|
||||||
|
_showError("No options available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final RenderBox button =
|
||||||
|
key.currentContext!.findRenderObject() as RenderBox;
|
||||||
|
final RenderBox overlay =
|
||||||
|
Overlay.of(context).context.findRenderObject() as RenderBox;
|
||||||
|
final position = button.localToGlobal(Offset.zero, ancestor: overlay);
|
||||||
|
|
||||||
|
final selected = await showMenu<T>(
|
||||||
|
context: context,
|
||||||
|
position: RelativeRect.fromLTRB(
|
||||||
|
position.dx,
|
||||||
|
position.dy + button.size.height,
|
||||||
|
overlay.size.width - position.dx - button.size.width,
|
||||||
|
0),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
items: options
|
||||||
|
.map(
|
||||||
|
(opt) => PopupMenuItem<T>(value: opt, child: Text(getLabel(opt))))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected != null) onSelected(selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _validateSelections() {
|
||||||
|
if (controller.selectedPaymentMode.value == null) {
|
||||||
|
return _showError("Please select a payment mode");
|
||||||
|
}
|
||||||
|
if (controller.locationController.text.trim().isEmpty) {
|
||||||
|
return _showError("Please enter location");
|
||||||
|
}
|
||||||
|
if (controller.attachments.isEmpty) {
|
||||||
|
return _showError("Please upload bill");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _showError(String msg) {
|
||||||
|
showAppSnackbar(title: "Error", message: msg, type: SnackbarType.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
lib/model/finance/payment_mode_response_model.dart
Normal file
65
lib/model/finance/payment_mode_response_model.dart
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
class PaymentModeResponse {
|
||||||
|
final bool success;
|
||||||
|
final String message;
|
||||||
|
final List<PaymentModeData> data;
|
||||||
|
final dynamic errors;
|
||||||
|
final int statusCode;
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
PaymentModeResponse({
|
||||||
|
required this.success,
|
||||||
|
required this.message,
|
||||||
|
required this.data,
|
||||||
|
this.errors,
|
||||||
|
required this.statusCode,
|
||||||
|
required this.timestamp,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentModeResponse.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PaymentModeResponse(
|
||||||
|
success: json['success'] as bool,
|
||||||
|
message: json['message'] as String,
|
||||||
|
data: (json['data'] as List)
|
||||||
|
.map((item) => PaymentModeData.fromJson(item))
|
||||||
|
.toList(),
|
||||||
|
errors: json['errors'],
|
||||||
|
statusCode: json['statusCode'] as int,
|
||||||
|
timestamp: DateTime.parse(json['timestamp'] as String),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'success': success,
|
||||||
|
'message': message,
|
||||||
|
'data': data.map((e) => e.toJson()).toList(),
|
||||||
|
'errors': errors,
|
||||||
|
'statusCode': statusCode,
|
||||||
|
'timestamp': timestamp.toIso8601String(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaymentModeData {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String description;
|
||||||
|
|
||||||
|
PaymentModeData({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.description,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PaymentModeData.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PaymentModeData(
|
||||||
|
id: json['id'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
description: json['description'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -19,7 +19,9 @@ class PaymentRequestDetail {
|
|||||||
PaymentRequestDetail(
|
PaymentRequestDetail(
|
||||||
success: json['success'],
|
success: json['success'],
|
||||||
message: json['message'],
|
message: json['message'],
|
||||||
data: json['data'] != null ? PaymentRequestData.fromJson(json['data']) : null,
|
data: json['data'] != null
|
||||||
|
? PaymentRequestData.fromJson(json['data'])
|
||||||
|
: null,
|
||||||
errors: json['errors'],
|
errors: json['errors'],
|
||||||
statusCode: json['statusCode'],
|
statusCode: json['statusCode'],
|
||||||
timestamp: DateTime.parse(json['timestamp']),
|
timestamp: DateTime.parse(json['timestamp']),
|
||||||
@ -52,15 +54,15 @@ class PaymentRequestData {
|
|||||||
ExpenseStatus expenseStatus;
|
ExpenseStatus expenseStatus;
|
||||||
String? paidTransactionId;
|
String? paidTransactionId;
|
||||||
DateTime? paidAt;
|
DateTime? paidAt;
|
||||||
String? paidBy;
|
User? paidBy;
|
||||||
bool isAdvancePayment;
|
bool isAdvancePayment;
|
||||||
DateTime createdAt;
|
DateTime createdAt;
|
||||||
CreatedBy createdBy;
|
User createdBy;
|
||||||
DateTime updatedAt;
|
DateTime updatedAt;
|
||||||
dynamic updatedBy;
|
User? updatedBy;
|
||||||
List<NextStatus> nextStatus;
|
List<NextStatus> nextStatus;
|
||||||
List<dynamic> updateLogs;
|
List<UpdateLog> updateLogs;
|
||||||
List<dynamic> attachments;
|
List<Attachment> attachments;
|
||||||
bool isActive;
|
bool isActive;
|
||||||
bool isExpenseCreated;
|
bool isExpenseCreated;
|
||||||
|
|
||||||
@ -103,8 +105,12 @@ class PaymentRequestData {
|
|||||||
payee: json['payee'],
|
payee: json['payee'],
|
||||||
currency: Currency.fromJson(json['currency']),
|
currency: Currency.fromJson(json['currency']),
|
||||||
amount: (json['amount'] as num).toDouble(),
|
amount: (json['amount'] as num).toDouble(),
|
||||||
baseAmount: json['baseAmount'] != null ? (json['baseAmount'] as num).toDouble() : null,
|
baseAmount: json['baseAmount'] != null
|
||||||
taxAmount: json['taxAmount'] != null ? (json['taxAmount'] as num).toDouble() : null,
|
? (json['baseAmount'] as num).toDouble()
|
||||||
|
: null,
|
||||||
|
taxAmount: json['taxAmount'] != null
|
||||||
|
? (json['taxAmount'] as num).toDouble()
|
||||||
|
: null,
|
||||||
dueDate: DateTime.parse(json['dueDate']),
|
dueDate: DateTime.parse(json['dueDate']),
|
||||||
project: Project.fromJson(json['project']),
|
project: Project.fromJson(json['project']),
|
||||||
recurringPayment: json['recurringPayment'],
|
recurringPayment: json['recurringPayment'],
|
||||||
@ -112,17 +118,23 @@ class PaymentRequestData {
|
|||||||
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
|
expenseStatus: ExpenseStatus.fromJson(json['expenseStatus']),
|
||||||
paidTransactionId: json['paidTransactionId'],
|
paidTransactionId: json['paidTransactionId'],
|
||||||
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
|
paidAt: json['paidAt'] != null ? DateTime.parse(json['paidAt']) : null,
|
||||||
paidBy: json['paidBy'],
|
paidBy:
|
||||||
|
json['paidBy'] != null ? User.fromJson(json['paidBy']) : null,
|
||||||
isAdvancePayment: json['isAdvancePayment'],
|
isAdvancePayment: json['isAdvancePayment'],
|
||||||
createdAt: DateTime.parse(json['createdAt']),
|
createdAt: DateTime.parse(json['createdAt']),
|
||||||
createdBy: CreatedBy.fromJson(json['createdBy']),
|
createdBy: User.fromJson(json['createdBy']),
|
||||||
updatedAt: DateTime.parse(json['updatedAt']),
|
updatedAt: DateTime.parse(json['updatedAt']),
|
||||||
updatedBy: json['updatedBy'],
|
updatedBy:
|
||||||
|
json['updatedBy'] != null ? User.fromJson(json['updatedBy']) : null,
|
||||||
nextStatus: (json['nextStatus'] as List<dynamic>)
|
nextStatus: (json['nextStatus'] as List<dynamic>)
|
||||||
.map((e) => NextStatus.fromJson(e))
|
.map((e) => NextStatus.fromJson(e))
|
||||||
.toList(),
|
.toList(),
|
||||||
updateLogs: json['updateLogs'] ?? [],
|
updateLogs: (json['updateLogs'] as List<dynamic>)
|
||||||
attachments: json['attachments'] ?? [],
|
.map((e) => UpdateLog.fromJson(e))
|
||||||
|
.toList(),
|
||||||
|
attachments: (json['attachments'] as List<dynamic>)
|
||||||
|
.map((e) => Attachment.fromJson(e))
|
||||||
|
.toList(),
|
||||||
isActive: json['isActive'],
|
isActive: json['isActive'],
|
||||||
isExpenseCreated: json['isExpenseCreated'],
|
isExpenseCreated: json['isExpenseCreated'],
|
||||||
);
|
);
|
||||||
@ -144,15 +156,15 @@ class PaymentRequestData {
|
|||||||
'expenseStatus': expenseStatus.toJson(),
|
'expenseStatus': expenseStatus.toJson(),
|
||||||
'paidTransactionId': paidTransactionId,
|
'paidTransactionId': paidTransactionId,
|
||||||
'paidAt': paidAt?.toIso8601String(),
|
'paidAt': paidAt?.toIso8601String(),
|
||||||
'paidBy': paidBy,
|
'paidBy': paidBy?.toJson(),
|
||||||
'isAdvancePayment': isAdvancePayment,
|
'isAdvancePayment': isAdvancePayment,
|
||||||
'createdAt': createdAt.toIso8601String(),
|
'createdAt': createdAt.toIso8601String(),
|
||||||
'createdBy': createdBy.toJson(),
|
'createdBy': createdBy.toJson(),
|
||||||
'updatedAt': updatedAt.toIso8601String(),
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
'updatedBy': updatedBy,
|
'updatedBy': updatedBy?.toJson(),
|
||||||
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
|
'nextStatus': nextStatus.map((e) => e.toJson()).toList(),
|
||||||
'updateLogs': updateLogs,
|
'updateLogs': updateLogs.map((e) => e.toJson()).toList(),
|
||||||
'attachments': attachments,
|
'attachments': attachments.map((e) => e.toJson()).toList(),
|
||||||
'isActive': isActive,
|
'isActive': isActive,
|
||||||
'isExpenseCreated': isExpenseCreated,
|
'isExpenseCreated': isExpenseCreated,
|
||||||
};
|
};
|
||||||
@ -196,15 +208,10 @@ class Project {
|
|||||||
|
|
||||||
Project({required this.id, required this.name});
|
Project({required this.id, required this.name});
|
||||||
|
|
||||||
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
factory Project.fromJson(Map<String, dynamic> json) =>
|
||||||
id: json['id'],
|
Project(id: json['id'], name: json['name']);
|
||||||
name: json['name'],
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {'id': id, 'name': name};
|
||||||
'id': id,
|
|
||||||
'name': name,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ExpenseCategory {
|
class ExpenseCategory {
|
||||||
@ -222,7 +229,8 @@ class ExpenseCategory {
|
|||||||
required this.description,
|
required this.description,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory ExpenseCategory.fromJson(Map<String, dynamic> json) => ExpenseCategory(
|
factory ExpenseCategory.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ExpenseCategory(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
name: json['name'],
|
name: json['name'],
|
||||||
noOfPersonsRequired: json['noOfPersonsRequired'],
|
noOfPersonsRequired: json['noOfPersonsRequired'],
|
||||||
@ -281,7 +289,7 @@ class ExpenseStatus {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
class CreatedBy {
|
class User {
|
||||||
String id;
|
String id;
|
||||||
String firstName;
|
String firstName;
|
||||||
String lastName;
|
String lastName;
|
||||||
@ -290,7 +298,7 @@ class CreatedBy {
|
|||||||
String jobRoleId;
|
String jobRoleId;
|
||||||
String jobRoleName;
|
String jobRoleName;
|
||||||
|
|
||||||
CreatedBy({
|
User({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.firstName,
|
required this.firstName,
|
||||||
required this.lastName,
|
required this.lastName,
|
||||||
@ -300,7 +308,7 @@ class CreatedBy {
|
|||||||
required this.jobRoleName,
|
required this.jobRoleName,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
|
factory User.fromJson(Map<String, dynamic> json) => User(
|
||||||
id: json['id'],
|
id: json['id'],
|
||||||
firstName: json['firstName'],
|
firstName: json['firstName'],
|
||||||
lastName: json['lastName'],
|
lastName: json['lastName'],
|
||||||
@ -362,3 +370,75 @@ class NextStatus {
|
|||||||
'isSystem': isSystem,
|
'isSystem': isSystem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class UpdateLog {
|
||||||
|
String id;
|
||||||
|
ExpenseStatus status;
|
||||||
|
ExpenseStatus nextStatus;
|
||||||
|
String comment;
|
||||||
|
DateTime updatedAt;
|
||||||
|
User updatedBy;
|
||||||
|
|
||||||
|
UpdateLog({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
required this.nextStatus,
|
||||||
|
required this.comment,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.updatedBy,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UpdateLog.fromJson(Map<String, dynamic> json) => UpdateLog(
|
||||||
|
id: json['id'],
|
||||||
|
status: ExpenseStatus.fromJson(json['status']),
|
||||||
|
nextStatus: ExpenseStatus.fromJson(json['nextStatus']),
|
||||||
|
comment: json['comment'],
|
||||||
|
updatedAt: DateTime.parse(json['updatedAt']),
|
||||||
|
updatedBy: User.fromJson(json['updatedBy']),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'status': status.toJson(),
|
||||||
|
'nextStatus': nextStatus.toJson(),
|
||||||
|
'comment': comment,
|
||||||
|
'updatedAt': updatedAt.toIso8601String(),
|
||||||
|
'updatedBy': updatedBy.toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class Attachment {
|
||||||
|
String id;
|
||||||
|
String fileName;
|
||||||
|
String url;
|
||||||
|
String? thumbUrl;
|
||||||
|
int fileSize;
|
||||||
|
String contentType;
|
||||||
|
|
||||||
|
Attachment({
|
||||||
|
required this.id,
|
||||||
|
required this.fileName,
|
||||||
|
required this.url,
|
||||||
|
this.thumbUrl,
|
||||||
|
required this.fileSize,
|
||||||
|
required this.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Attachment.fromJson(Map<String, dynamic> json) => Attachment(
|
||||||
|
id: json['id'],
|
||||||
|
fileName: json['fileName'],
|
||||||
|
url: json['url'],
|
||||||
|
thumbUrl: json['thumbUrl'],
|
||||||
|
fileSize: json['fileSize'],
|
||||||
|
contentType: json['contentType'],
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'id': id,
|
||||||
|
'fileName': fileName,
|
||||||
|
'url': url,
|
||||||
|
'thumbUrl': thumbUrl,
|
||||||
|
'fileSize': fileSize,
|
||||||
|
'contentType': contentType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal file
271
lib/model/finance/payment_request_rembursement_bottom_sheet.dart
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import 'package:marco/controller/finance/payment_request_detail_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
|
||||||
|
|
||||||
|
class UpdatePaymentRequestWithReimbursement extends StatefulWidget {
|
||||||
|
final String expenseId;
|
||||||
|
final String statusId;
|
||||||
|
final void Function() onClose;
|
||||||
|
|
||||||
|
const UpdatePaymentRequestWithReimbursement({
|
||||||
|
super.key,
|
||||||
|
required this.expenseId,
|
||||||
|
required this.onClose,
|
||||||
|
required this.statusId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UpdatePaymentRequestWithReimbursement> createState() =>
|
||||||
|
_UpdatePaymentRequestWithReimbursement();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UpdatePaymentRequestWithReimbursement
|
||||||
|
extends State<UpdatePaymentRequestWithReimbursement> {
|
||||||
|
final PaymentRequestDetailController controller =
|
||||||
|
Get.find<PaymentRequestDetailController>();
|
||||||
|
|
||||||
|
final TextEditingController commentCtrl = TextEditingController();
|
||||||
|
final TextEditingController txnCtrl = TextEditingController();
|
||||||
|
final TextEditingController tdsCtrl = TextEditingController(text: '0');
|
||||||
|
final TextEditingController baseAmountCtrl = TextEditingController();
|
||||||
|
final TextEditingController taxAmountCtrl = TextEditingController();
|
||||||
|
final RxString dateStr = ''.obs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
commentCtrl.dispose();
|
||||||
|
txnCtrl.dispose();
|
||||||
|
tdsCtrl.dispose();
|
||||||
|
baseAmountCtrl.dispose();
|
||||||
|
taxAmountCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Employee selection bottom sheet
|
||||||
|
void _showEmployeeList() async {
|
||||||
|
await showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => ReusableEmployeeSelectorBottomSheet(
|
||||||
|
searchController: controller.employeeSearchController,
|
||||||
|
searchResults: controller.employeeSearchResults,
|
||||||
|
isSearching: controller.isSearchingEmployees,
|
||||||
|
onSearch: controller.searchEmployees,
|
||||||
|
onSelect: (emp) => controller.selectedReimbursedBy.value = emp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional cleanup
|
||||||
|
controller.employeeSearchController.clear();
|
||||||
|
controller.employeeSearchResults.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
return BaseBottomSheet(
|
||||||
|
title: "Proceed Payment",
|
||||||
|
isSubmitting: controller.isLoading.value,
|
||||||
|
onCancel: () {
|
||||||
|
widget.onClose();
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
onSubmit: () async {
|
||||||
|
// Mandatory fields validation
|
||||||
|
if (commentCtrl.text.trim().isEmpty ||
|
||||||
|
txnCtrl.text.trim().isEmpty ||
|
||||||
|
dateStr.value.isEmpty ||
|
||||||
|
baseAmountCtrl.text.trim().isEmpty ||
|
||||||
|
taxAmountCtrl.text.trim().isEmpty) {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: "Incomplete",
|
||||||
|
message: "Please fill all mandatory fields",
|
||||||
|
type: SnackbarType.warning,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Parse inputs
|
||||||
|
final parsedDate =
|
||||||
|
DateFormat('dd-MM-yyyy').parse(dateStr.value, true);
|
||||||
|
final baseAmount = double.tryParse(baseAmountCtrl.text.trim()) ?? 0;
|
||||||
|
final taxAmount = double.tryParse(taxAmountCtrl.text.trim()) ?? 0;
|
||||||
|
final tdsPercentage =
|
||||||
|
tdsCtrl.text.trim().isEmpty ? null : tdsCtrl.text.trim();
|
||||||
|
|
||||||
|
// Call API
|
||||||
|
final success = await controller.updatePaymentRequestStatus(
|
||||||
|
statusId: widget.statusId,
|
||||||
|
comment: commentCtrl.text.trim(),
|
||||||
|
paidTransactionId: txnCtrl.text.trim(),
|
||||||
|
paidById: controller.selectedReimbursedBy.value?.id,
|
||||||
|
paidAt: parsedDate,
|
||||||
|
baseAmount: baseAmount,
|
||||||
|
taxAmount: taxAmount,
|
||||||
|
tdsPercentage: tdsPercentage,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show snackbar
|
||||||
|
showAppSnackbar(
|
||||||
|
title: success ? 'Success' : 'Error',
|
||||||
|
message: success
|
||||||
|
? 'Payment updated successfully'
|
||||||
|
: 'Failed to update payment',
|
||||||
|
type: success ? SnackbarType.success : SnackbarType.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Ensure bottom sheet closes and callback is called
|
||||||
|
widget.onClose(); // optional callback for parent refresh
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
} else {
|
||||||
|
Get.close(1); // fallback if Navigator can't pop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
print("Error updating payment: $e\n$st");
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Something went wrong. Please try again.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.labelMedium("Transaction ID*"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: txnCtrl,
|
||||||
|
decoration: _inputDecoration("Enter transaction ID"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Transaction Date*"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final today = DateTime.now();
|
||||||
|
final firstDate = DateTime(2020);
|
||||||
|
final lastDate = today;
|
||||||
|
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: today,
|
||||||
|
firstDate: firstDate,
|
||||||
|
lastDate: lastDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
dateStr.value = DateFormat('dd-MM-yyyy').format(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextField(
|
||||||
|
controller: TextEditingController(text: dateStr.value),
|
||||||
|
decoration: _inputDecoration("Select Date").copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Paid By (Optional)"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showEmployeeList,
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.selectedReimbursedBy.value == null
|
||||||
|
? "Select Paid By"
|
||||||
|
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, size: 22),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("TDS Percentage (Optional)"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: tdsCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: _inputDecoration("Enter TDS Percentage"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Base Amount*"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: baseAmountCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: _inputDecoration("Enter Base Amount"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Tax Amount*"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: taxAmountCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: _inputDecoration("Enter Tax Amount"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
MyText.labelMedium("Comment*"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: commentCtrl,
|
||||||
|
decoration: _inputDecoration("Enter comment"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -14,6 +14,12 @@ import 'package:url_launcher/url_launcher.dart';
|
|||||||
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
import 'package:marco/helpers/widgets/image_viewer_dialog.dart';
|
||||||
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:timeago/timeago.dart' as timeago;
|
import 'package:timeago/timeago.dart' as timeago;
|
||||||
|
import 'package:marco/model/expense/comment_bottom_sheet.dart';
|
||||||
|
import 'package:marco/helpers/services/storage/local_storage.dart';
|
||||||
|
import 'package:marco/model/employees/employee_info.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
|
import 'package:marco/model/finance/payment_request_rembursement_bottom_sheet.dart';
|
||||||
|
import 'package:marco/model/finance/make_expense_bottom_sheet.dart';
|
||||||
|
|
||||||
class PaymentRequestDetailScreen extends StatefulWidget {
|
class PaymentRequestDetailScreen extends StatefulWidget {
|
||||||
final String paymentRequestId;
|
final String paymentRequestId;
|
||||||
@ -29,11 +35,46 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
final controller = Get.put(PaymentRequestDetailController());
|
final controller = Get.put(PaymentRequestDetailController());
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
final permissionController = Get.find<PermissionController>();
|
final permissionController = Get.find<PermissionController>();
|
||||||
|
final RxBool canSubmit = false.obs;
|
||||||
|
bool _checkedPermission = false;
|
||||||
|
|
||||||
|
EmployeeInfo? employeeInfo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller.init(widget.paymentRequestId);
|
controller.init(widget.paymentRequestId);
|
||||||
|
_loadEmployeeInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _checkPermissionToSubmit(PaymentRequestData request) {
|
||||||
|
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final isCreatedByCurrentUser = employeeInfo?.id == request.createdBy.id;
|
||||||
|
final hasDraftNextStatus =
|
||||||
|
request.nextStatus.any((s) => s.id == draftStatusId);
|
||||||
|
|
||||||
|
final result = isCreatedByCurrentUser && hasDraftNextStatus;
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
print('🔐 Submit Permission Check:\n'
|
||||||
|
'Logged-in employee: ${employeeInfo?.id}\n'
|
||||||
|
'Created by: ${request.createdBy.id}\n'
|
||||||
|
'Has Draft Next Status: $hasDraftNextStatus\n'
|
||||||
|
'Can Submit: $result');
|
||||||
|
|
||||||
|
canSubmit.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadEmployeeInfo() async {
|
||||||
|
employeeInfo = await LocalStorage.getEmployeeInfo();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _parseColor(String hexColor) {
|
||||||
|
String hex = hexColor.toUpperCase().replaceAll('#', '');
|
||||||
|
if (hex.length == 6) hex = 'FF$hex';
|
||||||
|
return Color(int.parse(hex, radix: 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -46,9 +87,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
return SkeletonLoaders.paymentRequestDetailSkeletonLoader();
|
||||||
}
|
}
|
||||||
|
final request = controller.paymentRequest.value;
|
||||||
final request =
|
|
||||||
controller.paymentRequest.value as PaymentRequestData?;
|
|
||||||
if (controller.errorMessage.isNotEmpty || request == null) {
|
if (controller.errorMessage.isNotEmpty || request == null) {
|
||||||
return Center(child: MyText.bodyMedium("No data to display."));
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
}
|
}
|
||||||
@ -57,7 +96,11 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
onRefresh: controller.fetchPaymentRequestDetail,
|
onRefresh: controller.fetchPaymentRequestDetail,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
|
12,
|
||||||
|
12,
|
||||||
|
12,
|
||||||
|
60 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Container(
|
child: Container(
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
@ -71,13 +114,12 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_Header(request: request),
|
_Header(request: request, colorParser: _parseColor),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
_Logs(
|
||||||
// Move Logs here, right after header
|
logs: request.updateLogs,
|
||||||
_Logs(logs: request.updateLogs),
|
colorParser: _parseColor),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
_Parties(request: request),
|
_Parties(request: request),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
_DetailsTable(request: request),
|
_DetailsTable(request: request),
|
||||||
@ -93,6 +135,134 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
bottomNavigationBar: Obx(() {
|
||||||
|
final request = controller.paymentRequest.value;
|
||||||
|
if (request == null ||
|
||||||
|
controller.isLoading.value ||
|
||||||
|
employeeInfo == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check permissions once
|
||||||
|
if (!_checkedPermission) {
|
||||||
|
_checkedPermission = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_checkPermissionToSubmit(request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter statuses
|
||||||
|
const reimbursementStatusId = '61578360-3a49-4c34-8604-7b35a3787b95';
|
||||||
|
const draftStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final availableStatuses = request.nextStatus.where((status) {
|
||||||
|
if (status.id == draftStatusId) {
|
||||||
|
return employeeInfo?.id == request.createdBy.id;
|
||||||
|
}
|
||||||
|
return permissionController
|
||||||
|
.hasAnyPermission(status.permissionIds ?? []);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// If there are no next statuses, show "Create Expense" button
|
||||||
|
if (availableStatuses.isEmpty) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
backgroundColor: Colors.blue,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
showCreateExpenseBottomSheet();
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
"Create Expense",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white, fontWeight: FontWeight.bold),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal status buttons
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: availableStatuses.map((status) {
|
||||||
|
final color = _parseColor(status.color);
|
||||||
|
|
||||||
|
return ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 10),
|
||||||
|
backgroundColor: color,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onPressed: () async {
|
||||||
|
if (status.id == reimbursementStatusId) {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.vertical(top: Radius.circular(5)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => UpdatePaymentRequestWithReimbursement(
|
||||||
|
expenseId: request.paymentRequestUID,
|
||||||
|
statusId: status.id,
|
||||||
|
onClose: () {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final comment = await showCommentBottomSheet(
|
||||||
|
context, status.displayName);
|
||||||
|
if (comment == null || comment.trim().isEmpty) return;
|
||||||
|
|
||||||
|
final success =
|
||||||
|
await controller.updatePaymentRequestStatus(
|
||||||
|
statusId: status.id,
|
||||||
|
comment: comment.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
showAppSnackbar(
|
||||||
|
title: success ? 'Success' : 'Error',
|
||||||
|
message: success
|
||||||
|
? 'Status updated successfully'
|
||||||
|
: 'Failed to update status',
|
||||||
|
type:
|
||||||
|
success ? SnackbarType.success : SnackbarType.error,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) await controller.fetchPaymentRequestDetail();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Text(status.displayName,
|
||||||
|
style: const TextStyle(color: Colors.white)),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,8 +296,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
),
|
),
|
||||||
MySpacing.height(2),
|
MySpacing.height(2),
|
||||||
GetBuilder<ProjectController>(
|
GetBuilder<ProjectController>(builder: (_) {
|
||||||
builder: (_) {
|
|
||||||
final name = projectController.selectedProject?.name ??
|
final name = projectController.selectedProject?.name ??
|
||||||
'Select Project';
|
'Select Project';
|
||||||
return Row(
|
return Row(
|
||||||
@ -145,8 +314,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -158,28 +326,17 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header Row
|
|
||||||
class _Header extends StatelessWidget {
|
class _Header extends StatelessWidget {
|
||||||
final PaymentRequestData request;
|
final PaymentRequestData request;
|
||||||
const _Header({required this.request});
|
final Color Function(String) colorParser;
|
||||||
|
const _Header({required this.request, required this.colorParser});
|
||||||
// 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final statusColor = parseColorFromHex(request.expenseStatus.color);
|
final statusColor = colorParser(request.expenseStatus.color);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Left side: wrap in Expanded to prevent overflow
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@ -199,8 +356,6 @@ class _Header extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Right side: Status Chip
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: statusColor.withOpacity(0.15),
|
color: statusColor.withOpacity(0.15),
|
||||||
@ -211,7 +366,6 @@ class _Header extends StatelessWidget {
|
|||||||
Icon(Icons.flag, size: 16, color: statusColor),
|
Icon(Icons.flag, size: 16, color: statusColor),
|
||||||
MySpacing.width(4),
|
MySpacing.width(4),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
// Prevent overflow of long status text
|
|
||||||
width: 100,
|
width: 100,
|
||||||
child: MyText.labelSmall(
|
child: MyText.labelSmall(
|
||||||
request.expenseStatus.displayName,
|
request.expenseStatus.displayName,
|
||||||
@ -228,185 +382,20 @@ class _Header extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 {
|
class _Logs extends StatelessWidget {
|
||||||
final List<dynamic> logs;
|
final List<UpdateLog> logs;
|
||||||
const _Logs({required this.logs});
|
final Color Function(String) colorParser;
|
||||||
|
const _Logs({required this.logs, required this.colorParser});
|
||||||
|
|
||||||
// Helper to parse hex color string to Color
|
DateTime _parseTimestamp(DateTime ts) => ts;
|
||||||
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (logs.isEmpty) return MyText.bodyMedium('No Timeline', color: Colors.grey);
|
if (logs.isEmpty) {
|
||||||
|
return MyText.bodyMedium('No Timeline', color: Colors.grey);
|
||||||
|
}
|
||||||
|
|
||||||
final reversedLogs = logs.reversed.toList();
|
final reversedLogs = logs.reversed.toList();
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -416,32 +405,24 @@ class _Logs extends StatelessWidget {
|
|||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: reversedLogs.length,
|
itemCount: reversedLogs.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
final log = reversedLogs[index] as Map<String, dynamic>;
|
final log = reversedLogs[index];
|
||||||
final statusMap = log['status'] ?? {};
|
|
||||||
final status = statusMap['name'] ?? '';
|
|
||||||
final description = statusMap['description'] ?? '';
|
|
||||||
final comment = log['comment'] ?? '';
|
|
||||||
|
|
||||||
final nextStatusMap = log['nextStatus'] ?? {};
|
final status = log.status.name;
|
||||||
final nextStatusName = nextStatusMap['name'] ?? '';
|
final description = log.status.description;
|
||||||
|
final comment = log.comment;
|
||||||
|
final nextStatusName = log.nextStatus.name;
|
||||||
|
|
||||||
final updatedBy = log['updatedBy'] ?? {};
|
final updatedBy = log.updatedBy;
|
||||||
final initials =
|
final initials =
|
||||||
"${(updatedBy['firstName'] ?? '').isNotEmpty ? (updatedBy['firstName']![0]) : ''}"
|
'${updatedBy.firstName.isNotEmpty == true ? updatedBy.firstName[0] : ''}'
|
||||||
"${(updatedBy['lastName'] ?? '').isNotEmpty ? (updatedBy['lastName']![0]) : ''}";
|
'${updatedBy.lastName.isNotEmpty == true ? updatedBy.lastName[0] : ''}';
|
||||||
final name =
|
final name = '${updatedBy.firstName} ${updatedBy.lastName}';
|
||||||
"${updatedBy['firstName'] ?? ''} ${updatedBy['lastName'] ?? ''}";
|
|
||||||
|
|
||||||
final timestamp = parseTimestamp(log['updatedAt']);
|
final timestamp = _parseTimestamp(log.updatedAt);
|
||||||
final timeAgo = timeago.format(timestamp);
|
final timeAgo = timeago.format(timestamp);
|
||||||
|
|
||||||
final statusColor = statusMap['color'] != null
|
final statusColor = colorParser(log.status.color);
|
||||||
? parseColorFromHex(statusMap['color'])
|
final nextStatusColor = colorParser(log.nextStatus.color);
|
||||||
: Colors.black;
|
|
||||||
|
|
||||||
final nextStatusColor = nextStatusMap['color'] != null
|
|
||||||
? parseColorFromHex(nextStatusMap['color'])
|
|
||||||
: Colors.blue.shade700;
|
|
||||||
|
|
||||||
return TimelineTile(
|
return TimelineTile(
|
||||||
alignment: TimelineAlign.start,
|
alignment: TimelineAlign.start,
|
||||||
@ -451,10 +432,8 @@ class _Logs extends StatelessWidget {
|
|||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
indicator: Container(
|
indicator: Container(
|
||||||
decoration: BoxDecoration(
|
decoration:
|
||||||
shape: BoxShape.circle,
|
BoxDecoration(shape: BoxShape.circle, color: statusColor),
|
||||||
color: statusColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
beforeLineStyle:
|
beforeLineStyle:
|
||||||
@ -464,20 +443,14 @@ class _Logs extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Status and time in one row
|
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium(
|
MyText.bodyMedium(status,
|
||||||
status,
|
fontWeight: 600, color: statusColor),
|
||||||
fontWeight: 600,
|
MyText.bodySmall(timeAgo,
|
||||||
color: statusColor,
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
timeAgo,
|
|
||||||
color: Colors.grey[600],
|
color: Colors.grey[600],
|
||||||
textAlign: TextAlign.right,
|
textAlign: TextAlign.right),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (description.isNotEmpty) ...[
|
if (description.isNotEmpty) ...[
|
||||||
@ -502,11 +475,8 @@ class _Logs extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(name,
|
||||||
name,
|
overflow: TextOverflow.ellipsis)),
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (nextStatusName.isNotEmpty)
|
if (nextStatusName.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@ -515,11 +485,8 @@ class _Logs extends StatelessWidget {
|
|||||||
color: nextStatusColor.withOpacity(0.15),
|
color: nextStatusColor.withOpacity(0.15),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(nextStatusName,
|
||||||
nextStatusName,
|
fontWeight: 600, color: nextStatusColor),
|
||||||
fontWeight: 600,
|
|
||||||
color: nextStatusColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -533,3 +500,146 @@ class _Logs extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _Parties extends StatelessWidget {
|
||||||
|
final PaymentRequestData request;
|
||||||
|
const _Parties({required this.request});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_labelValueRow('Project', request.project.name),
|
||||||
|
_labelValueRow('Payee', request.payee),
|
||||||
|
_labelValueRow('Created By',
|
||||||
|
'${request.createdBy.firstName} ${request.createdBy.lastName}'),
|
||||||
|
_labelValueRow('Pre-Approved', request.isAdvancePayment ? 'Yes' : 'No'),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DetailsTable extends StatelessWidget {
|
||||||
|
final PaymentRequestData request;
|
||||||
|
const _DetailsTable({required this.request});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_labelValueRow("Payment Request ID:", request.paymentRequestUID),
|
||||||
|
_labelValueRow("Expense Category:", request.expenseCategory.name),
|
||||||
|
_labelValueRow("Amount:",
|
||||||
|
"${request.currency.symbol} ${request.amount.toStringAsFixed(2)}"),
|
||||||
|
_labelValueRow(
|
||||||
|
"Due Date:",
|
||||||
|
DateTimeUtils.convertUtcToLocal(request.dueDate.toIso8601String(),
|
||||||
|
format: 'dd MMM yyyy')),
|
||||||
|
_labelValueRow("Description:", request.description),
|
||||||
|
_labelValueRow(
|
||||||
|
"Attachment:", request.attachments.isNotEmpty ? "Yes" : "No"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Documents extends StatelessWidget {
|
||||||
|
final List<Attachment> documents;
|
||||||
|
const _Documents({required this.documents});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (documents.isEmpty)
|
||||||
|
return MyText.bodyMedium('No Documents', color: Colors.grey);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall("Documents:", fontWeight: 600),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: documents.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 8),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final doc = documents[index];
|
||||||
|
final isImage = doc.contentType.startsWith('image/');
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final imageDocs = documents
|
||||||
|
.where((d) => d.contentType.startsWith('image/'))
|
||||||
|
.toList();
|
||||||
|
final initialIndex =
|
||||||
|
imageDocs.indexWhere((d) => d.id == doc.id);
|
||||||
|
|
||||||
|
if (isImage && imageDocs.isNotEmpty && initialIndex != -1) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => ImageViewerDialog(
|
||||||
|
imageSources: imageDocs.map((e) => e.url).toList(),
|
||||||
|
initialIndex: initialIndex,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
final Uri url = Uri.parse(doc.url);
|
||||||
|
if (await canLaunchUrl(url)) {
|
||||||
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
showAppSnackbar(
|
||||||
|
title: 'Error',
|
||||||
|
message: 'Could not open document.',
|
||||||
|
type: SnackbarType.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(isImage ? Icons.image : Icons.insert_drive_file,
|
||||||
|
size: 20, color: Colors.grey[600]),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.labelSmall(
|
||||||
|
doc.fileName,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility widget for label-value row.
|
||||||
|
Widget _labelValueRow(String label, String value) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 120,
|
||||||
|
child: MyText.bodySmall(label, fontWeight: 600),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: MyText.bodySmall(value, fontWeight: 500, softWrap: true),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|||||||
@ -268,6 +268,18 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
|
|
||||||
final list = filteredList(isHistory: isHistory);
|
final list = filteredList(isHistory: isHistory);
|
||||||
|
|
||||||
|
// Single ScrollController for this list
|
||||||
|
final scrollController = ScrollController();
|
||||||
|
|
||||||
|
// Load more when reaching near bottom
|
||||||
|
scrollController.addListener(() {
|
||||||
|
if (scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent - 100 &&
|
||||||
|
!paymentController.isLoading.value) {
|
||||||
|
paymentController.loadMorePaymentRequests();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: _refreshPaymentRequests,
|
onRefresh: _refreshPaymentRequests,
|
||||||
child: list.isEmpty
|
child: list.isEmpty
|
||||||
@ -288,11 +300,22 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: ListView.separated(
|
: ListView.separated(
|
||||||
|
controller: scrollController, // attach controller
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
itemCount: list.length,
|
itemCount: list.length + 1, // extra item for loading
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
Divider(color: Colors.grey.shade300, height: 20),
|
Divider(color: Colors.grey.shade300, height: 20),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
|
if (index == list.length) {
|
||||||
|
// Show loading indicator at bottom
|
||||||
|
return Obx(() => paymentController.isLoading.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
final item = list[index];
|
final item = list[index];
|
||||||
return _buildPaymentRequestTile(item);
|
return _buildPaymentRequestTile(item);
|
||||||
},
|
},
|
||||||
@ -349,7 +372,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: MyText.bodySmall(
|
child: MyText.bodySmall(
|
||||||
item.expenseStatus.displayName,
|
item.expenseStatus.name,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user