marco.pms.mobileapp/lib/controller/expense/add_expense_controller.dart

501 lines
16 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.dart';
import 'package:mime/mime.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
class AddExpenseController extends GetxController {
// Text Controllers
final amountController = TextEditingController();
final descriptionController = TextEditingController();
final supplierController = TextEditingController();
final transactionIdController = TextEditingController();
final gstController = TextEditingController();
final locationController = TextEditingController();
final transactionDateController = TextEditingController();
final noOfPersonsController = TextEditingController();
// State
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = false.obs;
// Dropdown Selections
final selectedPaymentMode = Rx<PaymentModeModel?>(null);
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
final selectedPaidBy = Rx<EmployeeModel?>(null);
final selectedProject = ''.obs;
final selectedTransactionDate = Rx<DateTime?>(null);
// Data Lists
final attachments = <File>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs;
// Editing
String? editingExpenseId;
final expenseController = Get.find<ExpenseController>();
@override
void onInit() {
super.onInit();
fetchMasterData();
fetchGlobalProjects();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
@override
void onClose() {
amountController.dispose();
descriptionController.dispose();
supplierController.dispose();
transactionIdController.dispose();
gstController.dispose();
locationController.dispose();
transactionDateController.dispose();
noOfPersonsController.dispose();
super.onClose();
}
Future<void> searchEmployees(String searchQuery) async {
if (searchQuery.trim().isEmpty) {
employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true;
try {
final results = await ApiService.searchEmployeesBasic(
searchString: searchQuery.trim(),
);
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// ---------- Form Population for Edit ----------
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = data['id'];
// --- Fetch all Paid By variables up front ---
final paidById = (data['paidById'] ?? '').toString();
final paidByFirstName = (data['paidByFirstName'] ?? '').toString().trim();
final paidByLastName = (data['paidByLastName'] ?? '').toString().trim();
// --- Standard Fields ---
selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? '';
supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? '';
// --- Transaction Date ---
if (data['transactionDate'] != null) {
try {
final parsedDate = DateTime.parse(data['transactionDate']);
selectedTransactionDate.value = parsedDate;
transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsedDate);
} catch (e) {
logSafe('Error parsing transactionDate: $e', level: LogLevel.warning);
selectedTransactionDate.value = null;
transactionDateController.clear();
}
} else {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
// --- No of Persons ---
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// --- Dropdown selections ---
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// --- Paid By select ---
// 1. By ID
// --- Paid By select ---
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null) {
final fullName = '$paidByFirstName $paidByLastName';
await searchEmployees(fullName);
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
// --- Existing Attachments ---
existingAttachments.clear();
if (data['attachments'] != null && data['attachments'] is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments']).map((e) {
return {
...e,
'isActive': true, // default
};
}),
);
}
_logPrefilledData();
}
void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
logSafe('ID: $editingExpenseId', level: LogLevel.info);
logSafe('Project: ${selectedProject.value}', level: LogLevel.info);
logSafe('Amount: ${amountController.text}', level: LogLevel.info);
logSafe('Supplier: ${supplierController.text}', level: LogLevel.info);
logSafe('Description: ${descriptionController.text}', level: LogLevel.info);
logSafe('Transaction ID: ${transactionIdController.text}',
level: LogLevel.info);
logSafe('Location: ${locationController.text}', level: LogLevel.info);
logSafe('Transaction Date: ${transactionDateController.text}',
level: LogLevel.info);
logSafe('No. of Persons: ${noOfPersonsController.text}',
level: LogLevel.info);
logSafe('Expense Type: ${selectedExpenseType.value?.name}',
level: LogLevel.info);
logSafe('Payment Mode: ${selectedPaymentMode.value?.name}',
level: LogLevel.info);
logSafe('Paid By: ${selectedPaidBy.value?.name}', level: LogLevel.info);
logSafe('Attachments: ${attachments.length}', level: LogLevel.info);
logSafe('Existing Attachments: ${existingAttachments.length}',
level: LogLevel.info);
}
// ---------- Form Actions ----------
Future<void> pickTransactionDate(BuildContext context) async {
final picked = await showDatePicker(
context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(),
);
if (picked != null) {
selectedTransactionDate.value = picked;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked);
}
}
Future<void> loadMasterData() async {
await Future.wait([
fetchMasterData(),
fetchGlobalProjects(),
]);
}
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((path) => File(path)),
);
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Attachment error: $e",
type: SnackbarType.error,
);
}
}
void removeAttachment(File file) => attachments.remove(file);
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
var permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
permission = await Geolocator.requestPermission();
if (permission == LocationPermission.denied ||
permission == LocationPermission.deniedForever) {
showAppSnackbar(
title: "Error",
message: "Location permission denied.",
type: SnackbarType.error,
);
return;
}
}
if (!await Geolocator.isLocationServiceEnabled()) {
showAppSnackbar(
title: "Error",
message: "Location service disabled.",
type: SnackbarType.error,
);
return;
}
final position = await Geolocator.getCurrentPosition();
final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude);
if (placemarks.isNotEmpty) {
final place = placemarks.first;
final address = [
place.name,
place.street,
place.locality,
place.administrativeArea,
place.country
].where((e) => e != null && e.isNotEmpty).join(", ");
locationController.text = address;
} else {
locationController.text = "${position.latitude}, ${position.longitude}";
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Location error: $e",
type: SnackbarType.error,
);
} finally {
isFetchingLocation.value = false;
}
}
// ---------- Submission ----------
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
final validation = validateForm();
if (validation.isNotEmpty) {
showAppSnackbar(
title: "Missing Fields",
message: validation,
type: SnackbarType.error,
);
return;
}
final payload = await _buildExpensePayload();
final success = isEditMode.value && editingExpenseId != null
? await ApiService.editExpenseApi(
expenseId: editingExpenseId!, payload: payload)
: await ApiService.createExpenseApi(
projectId: payload['projectId'],
expensesTypeId: payload['expensesTypeId'],
paymentModeId: payload['paymentModeId'],
paidById: payload['paidById'],
transactionDate: DateTime.parse(payload['transactionDate']),
transactionId: payload['transactionId'],
description: payload['description'],
location: payload['location'],
supplerName: payload['supplerName'],
amount: payload['amount'],
noOfPersons: payload['noOfPersons'],
billAttachments: payload['billAttachments'],
);
if (success) {
await expenseController.fetchExpenses();
Get.back();
showAppSnackbar(
title: "Success",
message:
"Expense ${isEditMode.value ? 'updated' : 'created'} successfully!",
type: SnackbarType.success,
);
} else {
showAppSnackbar(
title: "Error",
message: "Operation failed. Try again.",
type: SnackbarType.error,
);
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Unexpected error: $e",
type: SnackbarType.error,
);
} finally {
isSubmitting.value = false;
}
}
Future<Map<String, dynamic>> _buildExpensePayload() async {
final amount = double.parse(amountController.text.trim());
final projectId = projectsMap[selectedProject.value]!;
final selectedDate =
selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc();
final existingAttachmentPayloads = existingAttachments.map((e) {
final isActive = e['isActive'] ?? true;
return {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": isActive,
"base64Data": isActive ? e['base64Data'] : null,
};
}).toList();
final newAttachmentPayloads =
await Future.wait(attachments.map((file) async {
final bytes = await file.readAsBytes();
return {
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType": lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": await file.length(),
"description": "",
};
}));
final billAttachments = [
...existingAttachmentPayloads,
...newAttachmentPayloads
];
final Map<String, dynamic> payload = {
"projectId": projectId,
"expensesTypeId": selectedExpenseType.value!.id,
"paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id,
"transactionDate": selectedDate.toIso8601String(),
"transactionId": transactionIdController.text,
"description": descriptionController.text,
"location": locationController.text,
"supplerName": supplierController.text,
"amount": amount,
"noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments": billAttachments,
};
// ✅ Add expense ID if in edit mode
if (isEditMode.value && editingExpenseId != null) {
payload['id'] = editingExpenseId;
}
return payload;
}
String validateForm() {
final missing = <String>[];
if (selectedProject.value.isEmpty) missing.add("Project");
if (selectedExpenseType.value == null) missing.add("Expense Type");
if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (supplierController.text.trim().isEmpty) missing.add("Supplier Name");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments");
final amount = double.tryParse(amountController.text.trim());
if (amount == null) missing.add("Valid Amount");
final selectedDate = selectedTransactionDate.value;
if (selectedDate != null && selectedDate.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// ---------- Data Fetching ----------
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
final modes = await ApiService.getMasterPaymentModes();
if (types is List) {
expenseTypes.value =
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
if (modes is List) {
paymentModes.value =
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
}
} catch (e) {
showAppSnackbar(
title: "Error",
message: "Failed to fetch master data",
type: SnackbarType.error,
);
}
}
Future<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
for (var item in response) {
final name = item['name']?.toString().trim();
final id = item['id']?.toString().trim();
if (name != null && id != null) {
projectsMap[name] = id;
names.add(name);
}
}
globalProjects.assignAll(names);
}
} catch (e) {
logSafe("Error fetching projects: $e", level: LogLevel.error);
}
}
}