feat(expense): enhance expense submission process and UI feedback

This commit is contained in:
Vaibhav Surve 2025-07-21 09:54:13 +05:30
parent 30318cd294
commit 6c0e73d870
3 changed files with 161 additions and 117 deletions

View File

@ -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<ExpenseController>();
// === Project Mapping ===
final RxMap<String, String> projectsMap = <String, String>{}.obs;
@ -49,6 +51,7 @@ class AddExpenseController extends GetxController {
final RxList<File> attachments = <File>[].obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
RxBool isLoading = false.obs;
final RxBool isSubmitting = false.obs;
@override
void onInit() {
@ -172,89 +175,105 @@ class AddExpenseController extends GetxController {
// === Submit Expense ===
Future<void> 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<ExpenseController>().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;
}
}

View File

@ -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),
),
);
}),
),
],
)

View File

@ -63,7 +63,8 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
}
// 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<ExpenseMainScreen> {
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),