- Changed AttendanceLogsTab from StatelessWidget to StatefulWidget to manage state for showing pending actions. - Added a status header in AttendanceLogsTab to indicate when only pending actions are displayed. - Updated filtering logic in AttendanceLogsTab to use filteredLogs based on the pending actions toggle. - Refactored AttendanceScreen to include a search bar for filtering attendance logs by name. - Introduced a new filter icon in AttendanceScreen for accessing the filter options. - Updated RegularizationRequestsTab to use filteredRegularizationLogs for displaying requests. - Modified TodaysAttendanceTab to utilize filteredEmployees for showing today's attendance. - Cleaned up code formatting and improved readability across various files.
		
			
				
	
	
		
			498 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Dart
		
	
	
	
	
	
			
		
		
	
	
			498 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:image_picker/image_picker.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';
 | |
| 
 | |
| class AddExpenseController extends GetxController {
 | |
|   // --- Text Controllers ---
 | |
|   final controllers = <TextEditingController>[
 | |
|     TextEditingController(), // amount
 | |
|     TextEditingController(), // description
 | |
|     TextEditingController(), // supplier
 | |
|     TextEditingController(), // transactionId
 | |
|     TextEditingController(), // gst
 | |
|     TextEditingController(), // location
 | |
|     TextEditingController(), // transactionDate
 | |
|     TextEditingController(), // noOfPersons
 | |
|     TextEditingController(), // employeeSearch
 | |
|   ];
 | |
| 
 | |
|   TextEditingController get amountController => controllers[0];
 | |
|   TextEditingController get descriptionController => controllers[1];
 | |
|   TextEditingController get supplierController => controllers[2];
 | |
|   TextEditingController get transactionIdController => controllers[3];
 | |
|   TextEditingController get gstController => controllers[4];
 | |
|   TextEditingController get locationController => controllers[5];
 | |
|   TextEditingController get transactionDateController => controllers[6];
 | |
|   TextEditingController get noOfPersonsController => controllers[7];
 | |
|   TextEditingController get employeeSearchController => controllers[8];
 | |
| 
 | |
|   // --- 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();
 | |
|     loadMasterData();
 | |
|     employeeSearchController.addListener(
 | |
|       () => searchEmployees(employeeSearchController.text),
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   @override
 | |
|   void onClose() {
 | |
|     for (var c in controllers) {
 | |
|       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(),
 | |
|       );
 | |
| 
 | |
|       if (data is List) {
 | |
|         employeeSearchResults.assignAll(
 | |
|           data
 | |
|               .map((e) => EmployeeModel.fromJson(e as Map<String, dynamic>))
 | |
|               .toList(),
 | |
|         );
 | |
|       } else {
 | |
|         employeeSearchResults.clear();
 | |
|       }
 | |
|     } catch (e) {
 | |
|       logSafe("Error searching employees: $e", level: LogLevel.error);
 | |
|       employeeSearchResults.clear();
 | |
|     } finally {
 | |
|       isSearchingEmployees.value = false;
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // --- Form Population (Edit) ---
 | |
|   Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
 | |
|     isEditMode.value = true;
 | |
|     editingExpenseId = '${data['id']}';
 | |
| 
 | |
|     selectedProject.value = data['projectName'] ?? '';
 | |
|     amountController.text = '${data['amount'] ?? ''}';
 | |
|     supplierController.text = data['supplerName'] ?? '';
 | |
|     descriptionController.text = data['description'] ?? '';
 | |
|     transactionIdController.text = data['transactionId'] ?? '';
 | |
|     locationController.text = data['location'] ?? '';
 | |
|     noOfPersonsController.text = '${data['noOfPersons'] ?? 0}';
 | |
| 
 | |
|     _setTransactionDate(data['transactionDate']);
 | |
|     _setDropdowns(data);
 | |
|     await _setPaidBy(data);
 | |
|     _setAttachments(data['attachments']);
 | |
| 
 | |
|     _logPrefilledData();
 | |
|   }
 | |
| 
 | |
|   void _setTransactionDate(dynamic dateStr) {
 | |
|     if (dateStr == null) {
 | |
|       selectedTransactionDate.value = null;
 | |
|       transactionDateController.clear();
 | |
|       return;
 | |
|     }
 | |
|     try {
 | |
|       final parsed = DateTime.parse(dateStr);
 | |
|       selectedTransactionDate.value = parsed;
 | |
|       transactionDateController.text = DateFormat('dd-MM-yyyy').format(parsed);
 | |
|     } catch (_) {
 | |
|       selectedTransactionDate.value = null;
 | |
|       transactionDateController.clear();
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _setDropdowns(Map<String, dynamic> data) {
 | |
|     selectedExpenseType.value =
 | |
|         expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
 | |
|     selectedPaymentMode.value =
 | |
|         paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
 | |
|   }
 | |
| 
 | |
|   Future<void> _setPaidBy(Map<String, dynamic> data) async {
 | |
|     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());
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _setAttachments(dynamic attachmentsData) {
 | |
|     existingAttachments.clear();
 | |
|     if (attachmentsData is List) {
 | |
|       existingAttachments.addAll(
 | |
|         List<Map<String, dynamic>>.from(attachmentsData).map(
 | |
|           (e) => {...e, 'isActive': true},
 | |
|         ),
 | |
|       );
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   void _logPrefilledData() {
 | |
|     final 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}',
 | |
|     ];
 | |
|     for (var line in info) {
 | |
|       logSafe(line, 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(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) attachments.add(File(pickedFile.path));
 | |
|     } catch (e) {
 | |
|       _errorSnackbar("Camera error: $e");
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // --- Location ---
 | |
|   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;
 | |
|   }
 | |
| 
 | |
|   // --- Data Fetching ---
 | |
|   Future<void> loadMasterData() async =>
 | |
|       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 as Map<String, dynamic>))
 | |
|             .toList();
 | |
|       }
 | |
| 
 | |
|       final modes = await ApiService.getMasterPaymentModes();
 | |
|       if (modes is List) {
 | |
|         paymentModes.value = modes
 | |
|             .map((e) => PaymentModeModel.fromJson(e as Map<String, dynamic>))
 | |
|             .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();
 | |
|           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);
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // --- 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 = await _submitToApi(payload);
 | |
| 
 | |
|       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<bool> _submitToApi(Map<String, dynamic> payload) async {
 | |
|     if (isEditMode.value && editingExpenseId != null) {
 | |
|       return ApiService.editExpenseApi(
 | |
|         expenseId: editingExpenseId!,
 | |
|         payload: payload,
 | |
|       );
 | |
|     }
 | |
|     return 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'],
 | |
|     );
 | |
|   }
 | |
| 
 | |
|   Future<Map<String, dynamic>> _buildExpensePayload() async {
 | |
|     final now = DateTime.now();
 | |
| 
 | |
|     final existingPayload = isEditMode.value
 | |
|         ? existingAttachments
 | |
|             .map((e) => {
 | |
|                   "documentId": e['documentId'],
 | |
|                   "fileName": e['fileName'],
 | |
|                   "contentType": e['contentType'],
 | |
|                   "fileSize": 0,
 | |
|                   "description": "",
 | |
|                   "url": e['url'],
 | |
|                   "isActive": e['isActive'] ?? true,
 | |
|                   "base64Data": "",
 | |
|                 })
 | |
|             .toList()
 | |
|         : <Map<String, dynamic>>[];
 | |
| 
 | |
|     final newPayload = 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 type = selectedExpenseType.value!;
 | |
| 
 | |
|     return {
 | |
|       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 ?? 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": [
 | |
|         ...existingPayload,
 | |
|         ...newPayload,
 | |
|       ].isEmpty
 | |
|           ? null
 | |
|           : [...existingPayload, ...newPayload],
 | |
|     };
 | |
|   }
 | |
| 
 | |
|   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");
 | |
| 
 | |
|     if (selectedTransactionDate.value == null) {
 | |
|       missing.add("Transaction Date");
 | |
|     } else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
 | |
|       missing.add("Valid Transaction Date");
 | |
|     }
 | |
| 
 | |
|     if (double.tryParse(amountController.text.trim()) == null) {
 | |
|       missing.add("Valid Amount");
 | |
|     }
 | |
| 
 | |
|     final 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);
 | |
|   }
 | |
| }
 |