made changes for employee get method
This commit is contained in:
parent
0401b41b3c
commit
aa76ec60cb
@ -8,13 +8,13 @@ import 'package:geocoding/geocoding.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mime/mime.dart';
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
|
||||
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||
import 'package:marco/helpers/services/api_service.dart';
|
||||
import 'package:marco/helpers/services/app_logger.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
|
||||
@ -50,6 +50,9 @@ class AddExpenseController extends GetxController {
|
||||
final paymentModes = <PaymentModeModel>[].obs;
|
||||
final allEmployees = <EmployeeModel>[].obs;
|
||||
final existingAttachments = <Map<String, dynamic>>[].obs;
|
||||
final employeeSearchController = TextEditingController();
|
||||
final isSearchingEmployees = false.obs;
|
||||
final employeeSearchResults = <EmployeeModel>[].obs;
|
||||
|
||||
// Editing
|
||||
String? editingExpenseId;
|
||||
@ -61,7 +64,10 @@ class AddExpenseController extends GetxController {
|
||||
super.onInit();
|
||||
fetchMasterData();
|
||||
fetchGlobalProjects();
|
||||
fetchAllEmployees();
|
||||
|
||||
employeeSearchController.addListener(() {
|
||||
searchEmployees(employeeSearchController.text);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -77,12 +83,44 @@ class AddExpenseController extends GetxController {
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> searchEmployees(String searchQuery) async {
|
||||
if (searchQuery.trim().isEmpty) {
|
||||
employeeSearchResults.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
isSearchingEmployees.value = true;
|
||||
try {
|
||||
final results = await ApiService.searchEmployeesBasic(
|
||||
searchString: searchQuery.trim(),
|
||||
);
|
||||
|
||||
if (results != null) {
|
||||
employeeSearchResults.assignAll(
|
||||
results.map((e) => EmployeeModel.fromJson(e)),
|
||||
);
|
||||
} else {
|
||||
employeeSearchResults.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||
employeeSearchResults.clear();
|
||||
} finally {
|
||||
isSearchingEmployees.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Form Population for Edit ----------
|
||||
void populateFieldsForEdit(Map<String, dynamic> data) {
|
||||
Future<void> populateFieldsForEdit(Map<String, dynamic> data) async {
|
||||
isEditMode.value = true;
|
||||
editingExpenseId = data['id'];
|
||||
|
||||
// Basic fields
|
||||
// --- Fetch all Paid By variables up front ---
|
||||
final paidById = (data['paidById'] ?? '').toString();
|
||||
final paidByFirstName = (data['paidByFirstName'] ?? '').toString().trim();
|
||||
final paidByLastName = (data['paidByLastName'] ?? '').toString().trim();
|
||||
|
||||
// --- Standard Fields ---
|
||||
selectedProject.value = data['projectName'] ?? '';
|
||||
amountController.text = data['amount']?.toString() ?? '';
|
||||
supplierController.text = data['supplerName'] ?? '';
|
||||
@ -90,7 +128,7 @@ class AddExpenseController extends GetxController {
|
||||
transactionIdController.text = data['transactionId'] ?? '';
|
||||
locationController.text = data['location'] ?? '';
|
||||
|
||||
// Transaction Date
|
||||
// --- Transaction Date ---
|
||||
if (data['transactionDate'] != null) {
|
||||
try {
|
||||
final parsedDate = DateTime.parse(data['transactionDate']);
|
||||
@ -107,31 +145,29 @@ class AddExpenseController extends GetxController {
|
||||
transactionDateController.clear();
|
||||
}
|
||||
|
||||
// No of Persons
|
||||
// --- No of Persons ---
|
||||
noOfPersonsController.text = (data['noOfPersons'] ?? 0).toString();
|
||||
|
||||
// Select Expense Type and Payment Mode by matching IDs
|
||||
// --- Dropdown selections ---
|
||||
selectedExpenseType.value =
|
||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||
selectedPaymentMode.value =
|
||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||
|
||||
// Select Paid By employee matching id (case insensitive, trimmed)
|
||||
final paidById = data['paidById']?.toString().trim().toLowerCase() ?? '';
|
||||
selectedPaidBy.value = allEmployees
|
||||
.firstWhereOrNull((e) => e.id.trim().toLowerCase() == paidById);
|
||||
// --- Paid By select ---
|
||||
// 1. By ID
|
||||
// --- Paid By select ---
|
||||
selectedPaidBy.value =
|
||||
allEmployees.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||
|
||||
if (selectedPaidBy.value == null && paidById.isNotEmpty) {
|
||||
logSafe('⚠️ Could not match paidById: "$paidById"',
|
||||
level: LogLevel.warning);
|
||||
for (var emp in allEmployees) {
|
||||
logSafe(
|
||||
'Employee ID: "${emp.id.trim().toLowerCase()}", Name: "${emp.name}"',
|
||||
level: LogLevel.warning);
|
||||
}
|
||||
if (selectedPaidBy.value == null) {
|
||||
final fullName = '$paidByFirstName $paidByLastName';
|
||||
await searchEmployees(fullName);
|
||||
selectedPaidBy.value = employeeSearchResults
|
||||
.firstWhereOrNull((e) => e.id.trim() == paidById.trim());
|
||||
}
|
||||
|
||||
// Populate existing attachments if present
|
||||
// --- Existing Attachments ---
|
||||
existingAttachments.clear();
|
||||
if (data['attachments'] != null && data['attachments'] is List) {
|
||||
existingAttachments
|
||||
@ -184,7 +220,6 @@ class AddExpenseController extends GetxController {
|
||||
await Future.wait([
|
||||
fetchMasterData(),
|
||||
fetchGlobalProjects(),
|
||||
fetchAllEmployees(),
|
||||
]);
|
||||
}
|
||||
|
||||
@ -451,18 +486,4 @@ class AddExpenseController extends GetxController {
|
||||
logSafe("Error fetching projects: $e", level: LogLevel.error);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> fetchAllEmployees() async {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
final response = await ApiService.getAllEmployees();
|
||||
if (response != null) {
|
||||
allEmployees.assignAll(response.map((e) => EmployeeModel.fromJson(e)));
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error fetching employees: $e", level: LogLevel.error);
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -8,7 +8,7 @@ import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/expense_status_model.dart';
|
||||
import 'package:marco/model/employee_model.dart';
|
||||
import 'package:marco/helpers/widgets/my_snackbar.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ExpenseController extends GetxController {
|
||||
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
|
||||
@ -32,6 +32,10 @@ class ExpenseController extends GetxController {
|
||||
final RxList<EmployeeModel> selectedCreatedByEmployees =
|
||||
<EmployeeModel>[].obs;
|
||||
final RxString selectedDateType = 'Transaction Date'.obs;
|
||||
|
||||
final employeeSearchController = TextEditingController();
|
||||
final isSearchingEmployees = false.obs;
|
||||
final employeeSearchResults = <EmployeeModel>[].obs;
|
||||
|
||||
final List<String> dateTypes = [
|
||||
'Transaction Date',
|
||||
@ -46,6 +50,9 @@ class ExpenseController extends GetxController {
|
||||
super.onInit();
|
||||
loadInitialMasterData();
|
||||
fetchAllEmployees();
|
||||
employeeSearchController.addListener(() {
|
||||
searchEmployees(employeeSearchController.text);
|
||||
});
|
||||
}
|
||||
|
||||
bool get isFilterApplied {
|
||||
@ -94,6 +101,33 @@ class ExpenseController extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchEmployees(String searchQuery) async {
|
||||
if (searchQuery.trim().isEmpty) {
|
||||
employeeSearchResults.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
isSearchingEmployees.value = true;
|
||||
try {
|
||||
final results = await ApiService.searchEmployeesBasic(
|
||||
searchString: searchQuery.trim(),
|
||||
);
|
||||
|
||||
if (results != null) {
|
||||
employeeSearchResults.assignAll(
|
||||
results.map((e) => EmployeeModel.fromJson(e)),
|
||||
);
|
||||
} else {
|
||||
employeeSearchResults.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
logSafe("Error searching employees: $e", level: LogLevel.error);
|
||||
employeeSearchResults.clear();
|
||||
} finally {
|
||||
isSearchingEmployees.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch expenses using filters
|
||||
Future<void> fetchExpenses({
|
||||
List<String>? projectIds,
|
||||
@ -173,32 +207,33 @@ class ExpenseController extends GetxController {
|
||||
|
||||
/// Fetch master data: expense types, payment modes, and expense status
|
||||
Future<void> fetchMasterData() async {
|
||||
try {
|
||||
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
||||
if (expenseTypesData is List) {
|
||||
expenseTypes.value =
|
||||
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
}
|
||||
try {
|
||||
final expenseTypesData = await ApiService.getMasterExpenseTypes();
|
||||
if (expenseTypesData is List) {
|
||||
expenseTypes.value =
|
||||
expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
final paymentModesData = await ApiService.getMasterPaymentModes();
|
||||
if (paymentModesData is List) {
|
||||
paymentModes.value =
|
||||
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||
}
|
||||
final paymentModesData = await ApiService.getMasterPaymentModes();
|
||||
if (paymentModesData is List) {
|
||||
paymentModes.value =
|
||||
paymentModesData.map((e) => PaymentModeModel.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
final expenseStatusData = await ApiService.getMasterExpenseStatus();
|
||||
if (expenseStatusData is List) {
|
||||
expenseStatuses.value =
|
||||
expenseStatusData.map((e) => ExpenseStatusModel.fromJson(e)).toList();
|
||||
final expenseStatusData = await ApiService.getMasterExpenseStatus();
|
||||
if (expenseStatusData is List) {
|
||||
expenseStatuses.value = expenseStatusData
|
||||
.map((e) => ExpenseStatusModel.fromJson(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch master data: $e",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
showAppSnackbar(
|
||||
title: "Error",
|
||||
message: "Failed to fetch master data: $e",
|
||||
type: SnackbarType.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch global projects
|
||||
Future<void> fetchGlobalProjects() async {
|
||||
|
@ -17,6 +17,7 @@ class ApiEndpoints {
|
||||
// Employee Screen API Endpoints
|
||||
static const String getAllEmployeesByProject = "/employee/list";
|
||||
static const String getAllEmployees = "/employee/list";
|
||||
static const String getEmployeesWithoutPermission = "/employee/basic";
|
||||
static const String getRoles = "/roles/jobrole";
|
||||
static const String createEmployee = "/employee/manage-mobile";
|
||||
static const String getEmployeeInfo = "/employee/profile/get";
|
||||
|
@ -1151,6 +1151,31 @@ class ApiService {
|
||||
}
|
||||
|
||||
// === Employee APIs ===
|
||||
/// Search employees by first name and last name only (not middle name)
|
||||
/// Returns a list of up to 10 employee records matching the search string.
|
||||
static Future<List<dynamic>?> searchEmployeesBasic({
|
||||
String? searchString,
|
||||
}) async {
|
||||
// Remove ArgumentError check because searchString is optional now
|
||||
|
||||
final queryParams = <String, String>{};
|
||||
|
||||
// Add searchString to query parameters only if it's not null or empty
|
||||
if (searchString != null && searchString.isNotEmpty) {
|
||||
queryParams['searchString'] = searchString;
|
||||
}
|
||||
|
||||
final response = await _getRequest(
|
||||
ApiEndpoints.getEmployeesWithoutPermission,
|
||||
queryParams: queryParams,
|
||||
);
|
||||
|
||||
if (response != null) {
|
||||
return _parseResponse(response, label: 'Search Employees Basic');
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static Future<List<dynamic>?> getAllEmployeesByProject(
|
||||
String projectId) async {
|
||||
|
30
lib/model/employees/employee_with_id_name_model.dart
Normal file
30
lib/model/employees/employee_with_id_name_model.dart
Normal file
@ -0,0 +1,30 @@
|
||||
class EmployeeModelWithIdName {
|
||||
final String id;
|
||||
final String firstName;
|
||||
final String lastName;
|
||||
final String name;
|
||||
|
||||
EmployeeModelWithIdName({
|
||||
required this.id,
|
||||
required this.firstName,
|
||||
required this.lastName,
|
||||
required this.name,
|
||||
});
|
||||
|
||||
factory EmployeeModelWithIdName.fromJson(Map<String, dynamic> json) {
|
||||
return EmployeeModelWithIdName(
|
||||
id: json['id']?.toString() ?? '',
|
||||
firstName: json['firstName'] ?? '',
|
||||
lastName: json['lastName'] ?? '',
|
||||
name: '${json['firstName'] ?? ''} ${json['lastName'] ?? ''}'.trim(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'firstName': name.split(' ').first,
|
||||
'lastName': name.split(' ').length > 1 ? name.split(' ').last : '',
|
||||
};
|
||||
}
|
||||
}
|
@ -4,6 +4,7 @@ import 'package:get/get.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/model/expense/expense_type_model.dart';
|
||||
import 'package:marco/model/expense/payment_types_model.dart';
|
||||
import 'package:marco/model/expense/employee_selector_bottom_sheet.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
import 'package:url_launcher/url_launcher_string.dart';
|
||||
@ -39,34 +40,23 @@ class _AddExpenseBottomSheet extends StatefulWidget {
|
||||
class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet> {
|
||||
final AddExpenseController controller = Get.put(AddExpenseController());
|
||||
|
||||
void _showEmployeeList() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
|
||||
builder: (_) => Obx(() {
|
||||
final employees = controller.allEmployees;
|
||||
return SizedBox(
|
||||
height: 300,
|
||||
child: ListView.builder(
|
||||
itemCount: employees.length,
|
||||
itemBuilder: (_, index) {
|
||||
final emp = employees[index];
|
||||
final fullName = '${emp.firstName} ${emp.lastName}'.trim();
|
||||
return ListTile(
|
||||
title: Text(fullName.isNotEmpty ? fullName : "Unnamed"),
|
||||
onTap: () {
|
||||
controller.selectedPaidBy.value = emp;
|
||||
Navigator.pop(context);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
void _showEmployeeList() async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (_) => EmployeeSelectorBottomSheet(),
|
||||
);
|
||||
|
||||
// Optional cleanup
|
||||
controller.employeeSearchController.clear();
|
||||
controller.employeeSearchResults.clear();
|
||||
}
|
||||
|
||||
|
||||
Future<void> _showOptionList<T>(
|
||||
List<T> options,
|
||||
|
73
lib/model/expense/employee_selector_bottom_sheet.dart
Normal file
73
lib/model/expense/employee_selector_bottom_sheet.dart
Normal file
@ -0,0 +1,73 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/controller/expense/add_expense_controller.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.dart';
|
||||
import 'package:marco/helpers/widgets/my_text.dart';
|
||||
import 'package:marco/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
class EmployeeSelectorBottomSheet extends StatelessWidget {
|
||||
final AddExpenseController controller = Get.find<AddExpenseController>();
|
||||
|
||||
EmployeeSelectorBottomSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: "Search Employee",
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: () {},
|
||||
showButtons: false,
|
||||
child: Obx(() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: controller.employeeSearchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search by name, email...",
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
),
|
||||
onChanged: (value) => controller.searchEmployees(value),
|
||||
),
|
||||
MySpacing.height(12),
|
||||
SizedBox(
|
||||
height: 400, // Adjust this if needed
|
||||
child: controller.isSearchingEmployees.value
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: controller.employeeSearchResults.isEmpty
|
||||
? Center(
|
||||
child: MyText.bodyMedium(
|
||||
"No employees found.",
|
||||
fontWeight: 500,
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
itemCount: controller.employeeSearchResults.length,
|
||||
itemBuilder: (_, index) {
|
||||
final emp = controller.employeeSearchResults[index];
|
||||
final fullName =
|
||||
'${emp.firstName} ${emp.lastName}'.trim();
|
||||
return ListTile(
|
||||
title: MyText.bodyLarge(
|
||||
fullName.isNotEmpty ? fullName : "Unnamed",
|
||||
fontWeight: 600,
|
||||
),
|
||||
onTap: () {
|
||||
controller.selectedPaidBy.value = emp;
|
||||
Get.back();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
128
lib/model/expense/employee_selector_for_filter_bottom_sheet.dart
Normal file
128
lib/model/expense/employee_selector_for_filter_bottom_sheet.dart
Normal file
@ -0,0 +1,128 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:marco/helpers/widgets/my_spacing.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/helpers/utils/base_bottom_sheet.dart';
|
||||
|
||||
class EmployeeSelectorBottomSheet extends StatefulWidget {
|
||||
final RxList<EmployeeModel> selectedEmployees;
|
||||
final Future<List<EmployeeModel>> Function(String) searchEmployees;
|
||||
final String title;
|
||||
|
||||
const EmployeeSelectorBottomSheet({
|
||||
super.key,
|
||||
required this.selectedEmployees,
|
||||
required this.searchEmployees,
|
||||
this.title = "Select Employees",
|
||||
});
|
||||
|
||||
@override
|
||||
State<EmployeeSelectorBottomSheet> createState() =>
|
||||
_EmployeeSelectorBottomSheetState();
|
||||
}
|
||||
|
||||
class _EmployeeSelectorBottomSheetState
|
||||
extends State<EmployeeSelectorBottomSheet> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final RxBool isSearching = false.obs;
|
||||
final RxList<EmployeeModel> searchResults = <EmployeeModel>[].obs;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Initial fetch (empty text gets all/none as you wish)
|
||||
_searchEmployees('');
|
||||
}
|
||||
|
||||
void _searchEmployees(String query) async {
|
||||
isSearching.value = true;
|
||||
List<EmployeeModel> results = await widget.searchEmployees(query);
|
||||
searchResults.assignAll(results);
|
||||
isSearching.value = false;
|
||||
}
|
||||
|
||||
void _submitSelection() =>
|
||||
Get.back(result: widget.selectedEmployees.toList());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BaseBottomSheet(
|
||||
title: widget.title,
|
||||
onCancel: () => Get.back(),
|
||||
onSubmit: _submitSelection,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Chips
|
||||
Obx(() => widget.selectedEmployees.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
children: widget.selectedEmployees
|
||||
.map(
|
||||
(emp) => Chip(
|
||||
label: MyText(emp.name),
|
||||
onDeleted: () =>
|
||||
widget.selectedEmployees.remove(emp),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
)),
|
||||
MySpacing.height(8),
|
||||
|
||||
// Search box
|
||||
TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search Employees...",
|
||||
border:
|
||||
OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
|
||||
prefixIcon: Icon(Icons.search),
|
||||
),
|
||||
onChanged: _searchEmployees,
|
||||
),
|
||||
MySpacing.height(12),
|
||||
|
||||
SizedBox(
|
||||
height: 320, // CHANGE AS PER DESIGN!
|
||||
child: Obx(() {
|
||||
if (isSearching.value) {
|
||||
return Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (searchResults.isEmpty) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child:
|
||||
MyText('No results', style: MyTextStyle.bodyMedium()),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: searchResults.length,
|
||||
separatorBuilder: (_, __) => Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final emp = searchResults[index];
|
||||
final isSelected = widget.selectedEmployees.contains(emp);
|
||||
return ListTile(
|
||||
title: MyText(emp.name),
|
||||
trailing: isSelected
|
||||
? Icon(Icons.check_circle, color: Colors.indigo)
|
||||
: Icon(Icons.radio_button_unchecked,
|
||||
color: Colors.grey),
|
||||
onTap: () {
|
||||
if (isSelected) {
|
||||
widget.selectedEmployees.remove(emp);
|
||||
} else {
|
||||
widget.selectedEmployees.add(emp);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
@ -130,6 +130,10 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
'expensesTypeId': expense.expensesType.id,
|
||||
'paymentModeId': expense.paymentMode.id,
|
||||
'paidById': expense.paidBy.id,
|
||||
// ==== Add these lines below ====
|
||||
'paidByFirstName': expense.paidBy.firstName,
|
||||
'paidByLastName': expense.paidBy.lastName,
|
||||
// =================================
|
||||
'attachments': expense.documents
|
||||
.map((doc) => {
|
||||
'url': doc.preSignedUrl,
|
||||
@ -146,7 +150,7 @@ class ExpenseDetailScreen extends StatelessWidget {
|
||||
await addCtrl.loadMasterData();
|
||||
addCtrl.populateFieldsForEdit(editData);
|
||||
|
||||
await showAddExpenseBottomSheet( isEdit: true,);
|
||||
await showAddExpenseBottomSheet(isEdit: true);
|
||||
|
||||
// Refresh expense details after editing
|
||||
await controller.fetchExpenseDetails();
|
||||
|
@ -7,6 +7,7 @@ import 'package:marco/helpers/widgets/my_spacing.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/expense/employee_selector_for_filter_bottom_sheet.dart';
|
||||
|
||||
class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
final ExpenseController expenseController;
|
||||
@ -18,9 +19,16 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
required this.scrollController,
|
||||
});
|
||||
|
||||
// FIX: create search adapter
|
||||
Future<List<EmployeeModel>> searchEmployeesForBottomSheet(
|
||||
String query) async {
|
||||
await expenseController
|
||||
.searchEmployees(query); // async method, returns void
|
||||
return expenseController.employeeSearchResults.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Obx rebuilds the widget when observable values from the controller change.
|
||||
return Obx(() {
|
||||
return BaseBottomSheet(
|
||||
title: 'Filter Expenses',
|
||||
@ -41,11 +49,11 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: () => expenseController.clearFilters(),
|
||||
child: const Text(
|
||||
child: MyText(
|
||||
"Reset Filter",
|
||||
style: TextStyle(
|
||||
style: MyTextStyle.labelMedium(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: 600,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -57,9 +65,9 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
MySpacing.height(16),
|
||||
_buildDateRangeFilter(context),
|
||||
MySpacing.height(16),
|
||||
_buildPaidByFilter(),
|
||||
_buildPaidByFilter(context),
|
||||
MySpacing.height(16),
|
||||
_buildCreatedByFilter(),
|
||||
_buildCreatedByFilter(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -67,7 +75,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
});
|
||||
}
|
||||
|
||||
/// Builds a generic field layout with a label and a child widget.
|
||||
Widget _buildField(String label, Widget child) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -79,7 +86,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracted widget builder for the Project filter.
|
||||
Widget _buildProjectFilter(BuildContext context) {
|
||||
return _buildField(
|
||||
"Project",
|
||||
@ -94,7 +100,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracted widget builder for the Expense Status filter.
|
||||
Widget _buildStatusFilter(BuildContext context) {
|
||||
return _buildField(
|
||||
"Expense Status",
|
||||
@ -117,123 +122,128 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracted widget builder for the Date Range filter.
|
||||
Widget _buildDateRangeFilter(BuildContext context) {
|
||||
return _buildField(
|
||||
"Date Filter",
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Obx(() {
|
||||
return SegmentedButton<String>(
|
||||
segments: expenseController.dateTypes
|
||||
.map(
|
||||
(type) => ButtonSegment(
|
||||
value: type,
|
||||
label: Text(
|
||||
type,
|
||||
style: MyTextStyle.bodySmall(
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
return _buildField(
|
||||
"Date Filter",
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Obx(() {
|
||||
return SegmentedButton<String>(
|
||||
segments: expenseController.dateTypes
|
||||
.map(
|
||||
(type) => ButtonSegment(
|
||||
value: type,
|
||||
label: MyText(
|
||||
type,
|
||||
style: MyTextStyle.bodySmall(
|
||||
fontWeight: 600,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
selected: {expenseController.selectedDateType.value},
|
||||
onSelectionChanged: (newSelection) {
|
||||
if (newSelection.isNotEmpty) {
|
||||
expenseController.selectedDateType.value = newSelection.first;
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
|
||||
padding: MaterialStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 6)),
|
||||
backgroundColor: MaterialStateProperty.resolveWith(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? Colors.indigo.shade100
|
||||
: Colors.grey.shade100,
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? Colors.indigo
|
||||
: Colors.black87,
|
||||
),
|
||||
shape: MaterialStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
)
|
||||
.toList(),
|
||||
selected: {expenseController.selectedDateType.value},
|
||||
onSelectionChanged: (newSelection) {
|
||||
if (newSelection.isNotEmpty) {
|
||||
expenseController.selectedDateType.value = newSelection.first;
|
||||
}
|
||||
},
|
||||
style: ButtonStyle(
|
||||
visualDensity:
|
||||
const VisualDensity(horizontal: -2, vertical: -2),
|
||||
padding: MaterialStateProperty.all(
|
||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
),
|
||||
),
|
||||
side: MaterialStateProperty.resolveWith(
|
||||
(states) => BorderSide(
|
||||
color: states.contains(MaterialState.selected)
|
||||
backgroundColor: MaterialStateProperty.resolveWith(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? Colors.indigo.shade100
|
||||
: Colors.grey.shade100,
|
||||
),
|
||||
foregroundColor: MaterialStateProperty.resolveWith(
|
||||
(states) => states.contains(MaterialState.selected)
|
||||
? Colors.indigo
|
||||
: Colors.grey.shade300,
|
||||
width: 1,
|
||||
: Colors.black87,
|
||||
),
|
||||
shape: MaterialStateProperty.all(
|
||||
RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
side: MaterialStateProperty.resolveWith(
|
||||
(states) => BorderSide(
|
||||
color: states.contains(MaterialState.selected)
|
||||
? Colors.indigo
|
||||
: Colors.grey.shade300,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
MySpacing.height(16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _dateButton(
|
||||
label: expenseController.startDate.value == null
|
||||
? 'Start Date'
|
||||
: DateTimeUtils.formatDate(
|
||||
expenseController.startDate.value!, 'dd MMM yyyy'),
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
expenseController.startDate,
|
||||
lastDate: expenseController.endDate.value,
|
||||
);
|
||||
}),
|
||||
MySpacing.height(16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _dateButton(
|
||||
label: expenseController.startDate.value == null
|
||||
? 'Start Date'
|
||||
: DateTimeUtils.formatDate(
|
||||
expenseController.startDate.value!, 'dd MMM yyyy'),
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
expenseController.startDate,
|
||||
lastDate: expenseController.endDate.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: _dateButton(
|
||||
label: expenseController.endDate.value == null
|
||||
? 'End Date'
|
||||
: DateTimeUtils.formatDate(
|
||||
expenseController.endDate.value!, 'dd MMM yyyy'),
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
expenseController.endDate,
|
||||
firstDate: expenseController.startDate.value,
|
||||
MySpacing.width(12),
|
||||
Expanded(
|
||||
child: _dateButton(
|
||||
label: expenseController.endDate.value == null
|
||||
? 'End Date'
|
||||
: DateTimeUtils.formatDate(
|
||||
expenseController.endDate.value!, 'dd MMM yyyy'),
|
||||
onTap: () => _selectDate(
|
||||
context,
|
||||
expenseController.endDate,
|
||||
firstDate: expenseController.startDate.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/// Extracted widget builder for the "Paid By" employee filter.
|
||||
Widget _buildPaidByFilter() {
|
||||
Widget _buildPaidByFilter(BuildContext context) {
|
||||
return _buildField(
|
||||
"Paid By",
|
||||
_employeeSelector(
|
||||
selectedEmployees: expenseController.selectedPaidByEmployees),
|
||||
context: context,
|
||||
selectedEmployees: expenseController.selectedPaidByEmployees,
|
||||
searchEmployees: searchEmployeesForBottomSheet, // FIXED
|
||||
title: 'Search Paid By',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracted widget builder for the "Created By" employee filter.
|
||||
Widget _buildCreatedByFilter() {
|
||||
Widget _buildCreatedByFilter(BuildContext context) {
|
||||
return _buildField(
|
||||
"Created By",
|
||||
_employeeSelector(
|
||||
selectedEmployees: expenseController.selectedCreatedByEmployees),
|
||||
context: context,
|
||||
selectedEmployees: expenseController.selectedCreatedByEmployees,
|
||||
searchEmployees: searchEmployeesForBottomSheet, // FIXED
|
||||
title: 'Search Created By',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper method to show a date picker and update the state.
|
||||
Future<void> _selectDate(
|
||||
BuildContext context,
|
||||
Rx<DateTime?> dateNotifier, {
|
||||
@ -251,7 +261,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable popup selector widget.
|
||||
Widget _popupSelector(
|
||||
BuildContext context, {
|
||||
required String currentValue,
|
||||
@ -264,7 +273,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
itemBuilder: (context) => items
|
||||
.map((e) => PopupMenuItem<String>(
|
||||
value: e,
|
||||
child: Text(e),
|
||||
child: MyText(e),
|
||||
))
|
||||
.toList(),
|
||||
child: Container(
|
||||
@ -278,7 +287,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: MyText(
|
||||
currentValue,
|
||||
style: const TextStyle(color: Colors.black87),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@ -291,7 +300,6 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reusable date button widget.
|
||||
Widget _dateButton({required String label, required VoidCallback onTap}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
@ -307,7 +315,7 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
const Icon(Icons.calendar_today, size: 16, color: Colors.grey),
|
||||
MySpacing.width(8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: MyText(
|
||||
label,
|
||||
style: MyTextStyle.bodyMedium(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@ -319,9 +327,36 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Reusable employee selector with Autocomplete.
|
||||
Widget _employeeSelector({required RxList<EmployeeModel> selectedEmployees}) {
|
||||
final textController = TextEditingController();
|
||||
Future<void> _showEmployeeSelectorBottomSheet({
|
||||
required BuildContext context,
|
||||
required RxList<EmployeeModel> selectedEmployees,
|
||||
required Future<List<EmployeeModel>> Function(String) searchEmployees,
|
||||
String title = 'Select Employee',
|
||||
}) async {
|
||||
final List<EmployeeModel>? result =
|
||||
await showModalBottomSheet<List<EmployeeModel>>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (context) => EmployeeSelectorBottomSheet(
|
||||
selectedEmployees: selectedEmployees,
|
||||
searchEmployees: searchEmployees,
|
||||
title: title,
|
||||
),
|
||||
);
|
||||
if (result != null) {
|
||||
selectedEmployees.assignAll(result);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _employeeSelector({
|
||||
required BuildContext context,
|
||||
required RxList<EmployeeModel> selectedEmployees,
|
||||
required Future<List<EmployeeModel>> Function(String) searchEmployees,
|
||||
String title = 'Search Employee',
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@ -331,102 +366,39 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
|
||||
}
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 0,
|
||||
children: selectedEmployees
|
||||
.map((emp) => Chip(
|
||||
label: Text(emp.name),
|
||||
label: MyText(emp.name),
|
||||
onDeleted: () => selectedEmployees.remove(emp),
|
||||
deleteIcon: const Icon(Icons.close, size: 18),
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
padding: const EdgeInsets.all(8),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}),
|
||||
MySpacing.height(8),
|
||||
Autocomplete<EmployeeModel>(
|
||||
optionsBuilder: (TextEditingValue textEditingValue) {
|
||||
if (textEditingValue.text.isEmpty) {
|
||||
return const Iterable<EmployeeModel>.empty();
|
||||
}
|
||||
return expenseController.allEmployees.where((emp) {
|
||||
final isNotSelected = !selectedEmployees.contains(emp);
|
||||
final matchesQuery = emp.name
|
||||
.toLowerCase()
|
||||
.contains(textEditingValue.text.toLowerCase());
|
||||
return isNotSelected && matchesQuery;
|
||||
});
|
||||
},
|
||||
displayStringForOption: (EmployeeModel emp) => emp.name,
|
||||
onSelected: (EmployeeModel emp) {
|
||||
if (!selectedEmployees.contains(emp)) {
|
||||
selectedEmployees.add(emp);
|
||||
}
|
||||
textController.clear();
|
||||
},
|
||||
fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) {
|
||||
// Assign the local controller to the one from the builder
|
||||
// to allow clearing it on selection.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (textController != controller) {
|
||||
// This is a workaround to sync controllers
|
||||
}
|
||||
});
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: _inputDecoration("Search Employee"),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
elevation: 4.0,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final emp = options.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(emp.name),
|
||||
onTap: () => onSelected(emp),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
GestureDetector(
|
||||
onTap: () => _showEmployeeSelectorBottomSheet(
|
||||
context: context,
|
||||
selectedEmployees: selectedEmployees,
|
||||
searchEmployees: searchEmployees,
|
||||
title: title,
|
||||
),
|
||||
child: Container(
|
||||
padding: MySpacing.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.search, color: Colors.grey),
|
||||
MySpacing.width(8),
|
||||
Expanded(child: MyText(title)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Centralized decoration for text fields.
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user