Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
8 changed files with 683 additions and 842 deletions
Showing only changes of commit 29f759ca9d - Show all commits

View File

@ -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.";

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

View File

@ -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";
} }

View File

@ -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,41 +83,38 @@ 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const _DragHandle(), _buildDropdown<String>(
Center(
child: MyText.titleLarge("Add Expense", fontWeight: 700),
),
const SizedBox(height: 20),
_buildSectionWithDropdown<String>(
icon: Icons.work_outline, icon: Icons.work_outline,
title: "Project", title: "Project",
requiredField: true, requiredField: true,
currentValue: controller.selectedProject.value.isEmpty value: controller.selectedProject.value.isEmpty
? "Select Project" ? "Select Project"
: controller.selectedProject.value, : controller.selectedProject.value,
onTap: () => _showOptionList<String>( onTap: () => _showOptionList<String>(
controller.globalProjects.toList(), controller.globalProjects.toList(),
(p) => p, (p) => p,
(val) => controller.selectedProject.value = val), (val) => controller.selectedProject.value = val,
), ),
const SizedBox(height: 16), ),
_buildSectionWithDropdown<ExpenseTypeModel>( MySpacing.height(16),
_buildDropdown<ExpenseTypeModel>(
icon: Icons.category_outlined, icon: Icons.category_outlined,
title: "Expense Type", title: "Expense Type",
requiredField: true, requiredField: true,
currentValue: controller.selectedExpenseType.value?.name ?? value: controller.selectedExpenseType.value?.name ??
"Select Expense Type", "Select Expense Type",
onTap: () => _showOptionList<ExpenseTypeModel>( onTap: () => _showOptionList<ExpenseTypeModel>(
controller.expenseTypes.toList(), controller.expenseTypes.toList(),
@ -125,8 +122,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
(val) => controller.selectedExpenseType.value = val, (val) => controller.selectedExpenseType.value = val,
), ),
), ),
if (controller if (controller.selectedExpenseType.value?.noOfPersonsRequired ==
.selectedExpenseType.value?.noOfPersonsRequired ==
true) true)
Padding( Padding(
padding: const EdgeInsets.only(top: 16), padding: const EdgeInsets.only(top: 16),
@ -138,7 +134,7 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
title: "No. of Persons", title: "No. of Persons",
requiredField: true, requiredField: true,
), ),
const SizedBox(height: 6), MySpacing.height(6),
_CustomTextField( _CustomTextField(
controller: controller.noOfPersonsController, controller: controller.noOfPersonsController,
hint: "Enter No. of Persons", hint: "Enter No. of Persons",
@ -147,35 +143,31 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
], ],
), ),
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined, title: "GST No."),
title: "GST No.", MySpacing.height(6),
),
const SizedBox(height: 6),
_CustomTextField( _CustomTextField(
controller: controller.gstController, controller: controller.gstController, hint: "Enter GST No."),
hint: "Enter GST No.", MySpacing.height(16),
), _buildDropdown<PaymentModeModel>(
const SizedBox(height: 16),
_buildSectionWithDropdown<PaymentModeModel>(
icon: Icons.payment, icon: Icons.payment,
title: "Payment Mode", title: "Payment Mode",
requiredField: true, requiredField: true,
currentValue: controller.selectedPaymentMode.value?.name ?? value: controller.selectedPaymentMode.value?.name ??
"Select Payment Mode", "Select Payment Mode",
onTap: () => _showOptionList<PaymentModeModel>( onTap: () => _showOptionList<PaymentModeModel>(
controller.paymentModes.toList(), controller.paymentModes.toList(),
(m) => m.name, (p) => p.name,
(val) => controller.selectedPaymentMode.value = val, (val) => controller.selectedPaymentMode.value = val,
), ),
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.person_outline, icon: Icons.person_outline,
title: "Paid By", title: "Paid By",
requiredField: true), requiredField: true),
const SizedBox(height: 6), MySpacing.height(6),
GestureDetector( GestureDetector(
onTap: _showEmployeeList, onTap: _showEmployeeList,
child: _TileContainer( child: _TileContainer(
@ -193,42 +185,42 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
), ),
), ),
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.currency_rupee, icon: Icons.currency_rupee,
title: "Amount", title: "Amount",
requiredField: true), requiredField: true),
const SizedBox(height: 6), MySpacing.height(6),
_CustomTextField( _CustomTextField(
controller: controller.amountController, controller: controller.amountController,
hint: "Enter Amount", hint: "Enter Amount",
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.store_mall_directory_outlined, icon: Icons.store_mall_directory_outlined,
title: "Supplier Name", title: "Supplier Name",
requiredField: true), requiredField: true,
const SizedBox(height: 6), ),
MySpacing.height(6),
_CustomTextField( _CustomTextField(
controller: controller.supplierController, controller: controller.supplierController,
hint: "Enter Supplier Name", hint: "Enter Supplier Name"),
), MySpacing.height(16),
const SizedBox(height: 16),
_SectionTitle( _SectionTitle(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined,
title: "Transaction ID"), title: "Transaction ID"),
const SizedBox(height: 6), MySpacing.height(6),
_CustomTextField( _CustomTextField(
controller: controller.transactionIdController, controller: controller.transactionIdController,
hint: "Enter Transaction ID", hint: "Enter Transaction ID"),
), MySpacing.height(16),
const SizedBox(height: 16),
_SectionTitle( _SectionTitle(
icon: Icons.calendar_today, icon: Icons.calendar_today,
title: "Transaction Date", title: "Transaction Date",
requiredField: true), requiredField: true,
const SizedBox(height: 6), ),
MySpacing.height(6),
GestureDetector( GestureDetector(
onTap: () => controller.pickTransactionDate(context), onTap: () => controller.pickTransactionDate(context),
child: AbsorbPointer( child: AbsorbPointer(
@ -238,28 +230,26 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
), ),
), ),
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(icon: Icons.location_on_outlined, title: "Location"),
icon: Icons.location_on_outlined, title: "Location"), MySpacing.height(6),
const SizedBox(height: 6),
TextField( TextField(
controller: controller.locationController, controller: controller.locationController,
decoration: InputDecoration( decoration: InputDecoration(
hintText: "Enter Location", hintText: "Enter Location",
filled: true, filled: true,
fillColor: Colors.grey.shade100, fillColor: Colors.grey.shade100,
border: OutlineInputBorder( border:
borderRadius: BorderRadius.circular(8)), OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric( contentPadding:
horizontal: 12, vertical: 10), const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
suffixIcon: controller.isFetchingLocation.value suffixIcon: controller.isFetchingLocation.value
? const Padding( ? const Padding(
padding: EdgeInsets.all(12), padding: EdgeInsets.all(12),
child: SizedBox( child: SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: child: CircularProgressIndicator(strokeWidth: 2),
CircularProgressIndicator(strokeWidth: 2),
), ),
) )
: IconButton( : IconButton(
@ -269,123 +259,52 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
), ),
), ),
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.attach_file, icon: Icons.attach_file,
title: "Attachments", title: "Attachments",
requiredField: true), requiredField: true),
const SizedBox(height: 6), MySpacing.height(6),
_AttachmentsSection( _AttachmentsSection(
attachments: controller.attachments, attachments: controller.attachments,
onRemove: controller.removeAttachment, onRemove: controller.removeAttachment,
onAdd: controller.pickAttachments, onAdd: controller.pickAttachments,
), ),
const SizedBox(height: 16), MySpacing.height(16),
_SectionTitle( _SectionTitle(
icon: Icons.description_outlined, icon: Icons.description_outlined,
title: "Description", title: "Description",
requiredField: true), requiredField: true),
const SizedBox(height: 6), MySpacing.height(6),
_CustomTextField( _CustomTextField(
controller: controller.descriptionController, controller: controller.descriptionController,
hint: "Enter Description", hint: "Enter Description",
maxLines: 3, 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),
),
);
},
),
),
],
),
], ],
), ),
); );
}), });
),
),
);
} }
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),
),
), ),
); );
} }

