Vaibhav_Feature-#768 #59
@ -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/services/app_logger.dart';
|
||||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:marco/model/employee_model.dart';
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
|
||||||
class AddExpenseController extends GetxController {
|
class AddExpenseController extends GetxController {
|
||||||
// === Text Controllers ===
|
// === Text Controllers ===
|
||||||
@ -22,6 +23,7 @@ class AddExpenseController extends GetxController {
|
|||||||
final transactionIdController = TextEditingController();
|
final transactionIdController = TextEditingController();
|
||||||
final gstController = TextEditingController();
|
final gstController = TextEditingController();
|
||||||
final locationController = TextEditingController();
|
final locationController = TextEditingController();
|
||||||
|
final ExpenseController expenseController = Get.find<ExpenseController>();
|
||||||
|
|
||||||
// === Project Mapping ===
|
// === Project Mapping ===
|
||||||
final RxMap<String, String> projectsMap = <String, String>{}.obs;
|
final RxMap<String, String> projectsMap = <String, String>{}.obs;
|
||||||
@ -49,6 +51,7 @@ class AddExpenseController extends GetxController {
|
|||||||
final RxList<File> attachments = <File>[].obs;
|
final RxList<File> attachments = <File>[].obs;
|
||||||
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
RxBool isLoading = false.obs;
|
RxBool isLoading = false.obs;
|
||||||
|
final RxBool isSubmitting = false.obs;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -172,89 +175,105 @@ class AddExpenseController extends GetxController {
|
|||||||
|
|
||||||
// === Submit Expense ===
|
// === Submit Expense ===
|
||||||
Future<void> submitExpense() async {
|
Future<void> submitExpense() async {
|
||||||
// Validation for required fields
|
if (isSubmitting.value) return; // Prevent multiple taps
|
||||||
if (selectedProject.value.isEmpty ||
|
isSubmitting.value = true;
|
||||||
selectedExpenseType.value == null ||
|
|
||||||
selectedPaymentMode.value == null ||
|
try {
|
||||||
descriptionController.text.isEmpty ||
|
// === Validation ===
|
||||||
supplierController.text.isEmpty ||
|
if (selectedProject.value.isEmpty ||
|
||||||
amountController.text.isEmpty ||
|
selectedExpenseType.value == null ||
|
||||||
selectedExpenseStatus.value == null ||
|
selectedPaymentMode.value == null ||
|
||||||
attachments.isEmpty) {
|
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(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
message: "Please fill all required fields.",
|
message: "Something went wrong: $e",
|
||||||
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.",
|
|
||||||
type: SnackbarType.error,
|
type: SnackbarType.error,
|
||||||
);
|
);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -395,32 +395,43 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
label:
|
label:
|
||||||
MyText.bodyMedium("Cancel", fontWeight: 600),
|
MyText.bodyMedium("Cancel", fontWeight: 600),
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
minimumSize:
|
minimumSize: const Size.fromHeight(48),
|
||||||
const Size.fromHeight(48),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ElevatedButton.icon(
|
child: Obx(() {
|
||||||
onPressed: controller.submitExpense,
|
final isLoading = controller.isSubmitting.value;
|
||||||
icon: const Icon(Icons.check, size: 18),
|
return ElevatedButton.icon(
|
||||||
label: MyText.bodyMedium(
|
onPressed:
|
||||||
"Submit",
|
isLoading ? null : controller.submitExpense,
|
||||||
color: Colors.white,
|
icon: isLoading
|
||||||
fontWeight: 600,
|
? const SizedBox(
|
||||||
),
|
width: 16,
|
||||||
style: ElevatedButton.styleFrom(
|
height: 16,
|
||||||
backgroundColor: Colors.indigo,
|
child: CircularProgressIndicator(
|
||||||
shape: RoundedRectangleBorder(
|
strokeWidth: 2,
|
||||||
borderRadius: BorderRadius.circular(8),
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.check, size: 18),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
isLoading ? "Submitting..." : "Submit",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
),
|
),
|
||||||
padding:
|
style: ElevatedButton.styleFrom(
|
||||||
const EdgeInsets.symmetric(vertical: 14),
|
backgroundColor: Colors.indigo,
|
||||||
minimumSize:
|
shape: RoundedRectangleBorder(
|
||||||
const Size.fromHeight(48),
|
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
|
// Apply search filter
|
||||||
final filteredList = expenseController.expenses.where((expense) {
|
final filteredList =
|
||||||
|
expenseController.expenses.where((expense) {
|
||||||
final query = searchQuery.value.toLowerCase();
|
final query = searchQuery.value.toLowerCase();
|
||||||
return query.isEmpty ||
|
return query.isEmpty ||
|
||||||
expense.expensesType.name.toLowerCase().contains(query) ||
|
expense.expensesType.name.toLowerCase().contains(query) ||
|
||||||
@ -71,15 +72,22 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
expense.paymentMode.name.toLowerCase().contains(query);
|
expense.paymentMode.name.toLowerCase().contains(query);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
// Sort by latest transaction date first
|
||||||
|
filteredList.sort(
|
||||||
|
(a, b) => b.transactionDate.compareTo(a.transactionDate));
|
||||||
|
|
||||||
// Split into current month and history
|
// Split into current month and history
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
final currentMonthList = filteredList.where((e) =>
|
final currentMonthList = filteredList
|
||||||
e.transactionDate.month == now.month &&
|
.where((e) =>
|
||||||
e.transactionDate.year == now.year).toList();
|
e.transactionDate.month == now.month &&
|
||||||
|
e.transactionDate.year == now.year)
|
||||||
|
.toList();
|
||||||
|
|
||||||
final historyList = filteredList.where((e) =>
|
final historyList = filteredList
|
||||||
e.transactionDate.isBefore(
|
.where((e) => e.transactionDate
|
||||||
DateTime(now.year, now.month, 1))).toList();
|
.isBefore(DateTime(now.year, now.month, 1)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
final listToShow =
|
final listToShow =
|
||||||
isHistoryView.value ? historyList : currentMonthList;
|
isHistoryView.value ? historyList : currentMonthList;
|
||||||
@ -235,8 +243,7 @@ class _SearchAndFilter extends StatelessWidget {
|
|||||||
controller: searchController,
|
controller: searchController,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
contentPadding:
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
const EdgeInsets.symmetric(horizontal: 12),
|
|
||||||
prefixIcon:
|
prefixIcon:
|
||||||
const Icon(Icons.search, size: 20, color: Colors.grey),
|
const Icon(Icons.search, size: 20, color: Colors.grey),
|
||||||
hintText: 'Search expenses...',
|
hintText: 'Search expenses...',
|
||||||
@ -257,7 +264,7 @@ class _SearchAndFilter extends StatelessWidget {
|
|||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.tune, color: Colors.black),
|
icon: const Icon(Icons.tune, color: Colors.black),
|
||||||
onPressed: onFilterTap,
|
onPressed: null,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -339,7 +346,8 @@ class _ToggleButton extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
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),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
@ -365,12 +373,18 @@ class _ExpenseList extends StatelessWidget {
|
|||||||
|
|
||||||
static Color _getStatusColor(String status) {
|
static Color _getStatusColor(String status) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'Requested': return Colors.blue;
|
case 'Requested':
|
||||||
case 'Review': return Colors.orange;
|
return Colors.blue;
|
||||||
case 'Approved': return Colors.green;
|
case 'Review':
|
||||||
case 'Paid': return Colors.purple;
|
return Colors.orange;
|
||||||
case 'Closed': return Colors.grey;
|
case 'Approved':
|
||||||
default: return Colors.black;
|
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(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
itemCount: expenseList.length,
|
itemCount: expenseList.length,
|
||||||
separatorBuilder: (_, __) =>
|
separatorBuilder: (_, __) =>
|
||||||
Divider(color: Colors.grey.shade300, height: 20),
|
Divider(color: Colors.grey.shade300, height: 20),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user