370 lines
11 KiB
Dart
370 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:marco/helpers/services/api_service.dart';
|
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
|
import 'package:marco/model/employees/employee_model.dart';
|
|
import 'package:marco/model/finance/payment_request_details_model.dart';
|
|
import 'package:marco/model/expense/payment_types_model.dart';
|
|
import 'package:marco/helpers/widgets/time_stamp_image_helper.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:geocoding/geocoding.dart';
|
|
import 'package:geolocator/geolocator.dart';
|
|
import 'package:mime/mime.dart';
|
|
|
|
class PaymentRequestDetailController extends GetxController {
|
|
final Rx<PaymentRequestData?> paymentRequest = Rx<PaymentRequestData?>(null);
|
|
final RxBool isLoading = false.obs;
|
|
final RxString errorMessage = ''.obs;
|
|
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
|
|
|
|
// Employee selection
|
|
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
|
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
|
final RxList<EmployeeModel> employeeSearchResults = <EmployeeModel>[].obs;
|
|
final TextEditingController employeeSearchController =
|
|
TextEditingController();
|
|
final RxBool isSearchingEmployees = false.obs;
|
|
|
|
// Attachments
|
|
final RxList<File> attachments = <File>[].obs;
|
|
final RxList<Map<String, dynamic>> existingAttachments =
|
|
<Map<String, dynamic>>[].obs;
|
|
final isProcessingAttachment = false.obs;
|
|
|
|
// Payment mode
|
|
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
|
|
|
// Text controllers for form
|
|
final TextEditingController locationController = TextEditingController();
|
|
final TextEditingController gstNumberController = TextEditingController();
|
|
|
|
// Form submission state
|
|
final RxBool isSubmitting = false.obs;
|
|
|
|
late String _requestId;
|
|
bool _isInitialized = false;
|
|
RxBool paymentSheetOpened = false.obs;
|
|
final ImagePicker _picker = ImagePicker();
|
|
|
|
/// Initialize controller
|
|
void init(String requestId) {
|
|
if (_isInitialized) return;
|
|
_isInitialized = true;
|
|
|
|
_requestId = requestId;
|
|
|
|
// Fetch payment request details + employees concurrently
|
|
Future.wait([
|
|
fetchPaymentRequestDetail(),
|
|
fetchAllEmployees(),
|
|
fetchPaymentModes(),
|
|
]);
|
|
}
|
|
|
|
/// Generic API wrapper for error handling
|
|
Future<T?> _apiCallWrapper<T>(
|
|
Future<T?> Function() apiCall, String operationName) async {
|
|
isLoading.value = true;
|
|
errorMessage.value = '';
|
|
try {
|
|
final result = await apiCall();
|
|
return result;
|
|
} catch (e) {
|
|
errorMessage.value = 'Error during $operationName: $e';
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: errorMessage.value,
|
|
type: SnackbarType.error);
|
|
return null;
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
/// Fetch payment request details
|
|
Future<void> fetchPaymentRequestDetail() async {
|
|
isLoading.value = true;
|
|
try {
|
|
final response =
|
|
await ApiService.getExpensePaymentRequestDetailApi(_requestId);
|
|
if (response != null) {
|
|
paymentRequest.value = response.data;
|
|
} else {
|
|
errorMessage.value = "Failed to fetch payment request details";
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: errorMessage.value,
|
|
type: SnackbarType.error,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
errorMessage.value = "Error fetching payment request details: $e";
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: errorMessage.value,
|
|
type: SnackbarType.error,
|
|
);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
/// Pick files from gallery or file picker
|
|
Future<void> pickAttachments() async {
|
|
try {
|
|
final result = await FilePicker.platform.pickFiles(
|
|
type: FileType.custom,
|
|
allowedExtensions: ['jpg', 'jpeg', 'png', 'pdf', 'doc', 'docx'],
|
|
allowMultiple: true,
|
|
);
|
|
if (result != null) {
|
|
attachments.addAll(
|
|
result.paths.whereType<String>().map(File.new),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_errorSnackbar("Attachment error: $e");
|
|
}
|
|
}
|
|
|
|
void removeAttachment(File file) => attachments.remove(file);
|
|
|
|
Future<void> pickFromCamera() async {
|
|
try {
|
|
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
|
|
if (pickedFile != null) {
|
|
isProcessingAttachment.value = true;
|
|
File imageFile = File(pickedFile.path);
|
|
|
|
File timestampedFile = await TimestampImageHelper.addTimestamp(
|
|
imageFile: imageFile,
|
|
);
|
|
|
|
attachments.add(timestampedFile);
|
|
attachments.refresh();
|
|
}
|
|
} catch (e) {
|
|
_errorSnackbar("Camera error: $e");
|
|
} finally {
|
|
isProcessingAttachment.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Location ---
|
|
final RxBool isFetchingLocation = false.obs;
|
|
|
|
Future<void> fetchCurrentLocation() async {
|
|
isFetchingLocation.value = true;
|
|
try {
|
|
if (!await _ensureLocationPermission()) return;
|
|
|
|
final position = await Geolocator.getCurrentPosition();
|
|
final placemarks =
|
|
await placemarkFromCoordinates(position.latitude, position.longitude);
|
|
|
|
locationController.text = placemarks.isNotEmpty
|
|
? [
|
|
placemarks.first.name,
|
|
placemarks.first.street,
|
|
placemarks.first.locality,
|
|
placemarks.first.administrativeArea,
|
|
placemarks.first.country,
|
|
].where((e) => e?.isNotEmpty == true).join(", ")
|
|
: "${position.latitude}, ${position.longitude}";
|
|
} catch (e) {
|
|
_errorSnackbar("Location error: $e");
|
|
} finally {
|
|
isFetchingLocation.value = false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _ensureLocationPermission() async {
|
|
var permission = await Geolocator.checkPermission();
|
|
if (permission == LocationPermission.denied ||
|
|
permission == LocationPermission.deniedForever) {
|
|
permission = await Geolocator.requestPermission();
|
|
if (permission == LocationPermission.denied ||
|
|
permission == LocationPermission.deniedForever) {
|
|
_errorSnackbar("Location permission denied.");
|
|
return false;
|
|
}
|
|
}
|
|
if (!await Geolocator.isLocationServiceEnabled()) {
|
|
_errorSnackbar("Location service disabled.");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// Fetch all employees
|
|
Future<void> fetchAllEmployees() async {
|
|
final response = await _apiCallWrapper(
|
|
() => ApiService.getAllEmployees(), "fetch all employees");
|
|
|
|
if (response != null && response.isNotEmpty) {
|
|
try {
|
|
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
|
} catch (e) {
|
|
errorMessage.value = 'Failed to parse employee data: $e';
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: errorMessage.value,
|
|
type: SnackbarType.error);
|
|
}
|
|
} else {
|
|
allEmployees.clear();
|
|
}
|
|
}
|
|
|
|
/// Fetch payment modes
|
|
Future<void> fetchPaymentModes() async {
|
|
isLoading.value = true;
|
|
try {
|
|
final paymentModesData = await ApiService.getMasterPaymentModes();
|
|
if (paymentModesData is List) {
|
|
paymentModes.value =
|
|
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
|
} else {
|
|
paymentModes.clear();
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Failed to fetch payment modes',
|
|
type: SnackbarType.error);
|
|
}
|
|
} catch (e) {
|
|
paymentModes.clear();
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Error fetching payment modes: $e',
|
|
type: SnackbarType.error);
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
/// Search employees
|
|
Future<void> searchEmployees(String query) async {
|
|
if (query.trim().isEmpty) {
|
|
employeeSearchResults.clear();
|
|
return;
|
|
}
|
|
|
|
isSearchingEmployees.value = true;
|
|
try {
|
|
final data =
|
|
await ApiService.searchEmployeesBasic(searchString: query.trim());
|
|
employeeSearchResults.assignAll(
|
|
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
|
|
);
|
|
} catch (e) {
|
|
employeeSearchResults.clear();
|
|
} finally {
|
|
isSearchingEmployees.value = false;
|
|
}
|
|
}
|
|
|
|
/// Update payment request status
|
|
Future<bool> updatePaymentRequestStatus({
|
|
required String statusId,
|
|
required String comment,
|
|
String? paidTransactionId,
|
|
String? paidById,
|
|
DateTime? paidAt,
|
|
double? baseAmount,
|
|
double? taxAmount,
|
|
String? tdsPercentage,
|
|
}) async {
|
|
isLoading.value = true;
|
|
try {
|
|
final success = await ApiService.updateExpensePaymentRequestStatusApi(
|
|
paymentRequestId: _requestId,
|
|
statusId: statusId,
|
|
comment: comment,
|
|
paidTransactionId: paidTransactionId,
|
|
paidById: paidById,
|
|
paidAt: paidAt,
|
|
baseAmount: baseAmount,
|
|
taxAmount: taxAmount,
|
|
tdsPercentage: tdsPercentage,
|
|
);
|
|
|
|
if (success) {
|
|
showAppSnackbar(
|
|
title: 'Success',
|
|
message: 'Payment submitted successfully',
|
|
type: SnackbarType.success);
|
|
await fetchPaymentRequestDetail();
|
|
} else {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Failed to update status. Please try again.',
|
|
type: SnackbarType.error);
|
|
}
|
|
|
|
return success;
|
|
} catch (e) {
|
|
showAppSnackbar(
|
|
title: 'Error',
|
|
message: 'Something went wrong: $e',
|
|
type: SnackbarType.error);
|
|
return false;
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
// --- Snackbar Helper ---
|
|
void _errorSnackbar(String msg, [String title = "Error"]) {
|
|
showAppSnackbar(title: title, message: msg, type: SnackbarType.error);
|
|
}
|
|
|
|
// --- Payment Mode Selection ---
|
|
void selectPaymentMode(PaymentModeModel mode) {
|
|
selectedPaymentMode.value = mode;
|
|
}
|
|
|
|
// --- Submit Expense ---
|
|
Future<bool> submitExpense(
|
|
{required String statusId, String? comment}) async {
|
|
if (selectedPaymentMode.value == null) return false;
|
|
|
|
isSubmitting.value = true;
|
|
try {
|
|
// prepare attachments
|
|
final success = await ApiService.createExpenseForPRApi(
|
|
paymentModeId: selectedPaymentMode.value!.id,
|
|
location: locationController.text,
|
|
gstNumber: gstNumberController.text,
|
|
paymentRequestId: _requestId,
|
|
billAttachments: 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(),
|
|
statusId: statusId,
|
|
comment: comment ?? '',
|
|
);
|
|
|
|
if (success) {
|
|
// Refresh the payment request details so the UI updates
|
|
await fetchPaymentRequestDetail();
|
|
}
|
|
|
|
return success;
|
|
} finally {
|
|
isSubmitting.value = false;
|
|
}
|
|
}
|
|
}
|