View File

@ -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,82 +82,84 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
); );
} }
@override InputDecoration _inputDecoration(String hint) {
Widget build(BuildContext context) { return InputDecoration(
return SafeArea( hintText: hint,
child: Container( hintStyle: MyTextStyle.bodySmall(xMuted: true),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), filled: true,
decoration: const BoxDecoration( fillColor: Colors.grey.shade100,
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Drag handle
Container(
width: 50,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
),
// Title
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleLarge('Reimbursement Info', fontWeight: 700),
const SizedBox(),
],
),
const SizedBox(height: 20),
Flexible(
child: SingleChildScrollView(
child: Column(
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( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300), 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),
); );
} }
Widget _buildDatePickerField() { @override
Widget build(BuildContext context) {
return Obx(() { return Obx(() {
return InkWell( return BaseBottomSheet(
title: "Reimbursement Info",
isSubmitting: controller.isLoading.value,
onCancel: () {
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.labelMedium("Comment"),
MySpacing.height(8),
TextField(
controller: commentCtrl,
decoration: _inputDecoration("Enter comment"),
),
MySpacing.height(16),
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 { onTap: () async {
final picked = await showDatePicker( final picked = await showDatePicker(
context: context, context: context,
@ -166,132 +173,37 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
dateStr.value = DateFormat('yyyy-MM-dd').format(picked); dateStr.value = DateFormat('yyyy-MM-dd').format(picked);
} }
}, },
child: InputDecorator( child: AbsorbPointer(
decoration: InputDecoration( child: TextField(
labelText: 'Reimbursement Date', controller: TextEditingController(text: dateStr.value),
contentPadding: decoration: _inputDecoration("Select Date").copyWith(
const EdgeInsets.symmetric(horizontal: 12, vertical: 14), suffixIcon: const Icon(Icons.calendar_today),
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,
), ),
), ),
], MySpacing.height(16),
),
),
);
});
}
Widget _buildEmployeePickerField() { MyText.labelMedium("Reimbursed By"),
return Obx(() { MySpacing.height(8),
return GestureDetector( GestureDetector(
onTap: _showEmployeeList, onTap: _showEmployeeList,
child: InputDecorator( child: AbsorbPointer(
decoration: InputDecoration( child: TextField(
labelText: 'Reimbursed By', controller: TextEditingController(
contentPadding: text: controller.selectedReimbursedBy.value == null
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 ?? ''}', : '${controller.selectedReimbursedBy.value?.firstName ?? ''} ${controller.selectedReimbursedBy.value?.lastName ?? ''}',
style: TextStyle( ),
fontSize: 14, decoration: _inputDecoration("Select Employee").copyWith(
color: controller.selectedReimbursedBy.value == null suffixIcon: const Icon(Icons.expand_more),
? 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),
),
);
}),
),
],
);
}
} }

View File

@ -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',

View File

@ -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,54 +18,49 @@ 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();
child: Column( },
children: [ submitText: 'Submit',
Expanded( submitColor: Colors.indigo,
submitIcon: Icons.check_circle_outline,
child: SingleChildScrollView( child: SingleChildScrollView(
controller: scrollController, controller: scrollController,
padding: child: Column(
const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: _buildContent(context),
),
),
_buildBottomButtons(),
],
),
),
);
});
}
/// Builds the filter content
Widget _buildContent(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Center( Align(
child: Container( alignment: Alignment.centerRight,
width: 50, child: TextButton(
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(), onPressed: () => expenseController.clearFilters(),
child: const Text( child: const Text(
"Reset Filter", "Reset Filter",
@ -109,15 +70,10 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
), ),
), ),
), ),
],
), ),
MySpacing.height(8),
const SizedBox(height: 16), _buildField("Project", _popupSelector(
/// Project Filter
_buildCardSection(
title: "Project",
child: _popupSelector(
context, context,
currentValue: expenseController.selectedProject.value.isEmpty currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project' ? 'Select Project'
@ -125,14 +81,10 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
items: expenseController.globalProjects, items: expenseController.globalProjects,
onSelected: (value) => onSelected: (value) =>
expenseController.selectedProject.value = value, expenseController.selectedProject.value = value,
), )),
), MySpacing.height(16),
const SizedBox(height: 16),
/// Expense Status Filter _buildField("Expense Status", _popupSelector(
_buildCardSection(
title: "Expense Status",
child: _popupSelector(
context, context,
currentValue: expenseController.selectedStatus.value.isEmpty currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status' ? 'Select Expense Status'
@ -141,24 +93,20 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
e.id == expenseController.selectedStatus.value) e.id == expenseController.selectedStatus.value)
?.name ?? ?.name ??
'Select Expense Status', 'Select Expense Status',
items: items: expenseController.expenseStatuses
expenseController.expenseStatuses.map((e) => e.name).toList(), .map((e) => e.name)
.toList(),
onSelected: (name) { onSelected: (name) {
final status = expenseController.expenseStatuses final status = expenseController.expenseStatuses
.firstWhere((e) => e.name == name); .firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id; expenseController.selectedStatus.value = status.id;
}, },
), )),
), MySpacing.height(16),
const SizedBox(height: 16),
/// Date Range Filter _buildField("Date Range", Row(
_buildCardSection(
title: "Date Range",
child: Row(
children: [ children: [
Expanded( Expanded(child: _dateButton(
child: _dateButton(
label: expenseController.startDate.value == null label: expenseController.startDate.value == null
? 'Start Date' ? 'Start Date'
: DateTimeUtils.formatDate( : DateTimeUtils.formatDate(
@ -171,14 +119,13 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: DateTime.now().add(const Duration(days: 365)),
); );
if (picked != null) if (picked != null) {
expenseController.startDate.value = picked; expenseController.startDate.value = picked;
}
}, },
), )),
), MySpacing.width(8),
const SizedBox(width: 8), Expanded(child: _dateButton(
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null label: expenseController.endDate.value == null
? 'End Date' ? 'End Date'
: DateTimeUtils.formatDate( : DateTimeUtils.formatDate(
@ -191,96 +138,41 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
firstDate: DateTime(2020), firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)), lastDate: DateTime.now().add(const Duration(days: 365)),
); );
if (picked != null) if (picked != null) {
expenseController.endDate.value = picked; expenseController.endDate.value = picked;
}
}, },
), )),
),
], ],
), )),
), MySpacing.height(16),
const SizedBox(height: 16),
/// Paid By Filter _buildField("Paid By", _employeeSelector(
_buildCardSection(
title: "Paid By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedPaidByEmployees, selectedEmployees: expenseController.selectedPaidByEmployees,
), )),
), MySpacing.height(16),
const SizedBox(height: 16),
/// Created By Filter _buildField("Created By", _employeeSelector(
_buildCardSection(
title: "Created By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedCreatedByEmployees, selectedEmployees: expenseController.selectedCreatedByEmployees,
), )),
),
const SizedBox(height: 24),
], ],
),
),
); );
});
} }
/// Bottom Action Buttons Widget _buildField(String label, Widget child) {
Widget _buildBottomButtons() { return Column(
return Padding( crossAxisAlignment: CrossAxisAlignment.start,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [ children: [
// Cancel Button MyText.labelMedium(label),
Expanded( MySpacing.height(8),
child: ElevatedButton.icon( child,
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);

View File

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