Refactor expense reimbursement and filter UI components
- Updated ReimbursementBottomSheet to use BaseBottomSheet for consistent styling and functionality. - Improved input field decorations and added spacing helpers for better layout. - Simplified the employee selection process and integrated it into the new design. - Refactored ExpenseDetailScreen to utilize controller initialization method. - Enhanced ExpenseFilterBottomSheet with a cleaner structure and improved field handling. - Removed unnecessary wrapper for ExpenseFilterBottomSheet and integrated it directly into the expense screen.
This commit is contained in:
parent
adf5e1437e
commit
29f759ca9d
@ -11,35 +11,40 @@ class ExpenseDetailController extends GetxController {
|
|||||||
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
final Rx<EmployeeModel?> selectedReimbursedBy = Rx<EmployeeModel?>(null);
|
||||||
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
final RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
|
||||||
|
|
||||||
@override
|
bool _isInitialized = false;
|
||||||
void onInit() {
|
late String _expenseId;
|
||||||
super.onInit();
|
|
||||||
|
/// Call this once from the screen (NOT inside build) to initialize
|
||||||
|
void init(String expenseId) {
|
||||||
|
if (_isInitialized) return;
|
||||||
|
|
||||||
|
_isInitialized = true;
|
||||||
|
_expenseId = expenseId;
|
||||||
|
|
||||||
|
fetchExpenseDetails();
|
||||||
fetchAllEmployees();
|
fetchAllEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch expense details by ID
|
/// Fetch expense details by stored ID
|
||||||
Future<void> fetchExpenseDetails(String expenseId) async {
|
Future<void> fetchExpenseDetails() async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logSafe("Fetching expense details for ID: $expenseId");
|
logSafe("Fetching expense details for ID: $_expenseId");
|
||||||
|
|
||||||
final result =
|
final result = await ApiService.getExpenseDetailsApi(expenseId: _expenseId);
|
||||||
await ApiService.getExpenseDetailsApi(expenseId: expenseId);
|
|
||||||
if (result != null) {
|
if (result != null) {
|
||||||
try {
|
try {
|
||||||
expense.value = ExpenseDetailModel.fromJson(result);
|
expense.value = ExpenseDetailModel.fromJson(result);
|
||||||
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = 'Failed to parse expense details: $e';
|
errorMessage.value = 'Failed to parse expense details: $e';
|
||||||
logSafe("Parse error in fetchExpenseDetails: $e",
|
logSafe("Parse error in fetchExpenseDetails: $e", level: LogLevel.error);
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = 'Failed to fetch expense details from server.';
|
errorMessage.value = 'Failed to fetch expense details from server.';
|
||||||
logSafe("fetchExpenseDetails failed: null response",
|
logSafe("fetchExpenseDetails failed: null response", level: LogLevel.error);
|
||||||
level: LogLevel.error);
|
|
||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
errorMessage.value = 'An unexpected error occurred.';
|
errorMessage.value = 'An unexpected error occurred.';
|
||||||
@ -59,8 +64,7 @@ class ExpenseDetailController extends GetxController {
|
|||||||
final response = await ApiService.getAllEmployees();
|
final response = await ApiService.getAllEmployees();
|
||||||
if (response != null && response.isNotEmpty) {
|
if (response != null && response.isNotEmpty) {
|
||||||
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
||||||
logSafe("All Employees fetched: ${allEmployees.length}",
|
logSafe("All Employees fetched: ${allEmployees.length}", level: LogLevel.info);
|
||||||
level: LogLevel.info);
|
|
||||||
} else {
|
} else {
|
||||||
allEmployees.clear();
|
allEmployees.clear();
|
||||||
logSafe("No employees found.", level: LogLevel.warning);
|
logSafe("No employees found.", level: LogLevel.warning);
|
||||||
@ -76,22 +80,21 @@ class ExpenseDetailController extends GetxController {
|
|||||||
|
|
||||||
/// Update expense with reimbursement info and status
|
/// Update expense with reimbursement info and status
|
||||||
Future<bool> updateExpenseStatusWithReimbursement({
|
Future<bool> updateExpenseStatusWithReimbursement({
|
||||||
required String expenseId,
|
|
||||||
required String comment,
|
required String comment,
|
||||||
required String reimburseTransactionId,
|
required String reimburseTransactionId,
|
||||||
required String reimburseDate,
|
required String reimburseDate,
|
||||||
required String reimburseById,
|
required String reimburseById,
|
||||||
required String statusId, // ✅ dynamic
|
required String statusId,
|
||||||
}) async {
|
}) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logSafe("Submitting reimbursement for expense: $expenseId");
|
logSafe("Submitting reimbursement for expense: $_expenseId");
|
||||||
|
|
||||||
final success = await ApiService.updateExpenseStatusApi(
|
final success = await ApiService.updateExpenseStatusApi(
|
||||||
expenseId: expenseId,
|
expenseId: _expenseId,
|
||||||
statusId: statusId, // ✅ now dynamic
|
statusId: statusId,
|
||||||
comment: comment,
|
comment: comment,
|
||||||
reimburseTransactionId: reimburseTransactionId,
|
reimburseTransactionId: reimburseTransactionId,
|
||||||
reimburseDate: reimburseDate,
|
reimburseDate: reimburseDate,
|
||||||
@ -100,7 +103,7 @@ class ExpenseDetailController extends GetxController {
|
|||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logSafe("Reimbursement submitted successfully.");
|
logSafe("Reimbursement submitted successfully.");
|
||||||
await fetchExpenseDetails(expenseId);
|
await fetchExpenseDetails(); // refresh latest
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = "Failed to submit reimbursement.";
|
errorMessage.value = "Failed to submit reimbursement.";
|
||||||
@ -108,8 +111,7 @@ class ExpenseDetailController extends GetxController {
|
|||||||
}
|
}
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
errorMessage.value = 'An unexpected error occurred.';
|
errorMessage.value = 'An unexpected error occurred.';
|
||||||
logSafe("Exception in updateExpenseStatusWithReimbursement: $e",
|
logSafe("Exception in updateExpenseStatusWithReimbursement: $e", level: LogLevel.error);
|
||||||
level: LogLevel.error);
|
|
||||||
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
return false;
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
@ -118,21 +120,21 @@ class ExpenseDetailController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Update status for this specific expense
|
/// Update status for this specific expense
|
||||||
Future<bool> updateExpenseStatus(String expenseId, String statusId) async {
|
Future<bool> updateExpenseStatus(String statusId) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
errorMessage.value = '';
|
errorMessage.value = '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logSafe("Updating status for expense: $expenseId -> $statusId");
|
logSafe("Updating status for expense: $_expenseId -> $statusId");
|
||||||
|
|
||||||
final success = await ApiService.updateExpenseStatusApi(
|
final success = await ApiService.updateExpenseStatusApi(
|
||||||
expenseId: expenseId,
|
expenseId: _expenseId,
|
||||||
statusId: statusId,
|
statusId: statusId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
logSafe("Expense status updated successfully.");
|
logSafe("Expense status updated successfully.");
|
||||||
await fetchExpenseDetails(expenseId);
|
await fetchExpenseDetails(); // refresh
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = "Failed to update expense status.";
|
errorMessage.value = "Failed to update expense status.";
|
||||||
|
|||||||
118
lib/helpers/utils/base_bottom_sheet.dart
Normal file
118
lib/helpers/utils/base_bottom_sheet.dart
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
|
||||||
|
class BaseBottomSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final VoidCallback onSubmit;
|
||||||
|
final bool isSubmitting;
|
||||||
|
final String submitText;
|
||||||
|
final Color submitColor;
|
||||||
|
final IconData submitIcon;
|
||||||
|
|
||||||
|
const BaseBottomSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onSubmit,
|
||||||
|
this.isSubmitting = false,
|
||||||
|
this.submitText = 'Submit',
|
||||||
|
this.submitColor = Colors.indigo,
|
||||||
|
this.submitIcon = Icons.check_circle_outline,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: mediaQuery.viewInsets,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
top: 60),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.cardColor,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black12,
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, -2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
MySpacing.height(5),
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
MyText.titleLarge(title, fontWeight: 700),
|
||||||
|
MySpacing.height(12),
|
||||||
|
child,
|
||||||
|
MySpacing.height(24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: onCancel,
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
"Cancel",
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.grey,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: isSubmitting ? null : onSubmit,
|
||||||
|
icon: Icon(submitIcon, color: Colors.white),
|
||||||
|
label: MyText.bodyMedium(
|
||||||
|
isSubmitting ? "Submitting..." : submitText,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: submitColor,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,9 +3,9 @@ class Permissions {
|
|||||||
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
static const String manageProject = "172fc9b6-755b-4f62-ab26-55c34a330614";
|
||||||
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
static const String viewProjects = "6ea44136-987e-44ba-9e5d-1cf8f5837ebc";
|
||||||
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
|
static const String manageEmployees = "a97d366a-c2bb-448d-be93-402bd2324566";
|
||||||
static const String manageProjectInfra ="f2aee20a-b754-4537-8166-f9507b44585b";
|
static const String manageProjectInfra = "f2aee20a-b754-4537-8166-f9507b44585b";
|
||||||
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
|
static const String viewProjectInfra = "c7b68e33-72f0-474f-bd96-77636427ecc8";
|
||||||
static const String regularizeAttendance ="57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
static const String regularizeAttendance = "57802c4a-00aa-4a1f-a048-fd2f70dd44b6";
|
||||||
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
|
static const String assignToProject = "fbd213e0-0250-46f1-9f5f-4b2a1e6e76a3";
|
||||||
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
|
static const String infrastructure = "9666de86-d7c7-4d3d-acaa-fcd6d6b81f3c";
|
||||||
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
static const String manageTask = "08752f33-3b29-4816-b76b-ea8a968ed3c5";
|
||||||
@ -13,4 +13,13 @@ class Permissions {
|
|||||||
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
static const String assignReportTask = "6a32379b-8b3f-49a6-8c48-4b7ac1b55dc2";
|
||||||
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
static const String directoryAdmin = "4286a13b-bb40-4879-8c6d-18e9e393beda";
|
||||||
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
static const String directoryManager = "62668630-13ce-4f52-a0f0-db38af2230c5";
|
||||||
|
|
||||||
|
// Expense Permissions
|
||||||
|
static const String expenseViewSelf = "385be49f-8fde-440e-bdbc-3dffeb8dd116";
|
||||||
|
static const String expenseViewAll = "01e06444-9ca7-4df4-b900-8c3fa051b92f";
|
||||||
|
static const String expenseUpload = "0f57885d-bcb2-4711-ac95-d841ace6d5a7";
|
||||||
|
static const String expenseReview = "1f4bda08-1873-449a-bb66-3e8222bd871b";
|
||||||
|
static const String expenseApprove = "eaafdd76-8aac-45f9-a530-315589c6deca";
|
||||||
|
static const String expenseProcess = "ea5a1529-4ee8-4828-80ea-0e23c9d4dd11";
|
||||||
|
static const String expenseManage = "bdee29a2-b73b-402d-8dd1-c4b1f81ccbc3";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
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_type_model.dart';
|
||||||
|
import 'package:marco/model/expense/payment_types_model.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
void showAddExpenseBottomSheet() {
|
void showAddExpenseBottomSheet() {
|
||||||
Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true);
|
Get.bottomSheet(const _AddExpenseBottomSheet(), isScrollControlled: true);
|
||||||
@ -83,309 +83,228 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return Obx(() {
|
||||||
child: Padding(
|
return BaseBottomSheet(
|
||||||
padding: const EdgeInsets.only(top: 60),
|
title: "Add Expense",
|
||||||
child: Material(
|
isSubmitting: controller.isSubmitting.value,
|
||||||
color: Colors.white,
|
onCancel: Get.back,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
onSubmit: () {
|
||||||
child: Obx(() {
|
if (!controller.isSubmitting.value) {
|
||||||
return SingleChildScrollView(
|
controller.submitExpense();
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
}
|
||||||
child: Column(
|
},
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const _DragHandle(),
|
children: [
|
||||||
Center(
|
_buildDropdown<String>(
|
||||||
child: MyText.titleLarge("Add Expense", fontWeight: 700),
|
icon: Icons.work_outline,
|
||||||
),
|
title: "Project",
|
||||||
const SizedBox(height: 20),
|
requiredField: true,
|
||||||
_buildSectionWithDropdown<String>(
|
value: controller.selectedProject.value.isEmpty
|
||||||
icon: Icons.work_outline,
|
? "Select Project"
|
||||||
title: "Project",
|
: controller.selectedProject.value,
|
||||||
requiredField: true,
|
onTap: () => _showOptionList<String>(
|
||||||
currentValue: controller.selectedProject.value.isEmpty
|
controller.globalProjects.toList(),
|
||||||
? "Select Project"
|
(p) => p,
|
||||||
: controller.selectedProject.value,
|
(val) => controller.selectedProject.value = val,
|
||||||
onTap: () => _showOptionList<String>(
|
|
||||||
controller.globalProjects.toList(),
|
|
||||||
(p) => p,
|
|
||||||
(val) => controller.selectedProject.value = val),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildSectionWithDropdown<ExpenseTypeModel>(
|
|
||||||
icon: Icons.category_outlined,
|
|
||||||
title: "Expense Type",
|
|
||||||
requiredField: true,
|
|
||||||
currentValue: controller.selectedExpenseType.value?.name ??
|
|
||||||
"Select Expense Type",
|
|
||||||
onTap: () => _showOptionList<ExpenseTypeModel>(
|
|
||||||
controller.expenseTypes.toList(),
|
|
||||||
(e) => e.name,
|
|
||||||
(val) => controller.selectedExpenseType.value = val,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (controller
|
|
||||||
.selectedExpenseType.value?.noOfPersonsRequired ==
|
|
||||||
true)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.people_outline,
|
|
||||||
title: "No. of Persons",
|
|
||||||
requiredField: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.noOfPersonsController,
|
|
||||||
hint: "Enter No. of Persons",
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.confirmation_number_outlined,
|
|
||||||
title: "GST No.",
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.gstController,
|
|
||||||
hint: "Enter GST No.",
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildSectionWithDropdown<PaymentModeModel>(
|
|
||||||
icon: Icons.payment,
|
|
||||||
title: "Payment Mode",
|
|
||||||
requiredField: true,
|
|
||||||
currentValue: controller.selectedPaymentMode.value?.name ??
|
|
||||||
"Select Payment Mode",
|
|
||||||
onTap: () => _showOptionList<PaymentModeModel>(
|
|
||||||
controller.paymentModes.toList(),
|
|
||||||
(m) => m.name,
|
|
||||||
(val) => controller.selectedPaymentMode.value = val,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.person_outline,
|
|
||||||
title: "Paid By",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _showEmployeeList,
|
|
||||||
child: _TileContainer(
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
controller.selectedPaidBy.value == null
|
|
||||||
? "Select Paid By"
|
|
||||||
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
|
|
||||||
style: const TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
const Icon(Icons.arrow_drop_down, size: 22),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.currency_rupee,
|
|
||||||
title: "Amount",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.amountController,
|
|
||||||
hint: "Enter Amount",
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.store_mall_directory_outlined,
|
|
||||||
title: "Supplier Name",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.supplierController,
|
|
||||||
hint: "Enter Supplier Name",
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.confirmation_number_outlined,
|
|
||||||
title: "Transaction ID"),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.transactionIdController,
|
|
||||||
hint: "Enter Transaction ID",
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.calendar_today,
|
|
||||||
title: "Transaction Date",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => controller.pickTransactionDate(context),
|
|
||||||
child: AbsorbPointer(
|
|
||||||
child: _CustomTextField(
|
|
||||||
controller: controller.transactionDateController,
|
|
||||||
hint: "Select Transaction Date",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.location_on_outlined, title: "Location"),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
TextField(
|
|
||||||
controller: controller.locationController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: "Enter Location",
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey.shade100,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8)),
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12, vertical: 10),
|
|
||||||
suffixIcon: controller.isFetchingLocation.value
|
|
||||||
? const Padding(
|
|
||||||
padding: EdgeInsets.all(12),
|
|
||||||
child: SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child:
|
|
||||||
CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: IconButton(
|
|
||||||
icon: const Icon(Icons.my_location),
|
|
||||||
tooltip: "Use Current Location",
|
|
||||||
onPressed: controller.fetchCurrentLocation,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.attach_file,
|
|
||||||
title: "Attachments",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_AttachmentsSection(
|
|
||||||
attachments: controller.attachments,
|
|
||||||
onRemove: controller.removeAttachment,
|
|
||||||
onAdd: controller.pickAttachments,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_SectionTitle(
|
|
||||||
icon: Icons.description_outlined,
|
|
||||||
title: "Description",
|
|
||||||
requiredField: true),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
_CustomTextField(
|
|
||||||
controller: controller.descriptionController,
|
|
||||||
hint: "Enter Description",
|
|
||||||
maxLines: 3,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: Get.back,
|
|
||||||
icon: const Icon(Icons.close, size: 18),
|
|
||||||
label: MyText.bodyMedium("Cancel", fontWeight: 600),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(48)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8)),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
minimumSize: const Size.fromHeight(48),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
MySpacing.height(16),
|
||||||
|
_buildDropdown<ExpenseTypeModel>(
|
||||||
|
icon: Icons.category_outlined,
|
||||||
|
title: "Expense Type",
|
||||||
|
requiredField: true,
|
||||||
|
value: controller.selectedExpenseType.value?.name ??
|
||||||
|
"Select Expense Type",
|
||||||
|
onTap: () => _showOptionList<ExpenseTypeModel>(
|
||||||
|
controller.expenseTypes.toList(),
|
||||||
|
(e) => e.name,
|
||||||
|
(val) => controller.selectedExpenseType.value = val,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
|
||||||
|
true)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: "No. of Persons",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.noOfPersonsController,
|
||||||
|
hint: "Enter No. of Persons",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.confirmation_number_outlined, title: "GST No."),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.gstController, hint: "Enter GST No."),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_buildDropdown<PaymentModeModel>(
|
||||||
|
icon: Icons.payment,
|
||||||
|
title: "Payment Mode",
|
||||||
|
requiredField: true,
|
||||||
|
value: controller.selectedPaymentMode.value?.name ??
|
||||||
|
"Select Payment Mode",
|
||||||
|
onTap: () => _showOptionList<PaymentModeModel>(
|
||||||
|
controller.paymentModes.toList(),
|
||||||
|
(p) => p.name,
|
||||||
|
(val) => controller.selectedPaymentMode.value = val,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.person_outline,
|
||||||
|
title: "Paid By",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showEmployeeList,
|
||||||
|
child: _TileContainer(
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.selectedPaidBy.value == null
|
||||||
|
? "Select Paid By"
|
||||||
|
: '${controller.selectedPaidBy.value?.firstName ?? ''} ${controller.selectedPaidBy.value?.lastName ?? ''}',
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_drop_down, size: 22),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.currency_rupee,
|
||||||
|
title: "Amount",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.amountController,
|
||||||
|
hint: "Enter Amount",
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.store_mall_directory_outlined,
|
||||||
|
title: "Supplier Name",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.supplierController,
|
||||||
|
hint: "Enter Supplier Name"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.confirmation_number_outlined,
|
||||||
|
title: "Transaction ID"),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.transactionIdController,
|
||||||
|
hint: "Enter Transaction ID"),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.calendar_today,
|
||||||
|
title: "Transaction Date",
|
||||||
|
requiredField: true,
|
||||||
|
),
|
||||||
|
MySpacing.height(6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => controller.pickTransactionDate(context),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: _CustomTextField(
|
||||||
|
controller: controller.transactionDateController,
|
||||||
|
hint: "Select Transaction Date",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
|
||||||
|
MySpacing.height(6),
|
||||||
|
TextField(
|
||||||
|
controller: controller.locationController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: "Enter Location",
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border:
|
||||||
|
OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
suffixIcon: controller.isFetchingLocation.value
|
||||||
|
? const Padding(
|
||||||
|
padding: EdgeInsets.all(12),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: IconButton(
|
||||||
|
icon: const Icon(Icons.my_location),
|
||||||
|
tooltip: "Use Current Location",
|
||||||
|
onPressed: controller.fetchCurrentLocation,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.attach_file,
|
||||||
|
title: "Attachments",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_AttachmentsSection(
|
||||||
|
attachments: controller.attachments,
|
||||||
|
onRemove: controller.removeAttachment,
|
||||||
|
onAdd: controller.pickAttachments,
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
_SectionTitle(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
title: "Description",
|
||||||
|
requiredField: true),
|
||||||
|
MySpacing.height(6),
|
||||||
|
_CustomTextField(
|
||||||
|
controller: controller.descriptionController,
|
||||||
|
hint: "Enter Description",
|
||||||
|
maxLines: 3,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSectionWithDropdown<T>({
|
Widget _buildDropdown<T>({
|
||||||
required IconData icon,
|
required IconData icon,
|
||||||
required String title,
|
required String title,
|
||||||
required bool requiredField,
|
required bool requiredField,
|
||||||
required String currentValue,
|
required String value,
|
||||||
required VoidCallback onTap,
|
required VoidCallback onTap,
|
||||||
Widget? extraWidget,
|
|
||||||
}) {
|
}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
_SectionTitle(icon: icon, title: title, requiredField: requiredField),
|
||||||
const SizedBox(height: 6),
|
MySpacing.height(6),
|
||||||
_DropdownTile(title: currentValue, onTap: onTap),
|
_DropdownTile(title: value, onTap: onTap),
|
||||||
if (extraWidget != null) extraWidget,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DragHandle extends StatelessWidget {
|
|
||||||
const _DragHandle();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Center(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
class _SectionTitle extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@ -447,11 +366,23 @@ class _CustomTextField extends StatelessWidget {
|
|||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: hint,
|
hintText: hint,
|
||||||
|
hintStyle: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.grey.shade100,
|
fillColor: Colors.grey.shade100,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
|
||||||
contentPadding:
|
contentPadding:
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
import 'package:marco/controller/expense/expense_detail_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
|
|
||||||
class ReimbursementBottomSheet extends StatefulWidget {
|
class ReimbursementBottomSheet extends StatefulWidget {
|
||||||
final String expenseId;
|
final String expenseId;
|
||||||
@ -30,8 +34,7 @@ class ReimbursementBottomSheet extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||||
final ExpenseDetailController controller =
|
final ExpenseDetailController controller = Get.find<ExpenseDetailController>();
|
||||||
Get.find<ExpenseDetailController>();
|
|
||||||
|
|
||||||
final TextEditingController commentCtrl = TextEditingController();
|
final TextEditingController commentCtrl = TextEditingController();
|
||||||
final TextEditingController txnCtrl = TextEditingController();
|
final TextEditingController txnCtrl = TextEditingController();
|
||||||
@ -49,14 +52,16 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
|||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 300,
|
height: 300,
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
final employees = controller.allEmployees;
|
final employees = controller.allEmployees;
|
||||||
if (employees.isEmpty)
|
if (employees.isEmpty) {
|
||||||
return const Center(child: Text("No employees found"));
|
return const Center(child: Text("No employees found"));
|
||||||
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: employees.length,
|
itemCount: employees.length,
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, index) {
|
||||||
@ -77,221 +82,128 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(16),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return Obx(() {
|
||||||
child: Container(
|
return BaseBottomSheet(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
title: "Reimbursement Info",
|
||||||
decoration: const BoxDecoration(
|
isSubmitting: controller.isLoading.value,
|
||||||
color: Colors.white,
|
onCancel: () {
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
widget.onClose();
|
||||||
),
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
onSubmit: () async {
|
||||||
|
if (commentCtrl.text.trim().isEmpty ||
|
||||||
|
txnCtrl.text.trim().isEmpty ||
|
||||||
|
dateStr.value.isEmpty ||
|
||||||
|
controller.selectedReimbursedBy.value == null) {
|
||||||
|
Get.snackbar("Incomplete", "Please fill all fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final success = await widget.onSubmit(
|
||||||
|
comment: commentCtrl.text.trim(),
|
||||||
|
reimburseTransactionId: txnCtrl.text.trim(),
|
||||||
|
reimburseDate: dateStr.value,
|
||||||
|
reimburseById: controller.selectedReimbursedBy.value!.id,
|
||||||
|
statusId: widget.statusId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Get.back();
|
||||||
|
Get.snackbar('Success', 'Reimbursement submitted');
|
||||||
|
} else {
|
||||||
|
Get.snackbar('Error', controller.errorMessage.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Drag handle
|
MyText.labelMedium("Comment"),
|
||||||
Container(
|
MySpacing.height(8),
|
||||||
width: 50,
|
TextField(
|
||||||
height: 4,
|
controller: commentCtrl,
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
decoration: _inputDecoration("Enter comment"),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Colors.grey.shade300,
|
MySpacing.height(16),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
|
MyText.labelMedium("Transaction ID"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
TextField(
|
||||||
|
controller: txnCtrl,
|
||||||
|
decoration: _inputDecoration("Enter transaction ID"),
|
||||||
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
MyText.labelMedium("Reimbursement Date"),
|
||||||
|
MySpacing.height(8),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: dateStr.value.isEmpty
|
||||||
|
? DateTime.now()
|
||||||
|
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2100),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: AbsorbPointer(
|
||||||
|
child: TextField(
|
||||||
|
controller: TextEditingController(text: dateStr.value),
|
||||||
|
decoration: _inputDecoration("Select Date").copyWith(
|
||||||
|
suffixIcon: const Icon(Icons.calendar_today),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
// Title
|
MyText.labelMedium("Reimbursed By"),
|
||||||
Row(
|
MySpacing.height(8),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
GestureDetector(
|
||||||
children: [
|
onTap: _showEmployeeList,
|
||||||
MyText.titleLarge('Reimbursement Info', fontWeight: 700),
|
child: AbsorbPointer(
|
||||||
const SizedBox(),
|
child: TextField(
|
||||||
],
|
controller: TextEditingController(
|
||||||
),
|
text: controller.selectedReimbursedBy.value == null
|
||||||
const SizedBox(height: 20),
|
? ""
|
||||||
|
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
||||||
Flexible(
|
),
|
||||||
child: SingleChildScrollView(
|
decoration: _inputDecoration("Select Employee").copyWith(
|
||||||
child: Column(
|
suffixIcon: const Icon(Icons.expand_more),
|
||||||
children: [
|
),
|
||||||
_buildInputField(label: 'Comment', controller: commentCtrl),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildInputField(
|
|
||||||
label: 'Transaction ID', controller: txnCtrl),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildDatePickerField(),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildEmployeePickerField(),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
_buildActionButtons(),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildInputField(
|
|
||||||
{required String label, required TextEditingController controller}) {
|
|
||||||
return TextField(
|
|
||||||
controller: controller,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildDatePickerField() {
|
|
||||||
return Obx(() {
|
|
||||||
return InkWell(
|
|
||||||
onTap: () async {
|
|
||||||
final picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate: dateStr.value.isEmpty
|
|
||||||
? DateTime.now()
|
|
||||||
: DateFormat('yyyy-MM-dd').parse(dateStr.value),
|
|
||||||
firstDate: DateTime(2020),
|
|
||||||
lastDate: DateTime(2100),
|
|
||||||
);
|
|
||||||
if (picked != null) {
|
|
||||||
dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: InputDecorator(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Reimbursement Date',
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.date_range, color: Colors.grey.shade600),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
dateStr.value.isEmpty ? "Select Date" : dateStr.value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: dateStr.value.isEmpty ? Colors.grey : Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmployeePickerField() {
|
|
||||||
return Obx(() {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: _showEmployeeList,
|
|
||||||
child: InputDecorator(
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Reimbursed By',
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
controller.selectedReimbursedBy.value == null
|
|
||||||
? "Select Reimbursed By"
|
|
||||||
: '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: controller.selectedReimbursedBy.value == null
|
|
||||||
? Colors.grey
|
|
||||||
: Colors.black,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildActionButtons() {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
widget.onClose();
|
|
||||||
Get.back();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
label: MyText.bodyMedium("Cancel",
|
|
||||||
color: Colors.white, fontWeight: 600),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Obx(() {
|
|
||||||
return ElevatedButton.icon(
|
|
||||||
onPressed: controller.isLoading.value
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
if (commentCtrl.text.trim().isEmpty ||
|
|
||||||
txnCtrl.text.trim().isEmpty ||
|
|
||||||
dateStr.value.isEmpty ||
|
|
||||||
controller.selectedReimbursedBy.value == null) {
|
|
||||||
Get.snackbar("Incomplete", "Please fill all fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final success = await widget.onSubmit(
|
|
||||||
comment: commentCtrl.text.trim(),
|
|
||||||
reimburseTransactionId: txnCtrl.text.trim(),
|
|
||||||
reimburseDate: dateStr.value,
|
|
||||||
reimburseById:
|
|
||||||
controller.selectedReimbursedBy.value!.id,
|
|
||||||
statusId: widget.statusId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
Get.back();
|
|
||||||
Get.snackbar('Success', 'Reimbursement submitted');
|
|
||||||
} else {
|
|
||||||
Get.snackbar('Error', controller.errorMessage.value);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
|
|
||||||
label: MyText.bodyMedium(
|
|
||||||
controller.isLoading.value ? "Submitting..." : "Submit",
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 5, vertical: 7),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,7 +39,7 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final controller = Get.put(ExpenseDetailController());
|
final controller = Get.put(ExpenseDetailController());
|
||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
controller.fetchExpenseDetails(expenseId);
|
controller.init(expenseId);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF7F7F7),
|
backgroundColor: const Color(0xFFF7F7F7),
|
||||||
@ -90,27 +90,15 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
// Show error snackbar only once after frame render
|
|
||||||
if (controller.errorMessage.isNotEmpty) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
Get.snackbar(
|
|
||||||
"Error",
|
|
||||||
controller.errorMessage.value,
|
|
||||||
backgroundColor: Colors.red.withOpacity(0.9),
|
|
||||||
colorText: Colors.white,
|
|
||||||
);
|
|
||||||
controller.errorMessage.value = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return _buildLoadingSkeleton();
|
return _buildLoadingSkeleton();
|
||||||
}
|
}
|
||||||
|
|
||||||
final expense = controller.expense.value;
|
final expense = controller.expense.value;
|
||||||
if (expense == null) {
|
|
||||||
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: MyText.bodyMedium("No expense details found."),
|
child: MyText.bodyMedium("No data to display."),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,7 +209,6 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
}) async {
|
}) async {
|
||||||
final success = await controller
|
final success = await controller
|
||||||
.updateExpenseStatusWithReimbursement(
|
.updateExpenseStatusWithReimbursement(
|
||||||
expenseId: expense.id,
|
|
||||||
comment: comment,
|
comment: comment,
|
||||||
reimburseTransactionId: reimburseTransactionId,
|
reimburseTransactionId: reimburseTransactionId,
|
||||||
reimburseDate: reimburseDate,
|
reimburseDate: reimburseDate,
|
||||||
@ -236,7 +223,8 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
backgroundColor: Colors.green.withOpacity(0.8),
|
backgroundColor: Colors.green.withOpacity(0.8),
|
||||||
colorText: Colors.white,
|
colorText: Colors.white,
|
||||||
);
|
);
|
||||||
await controller.fetchExpenseDetails(expenseId);
|
await controller.fetchExpenseDetails();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
Get.snackbar(
|
Get.snackbar(
|
||||||
@ -251,8 +239,9 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
final success = await controller.updateExpenseStatus(
|
final success =
|
||||||
expense.id, next.id);
|
await controller.updateExpenseStatus(next.id);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
Get.snackbar(
|
Get.snackbar(
|
||||||
'Success',
|
'Success',
|
||||||
@ -260,7 +249,7 @@ class ExpenseDetailScreen extends StatelessWidget {
|
|||||||
backgroundColor: Colors.green.withOpacity(0.8),
|
backgroundColor: Colors.green.withOpacity(0.8),
|
||||||
colorText: Colors.white,
|
colorText: Colors.white,
|
||||||
);
|
);
|
||||||
await controller.fetchExpenseDetails(expenseId);
|
await controller.fetchExpenseDetails();
|
||||||
} else {
|
} else {
|
||||||
Get.snackbar(
|
Get.snackbar(
|
||||||
'Error',
|
'Error',
|
||||||
|
|||||||
@ -1,47 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||||
import 'package:marco/helpers/utils/date_time_utils.dart';
|
import 'package:marco/helpers/utils/date_time_utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:marco/helpers/widgets/my_text.dart';
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text_style.dart';
|
||||||
import 'package:marco/model/employee_model.dart';
|
import 'package:marco/model/employee_model.dart';
|
||||||
|
|
||||||
/// Wrapper to open Expense Filter Bottom Sheet
|
|
||||||
void openExpenseFilterBottomSheet(
|
|
||||||
BuildContext context, ExpenseController expenseController) {
|
|
||||||
showModalBottomSheet(
|
|
||||||
context: context,
|
|
||||||
isScrollControlled: true,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
builder: (context) {
|
|
||||||
return ExpenseFilterBottomSheetWrapper(
|
|
||||||
expenseController: expenseController);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExpenseFilterBottomSheetWrapper extends StatelessWidget {
|
|
||||||
final ExpenseController expenseController;
|
|
||||||
|
|
||||||
const ExpenseFilterBottomSheetWrapper(
|
|
||||||
{super.key, required this.expenseController});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return DraggableScrollableSheet(
|
|
||||||
initialChildSize: 0.7,
|
|
||||||
minChildSize: 0.4,
|
|
||||||
maxChildSize: 0.95,
|
|
||||||
expand: false,
|
|
||||||
builder: (context, scrollController) {
|
|
||||||
return ExpenseFilterBottomSheet(
|
|
||||||
expenseController: expenseController,
|
|
||||||
scrollController: scrollController,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ExpenseFilterBottomSheet extends StatelessWidget {
|
class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||||
final ExpenseController expenseController;
|
final ExpenseController expenseController;
|
||||||
final ScrollController scrollController;
|
final ScrollController scrollController;
|
||||||
@ -52,26 +18,143 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
required this.scrollController,
|
required this.scrollController,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: MyTextStyle.bodySmall(xMuted: true),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: Colors.blueAccent, width: 1.5),
|
||||||
|
),
|
||||||
|
contentPadding: MySpacing.all(12),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
return SafeArea(
|
return BaseBottomSheet(
|
||||||
child: Container(
|
title: 'Filter Expenses',
|
||||||
decoration: const BoxDecoration(
|
onCancel: () => Get.back(),
|
||||||
color: Colors.white,
|
onSubmit: () {
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
expenseController.fetchExpenses();
|
||||||
),
|
Get.back();
|
||||||
|
},
|
||||||
|
submitText: 'Submit',
|
||||||
|
submitColor: Colors.indigo,
|
||||||
|
submitIcon: Icons.check_circle_outline,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
controller: scrollController,
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Align(
|
||||||
child: SingleChildScrollView(
|
alignment: Alignment.centerRight,
|
||||||
controller: scrollController,
|
child: TextButton(
|
||||||
padding:
|
onPressed: () => expenseController.clearFilters(),
|
||||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
child: const Text(
|
||||||
child: _buildContent(context),
|
"Reset Filter",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildBottomButtons(),
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
_buildField("Project", _popupSelector(
|
||||||
|
context,
|
||||||
|
currentValue: expenseController.selectedProject.value.isEmpty
|
||||||
|
? 'Select Project'
|
||||||
|
: expenseController.selectedProject.value,
|
||||||
|
items: expenseController.globalProjects,
|
||||||
|
onSelected: (value) =>
|
||||||
|
expenseController.selectedProject.value = value,
|
||||||
|
)),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
_buildField("Expense Status", _popupSelector(
|
||||||
|
context,
|
||||||
|
currentValue: expenseController.selectedStatus.value.isEmpty
|
||||||
|
? 'Select Expense Status'
|
||||||
|
: expenseController.expenseStatuses
|
||||||
|
.firstWhereOrNull((e) =>
|
||||||
|
e.id == expenseController.selectedStatus.value)
|
||||||
|
?.name ??
|
||||||
|
'Select Expense Status',
|
||||||
|
items: expenseController.expenseStatuses
|
||||||
|
.map((e) => e.name)
|
||||||
|
.toList(),
|
||||||
|
onSelected: (name) {
|
||||||
|
final status = expenseController.expenseStatuses
|
||||||
|
.firstWhere((e) => e.name == name);
|
||||||
|
expenseController.selectedStatus.value = status.id;
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
_buildField("Date Range", Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _dateButton(
|
||||||
|
label: expenseController.startDate.value == null
|
||||||
|
? 'Start Date'
|
||||||
|
: DateTimeUtils.formatDate(
|
||||||
|
expenseController.startDate.value!, 'dd MMM yyyy'),
|
||||||
|
onTap: () async {
|
||||||
|
DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate:
|
||||||
|
expenseController.startDate.value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
expenseController.startDate.value = picked;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(child: _dateButton(
|
||||||
|
label: expenseController.endDate.value == null
|
||||||
|
? 'End Date'
|
||||||
|
: DateTimeUtils.formatDate(
|
||||||
|
expenseController.endDate.value!, 'dd MMM yyyy'),
|
||||||
|
onTap: () async {
|
||||||
|
DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate:
|
||||||
|
expenseController.endDate.value ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
expenseController.endDate.value = picked;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
)),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
_buildField("Paid By", _employeeSelector(
|
||||||
|
selectedEmployees: expenseController.selectedPaidByEmployees,
|
||||||
|
)),
|
||||||
|
MySpacing.height(16),
|
||||||
|
|
||||||
|
_buildField("Created By", _employeeSelector(
|
||||||
|
selectedEmployees: expenseController.selectedCreatedByEmployees,
|
||||||
|
)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -79,208 +162,17 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds the filter content
|
Widget _buildField(String label, Widget child) {
|
||||||
Widget _buildContent(BuildContext context) {
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Center(
|
MyText.labelMedium(label),
|
||||||
child: Container(
|
MySpacing.height(8),
|
||||||
width: 50,
|
child,
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
MyText.titleLarge('Filter Expenses', fontWeight: 700),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => expenseController.clearFilters(),
|
|
||||||
child: const Text(
|
|
||||||
"Reset Filter",
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.red,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
/// Project Filter
|
|
||||||
_buildCardSection(
|
|
||||||
title: "Project",
|
|
||||||
child: _popupSelector(
|
|
||||||
context,
|
|
||||||
currentValue: expenseController.selectedProject.value.isEmpty
|
|
||||||
? 'Select Project'
|
|
||||||
: expenseController.selectedProject.value,
|
|
||||||
items: expenseController.globalProjects,
|
|
||||||
onSelected: (value) =>
|
|
||||||
expenseController.selectedProject.value = value,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
/// Expense Status Filter
|
|
||||||
_buildCardSection(
|
|
||||||
title: "Expense Status",
|
|
||||||
child: _popupSelector(
|
|
||||||
context,
|
|
||||||
currentValue: expenseController.selectedStatus.value.isEmpty
|
|
||||||
? 'Select Expense Status'
|
|
||||||
: expenseController.expenseStatuses
|
|
||||||
.firstWhereOrNull((e) =>
|
|
||||||
e.id == expenseController.selectedStatus.value)
|
|
||||||
?.name ??
|
|
||||||
'Select Expense Status',
|
|
||||||
items:
|
|
||||||
expenseController.expenseStatuses.map((e) => e.name).toList(),
|
|
||||||
onSelected: (name) {
|
|
||||||
final status = expenseController.expenseStatuses
|
|
||||||
.firstWhere((e) => e.name == name);
|
|
||||||
expenseController.selectedStatus.value = status.id;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
/// Date Range Filter
|
|
||||||
_buildCardSection(
|
|
||||||
title: "Date Range",
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _dateButton(
|
|
||||||
label: expenseController.startDate.value == null
|
|
||||||
? 'Start Date'
|
|
||||||
: DateTimeUtils.formatDate(
|
|
||||||
expenseController.startDate.value!, 'dd MMM yyyy'),
|
|
||||||
onTap: () async {
|
|
||||||
DateTime? picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate:
|
|
||||||
expenseController.startDate.value ?? DateTime.now(),
|
|
||||||
firstDate: DateTime(2020),
|
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
||||||
);
|
|
||||||
if (picked != null)
|
|
||||||
expenseController.startDate.value = picked;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _dateButton(
|
|
||||||
label: expenseController.endDate.value == null
|
|
||||||
? 'End Date'
|
|
||||||
: DateTimeUtils.formatDate(
|
|
||||||
expenseController.endDate.value!, 'dd MMM yyyy'),
|
|
||||||
onTap: () async {
|
|
||||||
DateTime? picked = await showDatePicker(
|
|
||||||
context: context,
|
|
||||||
initialDate:
|
|
||||||
expenseController.endDate.value ?? DateTime.now(),
|
|
||||||
firstDate: DateTime(2020),
|
|
||||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
||||||
);
|
|
||||||
if (picked != null)
|
|
||||||
expenseController.endDate.value = picked;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
/// Paid By Filter
|
|
||||||
_buildCardSection(
|
|
||||||
title: "Paid By",
|
|
||||||
child: _employeeFilterSection(
|
|
||||||
selectedEmployees: expenseController.selectedPaidByEmployees,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
/// Created By Filter
|
|
||||||
_buildCardSection(
|
|
||||||
title: "Created By",
|
|
||||||
child: _employeeFilterSection(
|
|
||||||
selectedEmployees: expenseController.selectedCreatedByEmployees,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Bottom Action Buttons
|
|
||||||
Widget _buildBottomButtons() {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
// Cancel Button
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Get.back();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
label: MyText.bodyMedium(
|
|
||||||
"Cancel",
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
|
|
||||||
// Submit Button
|
|
||||||
Expanded(
|
|
||||||
child: ElevatedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
expenseController.fetchExpenses();
|
|
||||||
Get.back();
|
|
||||||
},
|
|
||||||
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
|
|
||||||
label: MyText.bodyMedium(
|
|
||||||
"Submit",
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.indigo,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Popup Selector
|
|
||||||
Widget _popupSelector(
|
Widget _popupSelector(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required String currentValue,
|
required String currentValue,
|
||||||
@ -288,21 +180,20 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
required ValueChanged<String> onSelected,
|
required ValueChanged<String> onSelected,
|
||||||
}) {
|
}) {
|
||||||
return PopupMenuButton<String>(
|
return PopupMenuButton<String>(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) => items
|
||||||
return items
|
.map((e) => PopupMenuItem<String>(
|
||||||
.map((e) => PopupMenuItem<String>(
|
value: e,
|
||||||
value: e,
|
child: Text(e),
|
||||||
child: Text(e),
|
))
|
||||||
))
|
.toList(),
|
||||||
.toList();
|
|
||||||
},
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: MySpacing.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
border: Border.all(color: Colors.grey.shade300),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
@ -321,55 +212,52 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Card Section Wrapper
|
|
||||||
Widget _buildCardSection({required String title, required Widget child}) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.bodyMedium(title, fontWeight: 600),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Date Button
|
|
||||||
Widget _dateButton({required String label, required VoidCallback onTap}) {
|
Widget _dateButton({required String label, required VoidCallback onTap}) {
|
||||||
return ElevatedButton.icon(
|
return GestureDetector(
|
||||||
onPressed: onTap,
|
onTap: onTap,
|
||||||
icon: const Icon(Icons.calendar_today, size: 16),
|
child: Container(
|
||||||
style: ElevatedButton.styleFrom(
|
padding: MySpacing.xy(16, 12),
|
||||||
backgroundColor: Colors.grey.shade100,
|
decoration: BoxDecoration(
|
||||||
foregroundColor: Colors.black,
|
color: Colors.grey.shade100,
|
||||||
elevation: 0,
|
borderRadius: BorderRadius.circular(12),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
border: Border.all(color: Colors.grey.shade300),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
|
||||||
|
MySpacing.width(8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(label,
|
||||||
|
style: MyTextStyle.bodyMedium(),
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
label: Text(label, overflow: TextOverflow.ellipsis),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Employee Filter Section
|
Widget _employeeSelector({
|
||||||
Widget _employeeFilterSection(
|
required RxList<EmployeeModel> selectedEmployees,
|
||||||
{required RxList<EmployeeModel> selectedEmployees}) {
|
}) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Obx(() {
|
Obx(() {
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 6,
|
spacing: 8,
|
||||||
runSpacing: -8,
|
runSpacing: -8,
|
||||||
children: selectedEmployees.map((emp) {
|
children: selectedEmployees.map((emp) {
|
||||||
return Chip(
|
return Chip(
|
||||||
label: Text(emp.name),
|
label: Text(emp.name),
|
||||||
deleteIcon: const Icon(Icons.close, size: 18),
|
|
||||||
onDeleted: () => selectedEmployees.remove(emp),
|
onDeleted: () => selectedEmployees.remove(emp),
|
||||||
|
deleteIcon: const Icon(Icons.close, size: 18),
|
||||||
backgroundColor: Colors.grey.shade200,
|
backgroundColor: Colors.grey.shade200,
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
const SizedBox(height: 6),
|
MySpacing.height(8),
|
||||||
Autocomplete<EmployeeModel>(
|
Autocomplete<EmployeeModel>(
|
||||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||||
if (textEditingValue.text.isEmpty) {
|
if (textEditingValue.text.isEmpty) {
|
||||||
@ -387,19 +275,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
selectedEmployees.add(emp);
|
selectedEmployees.add(emp);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
|
fieldViewBuilder: (context, controller, focusNode, _) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
decoration: InputDecoration(
|
decoration: _inputDecoration("Search Employee"),
|
||||||
hintText: 'Search Employee',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
contentPadding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
optionsViewBuilder: (context, onSelected, options) {
|
optionsViewBuilder: (context, onSelected, options) {
|
||||||
@ -411,7 +291,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
|||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 200,
|
height: 200,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
itemCount: options.length,
|
itemCount: options.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final emp = options.elementAt(index);
|
final emp = options.elementAt(index);
|
||||||
|
|||||||
@ -42,8 +42,9 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
|
|||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ExpenseFilterBottomSheetWrapper(
|
return ExpenseFilterBottomSheet(
|
||||||
expenseController: expenseController,
|
expenseController: expenseController,
|
||||||
|
scrollController: ScrollController(),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user