feat(expense): enhance expense submission process and UI feedback
This commit is contained in:
parent
30318cd294
commit
6c0e73d870
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
@ -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),
|
||||
|
Loading…
x
Reference in New Issue
Block a user