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

478 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: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/employees/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart';
import 'package:mime/mime.dart';
import 'package:image_picker/image_picker.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();
final employeeSearchController = TextEditingController();
// --- Reactive State ---
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = false.obs;
final isSearchingEmployees = false.obs;
// --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>();
final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs;
final employeeSearchResults = <EmployeeModel>[].obs;
String? editingExpenseId;
final expenseController = Get.find<ExpenseController>();
final ImagePicker _picker = ImagePicker();
@override
void onInit() {
super.onInit();
fetchMasterData();
fetchGlobalProjects();
employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text);
});
}
@override
void onClose() {
for (var c in [
amountController,
descriptionController,
supplierController,
transactionIdController,
gstController,
locationController,
transactionDateController,
noOfPersonsController,
employeeSearchController,
]) {
c.dispose();
}
super.onClose();
}
// --- Employee Search ---
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
isSearchingEmployees.value = true;
try {
final data =
await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
);
} catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear();
} finally {
isSearchingEmployees.value = false;
}
}
// --- Form Population: Edit Mode ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true;
editingExpenseId = '${data['id']}';
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'] ?? '';
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// Transaction Date
if (data['transactionDate'] != null) {
try {
final parsed = DateTime.parse(data['transactionDate']);
selectedTransactionDate.value = parsed;
transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsed);
} catch (_) {
selectedTransactionDate.value = null;
transactionDateController.clear();
}
}
// Dropdown
selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// Paid By
final paidById = '${data['paidById']}';
selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
await searchEmployees(
'${data['paidByFirstName']} ${data['paidByLastName']}');
selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
}
// Attachments
existingAttachments.clear();
if (data['attachments'] is List) {
existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments'])
.map((e) => {...e, 'isActive': true}),
);
}
_logPrefilledData();
}
void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
[
'ID: $editingExpenseId',
'Project: ${selectedProject.value}',
'Amount: ${amountController.text}',
'Supplier: ${supplierController.text}',
'Description: ${descriptionController.text}',
'Transaction ID: ${transactionIdController.text}',
'Location: ${locationController.text}',
'Transaction Date: ${transactionDateController.text}',
'No. of Persons: ${noOfPersonsController.text}',
'Expense Type: ${selectedExpenseType.value?.name}',
'Payment Mode: ${selectedPaymentMode.value?.name}',
'Paid By: ${selectedPaidBy.value?.name}',
'Attachments: ${attachments.length}',
'Existing Attachments: ${existingAttachments.length}',
].forEach((str) => logSafe(str, level: LogLevel.info));
}
// --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async {
final pickedDate = await showDatePicker(
context: context,
initialDate: selectedTransactionDate.value ?? DateTime.now(),
firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(),
);
if (pickedDate != null) {
final now = DateTime.now();
final finalDateTime = DateTime(
pickedDate.year,
pickedDate.month,
pickedDate.day,
now.hour,
now.minute,
now.second,
);
selectedTransactionDate.value = finalDateTime;
transactionDateController.text =
DateFormat('dd MMM yyyy').format(finalDateTime);
}
}
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) {
_errorSnackbar("Attachment error: $e");
}
}
void removeAttachment(File file) => attachments.remove(file);
// --- Location ---
Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true;
try {
final permission = await _ensureLocationPermission();
if (!permission) 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;
}
// --- Data Fetching ---
Future<void> loadMasterData() async =>
await Future.wait([fetchMasterData(), fetchGlobalProjects()]);
Future<void> fetchMasterData() async {
try {
final types = await ApiService.getMasterExpenseTypes();
if (types is List)
expenseTypes.value =
types.map((e) => ExpenseTypeModel.fromJson(e)).toList();
final modes = await ApiService.getMasterPaymentModes();
if (modes is List)
paymentModes.value =
modes.map((e) => PaymentModeModel.fromJson(e)).toList();
} catch (_) {
_errorSnackbar("Failed to fetch master data");
}
}
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(),
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);
}
}
Future<void> pickFromCamera() async {
try {
final pickedFile = await _picker.pickImage(source: ImageSource.camera);
if (pickedFile != null) {
attachments.add(File(pickedFile.path));
}
} catch (e) {
_errorSnackbar("Camera error: $e");
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
final validationMsg = validateForm();
if (validationMsg.isNotEmpty) {
_errorSnackbar(validationMsg, "Missing Fields");
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 {
_errorSnackbar("Operation failed. Try again.");
}
} catch (e) {
_errorSnackbar("Unexpected error: $e");
} finally {
isSubmitting.value = false;
}
}
Future<Map<String, dynamic>> _buildExpensePayload() async {
final now = DateTime.now();
// --- Existing Attachments Payload (for edit mode only) ---
final List<Map<String, dynamic>> existingAttachmentPayloads =
isEditMode.value
? existingAttachments
.map<Map<String, dynamic>>((e) => {
"documentId": e['documentId'],
"fileName": e['fileName'],
"contentType": e['contentType'],
"fileSize": 0,
"description": "",
"url": e['url'],
"isActive": e['isActive'] ?? true,
"base64Data": "", // <-- always empty now
})
.toList()
: <Map<String, dynamic>>[];
// --- New Attachments Payload (always include if attachments exist) ---
final List<Map<String, dynamic>> newAttachmentPayloads =
attachments.isNotEmpty
? await Future.wait(attachments.map((file) async {
final bytes = await file.readAsBytes();
final length = await file.length();
return <String, dynamic>{
"fileName": file.path.split('/').last,
"base64Data": base64Encode(bytes),
"contentType":
lookupMimeType(file.path) ?? 'application/octet-stream',
"fileSize": length,
"description": "",
};
}))
: <Map<String, dynamic>>[];
// --- Selected Expense Type ---
final type = selectedExpenseType.value!;
// --- Combine all attachments ---
final List<Map<String, dynamic>> combinedAttachments = [
...existingAttachmentPayloads,
...newAttachmentPayloads
];
// --- Build Payload ---
final payload = <String, dynamic>{
if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id,
"transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
.toIso8601String(),
"transactionId": transactionIdController.text,
"description": descriptionController.text,
"location": locationController.text,
"supplerName": supplierController.text,
"amount": double.parse(amountController.text.trim()),
"noOfPersons": type.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0,
"billAttachments":
combinedAttachments.isEmpty ? null : combinedAttachments,
};
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 (descriptionController.text.trim().isEmpty) missing.add("Description");
// Date Required
if (selectedTransactionDate.value == null) missing.add("Transaction Date");
if (selectedTransactionDate.value != null &&
selectedTransactionDate.value!.isAfter(DateTime.now())) {
missing.add("Valid Transaction Date");
}
final amount = double.tryParse(amountController.text.trim());
if (amount == null) missing.add("Valid Amount");
// Attachment: at least one required at all times
bool hasActiveExisting =
existingAttachments.any((e) => e['isActive'] != false);
if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
}
// --- Snackbar Helper ---
void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
title: title,
message: msg,
type: SnackbarType.error,
);
}