feat(expense): improve expense submission validation and UI feedback
This commit is contained in:
parent
a7bb24ee29
commit
ff01c05a73
@ -173,6 +173,7 @@ class AddExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
// === Submit Expense ===
|
||||
// === Submit Expense ===
|
||||
Future<void> submitExpense() async {
|
||||
if (isSubmitting.value) return; // Prevent multiple taps
|
||||
@ -180,17 +181,21 @@ class AddExpenseController extends GetxController {
|
||||
|
||||
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) {
|
||||
List<String> missingFields = [];
|
||||
|
||||
if (selectedProject.value.isEmpty) missingFields.add("Project");
|
||||
if (selectedExpenseType.value == null) missingFields.add("Expense Type");
|
||||
if (selectedPaymentMode.value == null) missingFields.add("Payment Mode");
|
||||
if (selectedPaidBy.value == null) missingFields.add("Paid By");
|
||||
if (amountController.text.isEmpty) missingFields.add("Amount");
|
||||
if (supplierController.text.isEmpty) missingFields.add("Supplier Name");
|
||||
if (descriptionController.text.isEmpty) missingFields.add("Description");
|
||||
if (attachments.isEmpty) missingFields.add("Attachments");
|
||||
|
||||
if (missingFields.isNotEmpty) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Please fill all required fields.",
|
||||
title: "Missing Fields",
|
||||
message: "Please provide: ${missingFields.join(', ')}.",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
return;
|
||||
@ -247,7 +252,6 @@ class AddExpenseController extends GetxController {
|
||||
supplerName: supplierController.text,
|
||||
amount: amount,
|
||||
noOfPersons: 0,
|
||||
statusId: selectedExpenseStatus.value!.id,
|
||||
billAttachments: attachmentData,
|
||||
);
|
||||
|
||||
|
@ -295,7 +295,6 @@ class ApiService {
|
||||
required String supplerName,
|
||||
required double amount,
|
||||
required int noOfPersons,
|
||||
required String statusId,
|
||||
required List<Map<String, dynamic>> billAttachments,
|
||||
}) async {
|
||||
final payload = {
|
||||
@ -310,7 +309,6 @@ class ApiService {
|
||||
"supplerName": supplerName,
|
||||
"amount": amount,
|
||||
"noOfPersons": noOfPersons,
|
||||
"statusId": statusId,
|
||||
"billAttachments": billAttachments,
|
||||
};
|
||||
|
||||
|
@ -4,7 +4,6 @@ import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/expense_status_model.dart';
|
||||
|
||||
void showAddExpenseBottomSheet() {
|
||||
Get.bottomSheet(
|
||||
@ -23,9 +22,9 @@ class _AddExpenseBottomSheet extends StatefulWidget {
|
||||
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
final AddExpenseController controller = Get.put(AddExpenseController());
|
||||
final RxBool isProjectExpanded = false.obs;
|
||||
|
||||
void _showEmployeeList(BuildContext context) {
|
||||
final employees = controller.allEmployees;
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
@ -84,7 +83,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
|
||||
// Project Dropdown
|
||||
const _SectionTitle(
|
||||
icon: Icons.work_outline, title: "Project"),
|
||||
icon: Icons.work_outline,
|
||||
title: "Project",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
return _DropdownTile(
|
||||
@ -105,6 +107,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
const _SectionTitle(
|
||||
icon: Icons.category_outlined,
|
||||
title: "Expense Type & GST No.",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
@ -128,7 +131,10 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
|
||||
// Payment Mode
|
||||
const _SectionTitle(
|
||||
icon: Icons.payment, title: "Payment Mode"),
|
||||
icon: Icons.payment,
|
||||
title: "Payment Mode",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
return _DropdownTile(
|
||||
@ -143,6 +149,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Paid By
|
||||
const _SectionTitle(
|
||||
icon: Icons.person_outline,
|
||||
title: "Paid By",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
final selected = controller.selectedPaidBy.value;
|
||||
return GestureDetector(
|
||||
@ -171,28 +185,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
// Expense Status
|
||||
const _SectionTitle(
|
||||
icon: Icons.flag_outlined, title: "Status"),
|
||||
const SizedBox(height: 6),
|
||||
Obx(() {
|
||||
return _DropdownTile(
|
||||
title: controller.selectedExpenseStatus.value?.name ??
|
||||
"Select Status",
|
||||
onTap: () => _showOptionList<ExpenseStatusModel>(
|
||||
context,
|
||||
controller.expenseStatuses.toList(),
|
||||
(s) => s.name,
|
||||
(val) =>
|
||||
controller.selectedExpenseStatus.value = val,
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Amount
|
||||
const _SectionTitle(
|
||||
icon: Icons.currency_rupee, title: "Amount"),
|
||||
icon: Icons.currency_rupee,
|
||||
title: "Amount",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_CustomTextField(
|
||||
controller: controller.amountController,
|
||||
@ -200,10 +198,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Supplier Name
|
||||
const _SectionTitle(
|
||||
icon: Icons.store_mall_directory_outlined,
|
||||
title: "Supplier Name",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_CustomTextField(
|
||||
@ -211,10 +211,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
hint: "Enter Supplier Name",
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Transaction ID
|
||||
const _SectionTitle(
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID"),
|
||||
icon: Icons.confirmation_number_outlined,
|
||||
title: "Transaction ID",
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_CustomTextField(
|
||||
controller: controller.transactionIdController,
|
||||
@ -256,13 +258,15 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Attachments Section
|
||||
const _SectionTitle(
|
||||
icon: Icons.attach_file, title: "Attachments"),
|
||||
icon: Icons.attach_file,
|
||||
title: "Attachments",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
Obx(() {
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
@ -376,6 +380,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
const _SectionTitle(
|
||||
icon: Icons.description_outlined,
|
||||
title: "Description",
|
||||
requiredField: true,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_CustomTextField(
|
||||
@ -439,28 +444,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// Project Selection List
|
||||
Obx(() {
|
||||
if (!isProjectExpanded.value) return const SizedBox.shrink();
|
||||
return Positioned(
|
||||
top: 110,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: _buildProjectSelectionList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -468,44 +451,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectSelectionList() {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 300),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.globalProjects.length,
|
||||
itemBuilder: (context, index) {
|
||||
final project = controller.globalProjects[index];
|
||||
final isSelected = project == controller.selectedProject.value;
|
||||
|
||||
return RadioListTile<String>(
|
||||
value: project,
|
||||
groupValue: controller.selectedProject.value,
|
||||
onChanged: (val) {
|
||||
controller.selectedProject.value = val!;
|
||||
isProjectExpanded.value = false;
|
||||
},
|
||||
title: Text(
|
||||
project,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected ? Colors.blueAccent : Colors.black87,
|
||||
),
|
||||
),
|
||||
activeColor: Colors.blueAccent,
|
||||
tileColor: isSelected
|
||||
? Colors.blueAccent.withOpacity(0.1)
|
||||
: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
visualDensity: const VisualDensity(vertical: -4),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showOptionList<T>(
|
||||
BuildContext context,
|
||||
List<T> options,
|
||||
@ -554,16 +499,38 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
const _SectionTitle({required this.icon, required this.title});
|
||||
final bool requiredField;
|
||||
|
||||
const _SectionTitle({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.requiredField = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Colors.grey[700];
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
MyText.bodyMedium(title, fontWeight: 600),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: DefaultTextStyle.of(context).style.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
children: [
|
||||
TextSpan(text: title),
|
||||
if (requiredField)
|
||||
const TextSpan(
|
||||
text: ' *',
|
||||
style: TextStyle(color: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
@ -51,18 +51,17 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
|
||||
if (expenseController.errorMessage.isNotEmpty) {
|
||||
return Center(
|
||||
child: Text(
|
||||
child: MyText.bodyMedium(
|
||||
expenseController.errorMessage.value,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
color: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (expenseController.expenses.isEmpty) {
|
||||
return const Center(child: Text("No expenses found."));
|
||||
return Center(child: MyText.bodyMedium("No expenses found."));
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
final filteredList =
|
||||
expenseController.expenses.where((expense) {
|
||||
final query = searchQuery.value.toLowerCase();
|
||||
@ -76,7 +75,6 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
filteredList.sort(
|
||||
(a, b) => b.transactionDate.compareTo(a.transactionDate));
|
||||
|
||||
// Split into current month and history
|
||||
final now = DateTime.now();
|
||||
final currentMonthList = filteredList
|
||||
.where((e) =>
|
||||
@ -117,23 +115,23 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
||||
child: Wrap(
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
const Text(
|
||||
MyText.bodyLarge(
|
||||
'Filter Expenses',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||
fontWeight: 700,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.date_range),
|
||||
title: const Text('Date Range'),
|
||||
title: MyText.bodyMedium('Date Range'),
|
||||
onTap: () {},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.work_outline),
|
||||
title: const Text('Project'),
|
||||
title: MyText.bodyMedium('Project'),
|
||||
onTap: () {},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.check_circle_outline),
|
||||
title: const Text('Status'),
|
||||
title: MyText.bodyMedium('Status'),
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
@ -264,7 +262,7 @@ class _SearchAndFilter extends StatelessWidget {
|
||||
MySpacing.width(8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.tune, color: Colors.black),
|
||||
onPressed: null,
|
||||
onPressed: null, // Disabled as per request
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -349,13 +347,11 @@ class _ToggleButton extends StatelessWidget {
|
||||
Icon(icon,
|
||||
size: 16, color: selected ? Colors.white : Colors.grey),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
MyText.bodyMedium(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: selected ? Colors.white : Colors.grey,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 13,
|
||||
),
|
||||
color: selected ? Colors.white : Colors.grey,
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -371,45 +367,27 @@ class _ExpenseList extends StatelessWidget {
|
||||
|
||||
const _ExpenseList({required this.expenseList});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (expenseList.isEmpty) {
|
||||
return const Center(child: Text('No expenses found.'));
|
||||
return Center(child: MyText.bodyMedium('No expenses found.'));
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||
itemCount: expenseList.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
Divider(color: Colors.grey.shade300, height: 20),
|
||||
itemBuilder: (context, index) {
|
||||
final expense = expenseList[index];
|
||||
final statusColor = _getStatusColor(expense.status.name);
|
||||
|
||||
// Convert UTC date to local formatted string
|
||||
final formattedDate = DateTimeUtils.convertUtcToLocal(
|
||||
expense.transactionDate.toIso8601String(),
|
||||
format: 'dd MMM yyyy, hh:mm a',
|
||||
);
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Get.to(
|
||||
() => const ExpenseDetailScreen(),
|
||||
arguments: {'expense': expense},
|
||||
@ -419,51 +397,35 @@ class _ExpenseList extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Title + Amount row
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.receipt_long,
|
||||
size: 20, color: Colors.red),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
MyText.bodyMedium(
|
||||
expense.expensesType.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
fontWeight: 700,
|
||||
),
|
||||
],
|
||||
),
|
||||
Text(
|
||||
MyText.bodyMedium(
|
||||
'₹ ${expense.amount.toStringAsFixed(2)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.red,
|
||||
),
|
||||
fontWeight: 600,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Date + Status
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
MyText.bodySmall(
|
||||
formattedDate,
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
MyText.bodySmall(
|
||||
expense.status.name,
|
||||
style: TextStyle(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
),
|
||||
fontWeight: 600,
|
||||
color: Colors.black,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
Loading…
x
Reference in New Issue
Block a user