diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index bb09b1c..17d8078 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -13,6 +13,7 @@ import 'package:marco/model/expense/expense_status_model.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/widgets/my_snackbar.dart'; import 'package:marco/model/employee_model.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; class AddExpenseController extends GetxController { // === Text Controllers === @@ -22,6 +23,7 @@ class AddExpenseController extends GetxController { final transactionIdController = TextEditingController(); final gstController = TextEditingController(); final locationController = TextEditingController(); + final ExpenseController expenseController = Get.find(); // === Project Mapping === final RxMap projectsMap = {}.obs; @@ -49,6 +51,7 @@ class AddExpenseController extends GetxController { final RxList attachments = [].obs; RxList allEmployees = [].obs; RxBool isLoading = false.obs; + final RxBool isSubmitting = false.obs; @override void onInit() { @@ -172,89 +175,105 @@ class AddExpenseController extends GetxController { // === Submit Expense === Future submitExpense() async { - // Validation for required fields - if (selectedProject.value.isEmpty || - selectedExpenseType.value == null || - selectedPaymentMode.value == null || - descriptionController.text.isEmpty || - supplierController.text.isEmpty || - amountController.text.isEmpty || - selectedExpenseStatus.value == null || - attachments.isEmpty) { + if (isSubmitting.value) return; // Prevent multiple taps + isSubmitting.value = true; + + try { + // === Validation === + if (selectedProject.value.isEmpty || + selectedExpenseType.value == null || + selectedPaymentMode.value == null || + descriptionController.text.isEmpty || + supplierController.text.isEmpty || + amountController.text.isEmpty || + selectedExpenseStatus.value == null || + attachments.isEmpty) { + showAppSnackbar( + title: "Error", + message: "Please fill all required fields.", + type: SnackbarType.error, + ); + return; + } + + final double? amount = double.tryParse(amountController.text); + if (amount == null) { + showAppSnackbar( + title: "Error", + message: "Please enter a valid amount.", + type: SnackbarType.error, + ); + return; + } + + final projectId = projectsMap[selectedProject.value]; + if (projectId == null) { + showAppSnackbar( + title: "Error", + message: "Invalid project selection.", + type: SnackbarType.error, + ); + return; + } + + // === Convert Attachments === + final attachmentData = await Future.wait(attachments.map((file) async { + final bytes = await file.readAsBytes(); + final base64String = base64Encode(bytes); + final mimeType = + lookupMimeType(file.path) ?? 'application/octet-stream'; + final fileSize = await file.length(); + + return { + "fileName": file.path.split('/').last, + "base64Data": base64String, + "contentType": mimeType, + "fileSize": fileSize, + "description": "", + }; + }).toList()); + + // === API Call === + final success = await ApiService.createExpenseApi( + projectId: projectId, + expensesTypeId: selectedExpenseType.value!.id, + paymentModeId: selectedPaymentMode.value!.id, + paidById: selectedPaidBy.value?.id ?? "", + transactionDate: + (selectedTransactionDate.value ?? DateTime.now()).toUtc(), + transactionId: transactionIdController.text, + description: descriptionController.text, + location: locationController.text, + supplerName: supplierController.text, + amount: amount, + noOfPersons: 0, + statusId: selectedExpenseStatus.value!.id, + billAttachments: attachmentData, + ); + + if (success) { + await Get.find().fetchExpenses(); // 🔄 Refresh list + Get.back(); + showAppSnackbar( + title: "Success", + message: "Expense created successfully!", + type: SnackbarType.success, + ); + } else { + showAppSnackbar( + title: "Error", + message: "Failed to create expense. Try again.", + type: SnackbarType.error, + ); + } + } catch (e) { showAppSnackbar( title: "Error", - message: "Please fill all required fields.", - type: SnackbarType.error, - ); - return; - } - - final double? amount = double.tryParse(amountController.text); - if (amount == null) { - showAppSnackbar( - title: "Error", - message: "Please enter a valid amount.", - type: SnackbarType.error, - ); - return; - } - - final projectId = projectsMap[selectedProject.value]; - if (projectId == null) { - showAppSnackbar( - title: "Error", - message: "Invalid project selection.", - type: SnackbarType.error, - ); - return; - } - - // Convert attachments to base64 + meta - final attachmentData = await Future.wait(attachments.map((file) async { - final bytes = await file.readAsBytes(); - final base64String = base64Encode(bytes); - final mimeType = lookupMimeType(file.path) ?? 'application/octet-stream'; - final fileSize = await file.length(); - - return { - "fileName": file.path.split('/').last, - "base64Data": base64String, - "contentType": mimeType, - "fileSize": fileSize, - "description": "", - }; - }).toList()); - - // Submit API call - final success = await ApiService.createExpenseApi( - projectId: projectId, - expensesTypeId: selectedExpenseType.value!.id, - paymentModeId: selectedPaymentMode.value!.id, - paidById: selectedPaidBy.value?.id ?? "", - transactionDate:(selectedTransactionDate.value ?? DateTime.now()).toUtc(), - transactionId: transactionIdController.text, - description: descriptionController.text, - location: locationController.text, - supplerName: supplierController.text, - amount: amount, - noOfPersons: 0, - statusId: selectedExpenseStatus.value!.id, - billAttachments: attachmentData, - ); - - if (success) { - Get.back(); - showAppSnackbar( - title: "Success", - message: "Expense created successfully!", - type: SnackbarType.success, - ); - } else { - showAppSnackbar( - title: "Error", - message: "Failed to create expense. Try again.", + message: "Something went wrong: $e", type: SnackbarType.error, ); + } finally { + isSubmitting.value = false; } } diff --git a/lib/model/expense/add_expense_bottom_sheet.dart b/lib/model/expense/add_expense_bottom_sheet.dart index 3abd58a..d7e6e11 100644 --- a/lib/model/expense/add_expense_bottom_sheet.dart +++ b/lib/model/expense/add_expense_bottom_sheet.dart @@ -395,32 +395,43 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> { label: MyText.bodyMedium("Cancel", fontWeight: 600), style: OutlinedButton.styleFrom( - minimumSize: - const Size.fromHeight(48), + minimumSize: const Size.fromHeight(48), ), ), ), const SizedBox(width: 12), Expanded( - child: ElevatedButton.icon( - onPressed: controller.submitExpense, - icon: const Icon(Icons.check, size: 18), - label: MyText.bodyMedium( - "Submit", - color: Colors.white, - fontWeight: 600, - ), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.indigo, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + child: Obx(() { + final isLoading = controller.isSubmitting.value; + return ElevatedButton.icon( + onPressed: + isLoading ? null : controller.submitExpense, + icon: isLoading + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.check, size: 18), + label: MyText.bodyMedium( + isLoading ? "Submitting..." : "Submit", + color: Colors.white, + fontWeight: 600, ), - padding: - const EdgeInsets.symmetric(vertical: 14), - minimumSize: - const Size.fromHeight(48), - ), - ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: + const EdgeInsets.symmetric(vertical: 14), + minimumSize: const Size.fromHeight(48), + ), + ); + }), ), ], ) diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index e0e8f50..c01b69e 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -63,7 +63,8 @@ class _ExpenseMainScreenState extends State { } // Apply search filter - final filteredList = expenseController.expenses.where((expense) { + final filteredList = + expenseController.expenses.where((expense) { final query = searchQuery.value.toLowerCase(); return query.isEmpty || expense.expensesType.name.toLowerCase().contains(query) || @@ -71,15 +72,22 @@ class _ExpenseMainScreenState extends State { expense.paymentMode.name.toLowerCase().contains(query); }).toList(); + // Sort by latest transaction date first + filteredList.sort( + (a, b) => b.transactionDate.compareTo(a.transactionDate)); + // Split into current month and history final now = DateTime.now(); - final currentMonthList = filteredList.where((e) => - e.transactionDate.month == now.month && - e.transactionDate.year == now.year).toList(); + final currentMonthList = filteredList + .where((e) => + e.transactionDate.month == now.month && + e.transactionDate.year == now.year) + .toList(); - final historyList = filteredList.where((e) => - e.transactionDate.isBefore( - DateTime(now.year, now.month, 1))).toList(); + final historyList = filteredList + .where((e) => e.transactionDate + .isBefore(DateTime(now.year, now.month, 1))) + .toList(); final listToShow = isHistoryView.value ? historyList : currentMonthList; @@ -235,8 +243,7 @@ class _SearchAndFilter extends StatelessWidget { controller: searchController, onChanged: onChanged, decoration: InputDecoration( - contentPadding: - const EdgeInsets.symmetric(horizontal: 12), + contentPadding: const EdgeInsets.symmetric(horizontal: 12), prefixIcon: const Icon(Icons.search, size: 20, color: Colors.grey), hintText: 'Search expenses...', @@ -257,7 +264,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: onFilterTap, + onPressed: null, ), ], ), @@ -339,7 +346,8 @@ class _ToggleButton extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 16, color: selected ? Colors.white : Colors.grey), + Icon(icon, + size: 16, color: selected ? Colors.white : Colors.grey), const SizedBox(width: 6), Text( label, @@ -365,12 +373,18 @@ class _ExpenseList extends StatelessWidget { static Color _getStatusColor(String status) { switch (status) { - case 'Requested': return Colors.blue; - case 'Review': return Colors.orange; - case 'Approved': return Colors.green; - case 'Paid': return Colors.purple; - case 'Closed': return Colors.grey; - default: return Colors.black; + case 'Requested': + return Colors.blue; + case 'Review': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.purple; + case 'Closed': + return Colors.grey; + default: + return Colors.black; } } @@ -381,7 +395,7 @@ class _ExpenseList extends StatelessWidget { } return ListView.separated( - padding: const EdgeInsets.all(12), + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), itemCount: expenseList.length, separatorBuilder: (_, __) => Divider(color: Colors.grey.shade300, height: 20),