- 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);
|
|
}
|
|
}
|