Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
Showing only changes of commit 06fc8a4c61 - Show all commits

View File

@ -7,19 +7,18 @@ import 'package:get/get.dart';
import 'package:geocoding/geocoding.dart'; import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart'; import 'package:geolocator/geolocator.dart';
import 'package:intl/intl.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/controller/expense/expense_screen_controller.dart';
import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart';
import 'package:marco/model/employee_model.dart';
import 'package:marco/model/expense/expense_type_model.dart'; import 'package:marco/model/expense/expense_type_model.dart';
import 'package:marco/model/expense/payment_types_model.dart'; import 'package:marco/model/expense/payment_types_model.dart';
import 'package:mime/mime.dart';
class AddExpenseController extends GetxController { class AddExpenseController extends GetxController {
// Text Controllers // --- Text Controllers ---
final amountController = TextEditingController(); final amountController = TextEditingController();
final descriptionController = TextEditingController(); final descriptionController = TextEditingController();
final supplierController = TextEditingController(); final supplierController = TextEditingController();
@ -29,32 +28,32 @@ class AddExpenseController extends GetxController {
final transactionDateController = TextEditingController(); final transactionDateController = TextEditingController();
final noOfPersonsController = TextEditingController(); final noOfPersonsController = TextEditingController();
// State final employeeSearchController = TextEditingController();
// --- Reactive State ---
final isLoading = false.obs; final isLoading = false.obs;
final isSubmitting = false.obs; final isSubmitting = false.obs;
final isFetchingLocation = false.obs; final isFetchingLocation = false.obs;
final isEditMode = false.obs; final isEditMode = false.obs;
final isSearchingEmployees = false.obs;
// Dropdown Selections // --- Dropdown Selections & Data ---
final selectedPaymentMode = Rx<PaymentModeModel?>(null); final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rx<ExpenseTypeModel?>(null); final selectedExpenseType = Rxn<ExpenseTypeModel>();
final selectedPaidBy = Rx<EmployeeModel?>(null); final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs; final selectedProject = ''.obs;
final selectedTransactionDate = Rx<DateTime?>(null); final selectedTransactionDate = Rxn<DateTime>();
// Data Lists
final attachments = <File>[].obs; final attachments = <File>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final globalProjects = <String>[].obs; final globalProjects = <String>[].obs;
final projectsMap = <String, String>{}.obs; final projectsMap = <String, String>{}.obs;
final expenseTypes = <ExpenseTypeModel>[].obs; final expenseTypes = <ExpenseTypeModel>[].obs;
final paymentModes = <PaymentModeModel>[].obs; final paymentModes = <PaymentModeModel>[].obs;
final allEmployees = <EmployeeModel>[].obs; final allEmployees = <EmployeeModel>[].obs;
final existingAttachments = <Map<String, dynamic>>[].obs;
final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs;
final employeeSearchResults = <EmployeeModel>[].obs; final employeeSearchResults = <EmployeeModel>[].obs;
// Editing
String? editingExpenseId; String? editingExpenseId;
final expenseController = Get.find<ExpenseController>(); final expenseController = Get.find<ExpenseController>();
@ -64,7 +63,6 @@ class AddExpenseController extends GetxController {
super.onInit(); super.onInit();
fetchMasterData(); fetchMasterData();
fetchGlobalProjects(); fetchGlobalProjects();
employeeSearchController.addListener(() { employeeSearchController.addListener(() {
searchEmployees(employeeSearchController.text); searchEmployees(employeeSearchController.text);
}); });
@ -72,36 +70,32 @@ class AddExpenseController extends GetxController {
@override @override
void onClose() { void onClose() {
amountController.dispose(); for (var c in [
descriptionController.dispose(); amountController,
supplierController.dispose(); descriptionController,
transactionIdController.dispose(); supplierController,
gstController.dispose(); transactionIdController,
locationController.dispose(); gstController,
transactionDateController.dispose(); locationController,
noOfPersonsController.dispose(); transactionDateController,
noOfPersonsController,
employeeSearchController,
]) {
c.dispose();
}
super.onClose(); super.onClose();
} }
Future<void> searchEmployees(String searchQuery) async { // --- Employee Search ---
if (searchQuery.trim().isEmpty) { Future<void> searchEmployees(String query) async {
employeeSearchResults.clear(); if (query.trim().isEmpty) return employeeSearchResults.clear();
return;
}
isSearchingEmployees.value = true; isSearchingEmployees.value = true;
try { try {
final results = await ApiService.searchEmployeesBasic( final data =
searchString: searchQuery.trim(), await ApiService.searchEmployeesBasic(searchString: query.trim());
employeeSearchResults.assignAll(
(data ?? []).map((e) => EmployeeModel.fromJson(e)),
); );
if (results != null) {
employeeSearchResults.assignAll(
results.map((e) => EmployeeModel.fromJson(e)),
);
} else {
employeeSearchResults.clear();
}
} catch (e) { } catch (e) {
logSafe("Error searching employees: $e", level: LogLevel.error); logSafe("Error searching employees: $e", level: LogLevel.error);
employeeSearchResults.clear(); employeeSearchResults.clear();
@ -110,73 +104,55 @@ class AddExpenseController extends GetxController {
} }
} }
// ---------- Form Population for Edit ---------- // --- Form Population: Edit Mode ---
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async { Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
isEditMode.value = true; isEditMode.value = true;
editingExpenseId = data['id']; 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'] ?? ''; selectedProject.value = data['projectName'] ?? '';
amountController.text = data['amount']?.toString() ?? ''; amountController.text = data['amount']?.toString() ?? '';
supplierController.text = data['supplerName'] ?? ''; supplierController.text = data['supplerName'] ?? '';
descriptionController.text = data['description'] ?? ''; descriptionController.text = data['description'] ?? '';
transactionIdController.text = data['transactionId'] ?? ''; transactionIdController.text = data['transactionId'] ?? '';
locationController.text = data['location'] ?? ''; locationController.text = data['location'] ?? '';
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// --- Transaction Date --- // Transaction Date
if (data['transactionDate'] != null) { if (data['transactionDate'] != null) {
try { try {
final parsedDate = DateTime.parse(data['transactionDate']); final parsed = DateTime.parse(data['transactionDate']);
selectedTransactionDate.value = parsedDate; selectedTransactionDate.value = parsed;
transactionDateController.text = transactionDateController.text =
DateFormat('dd-MM-yyyy').format(parsedDate); DateFormat('dd-MM-yyyy').format(parsed);
} catch (e) { } catch (_) {
logSafe('Error parsing transactionDate: $e', level: LogLevel.warning);
selectedTransactionDate.value = null; selectedTransactionDate.value = null;
transactionDateController.clear(); transactionDateController.clear();
} }
} else {
selectedTransactionDate.value = null;
transactionDateController.clear();
} }
// --- No of Persons --- // Dropdown
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
// --- Dropdown selections ---
selectedExpenseType.value = selectedExpenseType.value =
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value = selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
// --- Paid By select --- // Paid By
// 1. By ID final paidById = '${data['paidById']}';
// --- Paid By select ---
selectedPaidBy.value = selectedPaidBy.value =
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
if (selectedPaidBy.value == null && data['paidByFirstName'] != null) {
if (selectedPaidBy.value == null) { await searchEmployees(
final fullName = '$paidByFirstName $paidByLastName'; '${data['paidByFirstName']} ${data['paidByLastName']}');
await searchEmployees(fullName);
selectedPaidBy.value = employeeSearchResults selectedPaidBy.value = employeeSearchResults
.firstWhereOrNull((e) => e.id.trim() == paidById.trim()); .firstWhereOrNull((e) => e.id.trim() == paidById.trim());
} }
// --- Existing Attachments --- // Attachments
existingAttachments.clear(); existingAttachments.clear();
if (data['attachments'] != null && data['attachments'] is List) { if (data['attachments'] is List) {
existingAttachments.addAll( existingAttachments.addAll(
List<Map<String, dynamic>>.from(data['attachments']).map((e) { List<Map<String, dynamic>>.from(data['attachments'])
return { .map((e) => {...e, 'isActive': true}),
...e,
'isActive': true, // default
};
}),
); );
} }
@ -185,29 +161,25 @@ class AddExpenseController extends GetxController {
void _logPrefilledData() { void _logPrefilledData() {
logSafe('--- Prefilled Expense Data ---', level: LogLevel.info); logSafe('--- Prefilled Expense Data ---', level: LogLevel.info);
logSafe('ID: $editingExpenseId', level: LogLevel.info); [
logSafe('Project: ${selectedProject.value}', level: LogLevel.info); 'ID: $editingExpenseId',
logSafe('Amount: ${amountController.text}', level: LogLevel.info); 'Project: ${selectedProject.value}',
logSafe('Supplier: ${supplierController.text}', level: LogLevel.info); 'Amount: ${amountController.text}',
logSafe('Description: ${descriptionController.text}', level: LogLevel.info); 'Supplier: ${supplierController.text}',
logSafe('Transaction ID: ${transactionIdController.text}', 'Description: ${descriptionController.text}',
level: LogLevel.info); 'Transaction ID: ${transactionIdController.text}',
logSafe('Location: ${locationController.text}', level: LogLevel.info); 'Location: ${locationController.text}',
logSafe('Transaction Date: ${transactionDateController.text}', 'Transaction Date: ${transactionDateController.text}',
level: LogLevel.info); 'No. of Persons: ${noOfPersonsController.text}',
logSafe('No. of Persons: ${noOfPersonsController.text}', 'Expense Type: ${selectedExpenseType.value?.name}',
level: LogLevel.info); 'Payment Mode: ${selectedPaymentMode.value?.name}',
logSafe('Expense Type: ${selectedExpenseType.value?.name}', 'Paid By: ${selectedPaidBy.value?.name}',
level: LogLevel.info); 'Attachments: ${attachments.length}',
logSafe('Payment Mode: ${selectedPaymentMode.value?.name}', 'Existing Attachments: ${existingAttachments.length}',
level: LogLevel.info); ].forEach((str) => logSafe(str, 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 ---------- // --- Pickers ---
Future<void> pickTransactionDate(BuildContext context) async { Future<void> pickTransactionDate(BuildContext context) async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@ -215,20 +187,12 @@ class AddExpenseController extends GetxController {
firstDate: DateTime(DateTime.now().year - 5), firstDate: DateTime(DateTime.now().year - 5),
lastDate: DateTime.now(), lastDate: DateTime.now(),
); );
if (picked != null) { if (picked != null) {
selectedTransactionDate.value = picked; selectedTransactionDate.value = picked;
transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked); transactionDateController.text = DateFormat('dd-MM-yyyy').format(picked);
} }
} }
Future<void> loadMasterData() async {
await Future.wait([
fetchMasterData(),
fetchGlobalProjects(),
]);
}
Future<void> pickAttachments() async { Future<void> pickAttachments() async {
try { try {
final result = await FilePicker.platform.pickFiles( final result = await FilePicker.platform.pickFiles(
@ -237,89 +201,109 @@ class AddExpenseController extends GetxController {
allowMultiple: true, allowMultiple: true,
); );
if (result != null) { if (result != null) {
attachments.addAll( attachments
result.paths.whereType<String>().map((path) => File(path)), .addAll(result.paths.whereType<String>().map((path) => File(path)));
);
} }
} catch (e) { } catch (e) {
showAppSnackbar( _errorSnackbar("Attachment error: $e");
title: "Error",
message: "Attachment error: $e",
type: SnackbarType.error,
);
} }
} }
void removeAttachment(File file) => attachments.remove(file); void removeAttachment(File file) => attachments.remove(file);
// --- Location ---
Future<void> fetchCurrentLocation() async { Future<void> fetchCurrentLocation() async {
isFetchingLocation.value = true; isFetchingLocation.value = true;
try { try {
var permission = await Geolocator.checkPermission(); final permission = await _ensureLocationPermission();
if (permission == LocationPermission.denied || if (!permission) return;
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 position = await Geolocator.getCurrentPosition();
final placemarks = final placemarks =
await placemarkFromCoordinates(position.latitude, position.longitude); await placemarkFromCoordinates(position.latitude, position.longitude);
if (placemarks.isNotEmpty) { locationController.text = placemarks.isNotEmpty
final place = placemarks.first; ? [
final address = [ placemarks.first.name,
place.name, placemarks.first.street,
place.street, placemarks.first.locality,
place.locality, placemarks.first.administrativeArea,
place.administrativeArea, placemarks.first.country
place.country ].where((e) => e?.isNotEmpty == true).join(", ")
].where((e) => e != null && e.isNotEmpty).join(", "); : "${position.latitude}, ${position.longitude}";
locationController.text = address;
} else {
locationController.text = "${position.latitude}, ${position.longitude}";
}
} catch (e) { } catch (e) {
showAppSnackbar( _errorSnackbar("Location error: $e");
title: "Error",
message: "Location error: $e",
type: SnackbarType.error,
);
} finally { } finally {
isFetchingLocation.value = false; isFetchingLocation.value = false;
} }
} }
// ---------- Submission ---------- 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);
}
}
// --- Submission ---
Future<void> submitOrUpdateExpense() async { Future<void> submitOrUpdateExpense() async {
if (isSubmitting.value) return; if (isSubmitting.value) return;
isSubmitting.value = true; isSubmitting.value = true;
try { try {
final validation = validateForm(); final validationMsg = validateForm();
if (validation.isNotEmpty) { if (validationMsg.isNotEmpty) {
showAppSnackbar( _errorSnackbar(validationMsg, "Missing Fields");
title: "Missing Fields",
message: validation,
type: SnackbarType.error,
);
return; return;
} }
@ -353,42 +337,29 @@ class AddExpenseController extends GetxController {
type: SnackbarType.success, type: SnackbarType.success,
); );
} else { } else {
showAppSnackbar( _errorSnackbar("Operation failed. Try again.");
title: "Error",
message: "Operation failed. Try again.",
type: SnackbarType.error,
);
} }
} catch (e) { } catch (e) {
showAppSnackbar( _errorSnackbar("Unexpected error: $e");
title: "Error",
message: "Unexpected error: $e",
type: SnackbarType.error,
);
} finally { } finally {
isSubmitting.value = false; isSubmitting.value = false;
} }
} }
Future<Map<String, dynamic>> _buildExpensePayload() async { Future<Map<String, dynamic>> _buildExpensePayload() async {
final amount = double.parse(amountController.text.trim()); final now = DateTime.now();
final projectId = projectsMap[selectedProject.value]!; final existingAttachmentPayloads = existingAttachments
final selectedDate = .map((e) => {
selectedTransactionDate.value?.toUtc() ?? DateTime.now().toUtc(); "documentId": e['documentId'],
final existingAttachmentPayloads = existingAttachments.map((e) { "fileName": e['fileName'],
final isActive = e['isActive'] ?? true; "contentType": e['contentType'],
"fileSize": 0,
return { "description": "",
"documentId": e['documentId'], "url": e['url'],
"fileName": e['fileName'], "isActive": e['isActive'] ?? true,
"contentType": e['contentType'], "base64Data": e['isActive'] == false ? null : e['base64Data'],
"fileSize": 0, })
"description": "", .toList();
"url": e['url'],
"isActive": isActive,
"base64Data": isActive ? e['base64Data'] : null,
};
}).toList();
final newAttachmentPayloads = final newAttachmentPayloads =
await Future.wait(attachments.map((file) async { await Future.wait(attachments.map((file) async {
@ -401,34 +372,29 @@ class AddExpenseController extends GetxController {
"description": "", "description": "",
}; };
})); }));
final billAttachments = [
...existingAttachmentPayloads,
...newAttachmentPayloads
];
final Map<String, dynamic> payload = { final type = selectedExpenseType.value!;
"projectId": projectId, return {
"expensesTypeId": selectedExpenseType.value!.id, if (isEditMode.value && editingExpenseId != null) "id": editingExpenseId,
"projectId": projectsMap[selectedProject.value]!,
"expensesTypeId": type.id,
"paymentModeId": selectedPaymentMode.value!.id, "paymentModeId": selectedPaymentMode.value!.id,
"paidById": selectedPaidBy.value!.id, "paidById": selectedPaidBy.value!.id,
"transactionDate": selectedDate.toIso8601String(), "transactionDate": (selectedTransactionDate.value?.toUtc() ?? now.toUtc())
.toIso8601String(),
"transactionId": transactionIdController.text, "transactionId": transactionIdController.text,
"description": descriptionController.text, "description": descriptionController.text,
"location": locationController.text, "location": locationController.text,
"supplerName": supplierController.text, "supplerName": supplierController.text,
"amount": amount, "amount": double.parse(amountController.text.trim()),
"noOfPersons": selectedExpenseType.value?.noOfPersonsRequired == true "noOfPersons": type.noOfPersonsRequired == true
? int.tryParse(noOfPersonsController.text.trim()) ?? 0 ? int.tryParse(noOfPersonsController.text.trim()) ?? 0
: 0, : 0,
"billAttachments": billAttachments, "billAttachments": [
...existingAttachmentPayloads,
...newAttachmentPayloads
],
}; };
// Add expense ID if in edit mode
if (isEditMode.value && editingExpenseId != null) {
payload['id'] = editingExpenseId;
}
return payload;
} }
String validateForm() { String validateForm() {
@ -439,62 +405,30 @@ class AddExpenseController extends GetxController {
if (selectedPaymentMode.value == null) missing.add("Payment Mode"); if (selectedPaymentMode.value == null) missing.add("Payment Mode");
if (selectedPaidBy.value == null) missing.add("Paid By"); if (selectedPaidBy.value == null) missing.add("Paid By");
if (amountController.text.trim().isEmpty) missing.add("Amount"); 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 (descriptionController.text.trim().isEmpty) missing.add("Description");
if (!isEditMode.value && attachments.isEmpty) missing.add("Attachments");
// 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()); final amount = double.tryParse(amountController.text.trim());
if (amount == null) missing.add("Valid Amount"); if (amount == null) missing.add("Valid Amount");
final selectedDate = selectedTransactionDate.value; // Attachment: at least one required at all times
if (selectedDate != null && selectedDate.isAfter(DateTime.now())) { bool hasActiveExisting =
missing.add("Valid Transaction Date"); existingAttachments.any((e) => e['isActive'] != false);
} if (attachments.isEmpty && !hasActiveExisting) missing.add("Attachment");
return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}."; return missing.isEmpty ? '' : "Please provide: ${missing.join(', ')}.";
} }
// ---------- Data Fetching ---------- // --- Snackbar Helper ---
Future<void> fetchMasterData() async { void _errorSnackbar(String msg, [String title = "Error"]) => showAppSnackbar(
try { title: title,
final types = await ApiService.getMasterExpenseTypes(); message: msg,
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, 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);
}
}
} }