From 0e1b6e2a8c3a548e6310267ed547bfbad4433e4c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Tue, 22 Jul 2025 13:01:07 +0530 Subject: [PATCH] feat(expense): implement expense filtering functionality with UI integration --- .../expense/add_expense_controller.dart | 2 +- .../expense/expense_screen_controller.dart | 148 ++++++++- lib/helpers/services/api_service.dart | 46 ++- lib/helpers/utils/date_time_utils.dart | 14 +- lib/model/expense/expense_list_model.dart | 16 +- .../expense/expense_filter_bottom_sheet.dart | 304 ++++++++++++++++++ lib/view/expense/expense_screen.dart | 43 +-- 7 files changed, 525 insertions(+), 48 deletions(-) create mode 100644 lib/view/expense/expense_filter_bottom_sheet.dart diff --git a/lib/controller/expense/add_expense_controller.dart b/lib/controller/expense/add_expense_controller.dart index 9073035..32c0fa2 100644 --- a/lib/controller/expense/add_expense_controller.dart +++ b/lib/controller/expense/add_expense_controller.dart @@ -119,7 +119,7 @@ class AddExpenseController extends GetxController { } catch (e) { Get.snackbar("Error", "Failed to fetch master data: $e"); } - } + } // === Fetch Current Location === Future fetchCurrentLocation() async { diff --git a/lib/controller/expense/expense_screen_controller.dart b/lib/controller/expense/expense_screen_controller.dart index 9359c21..39dd724 100644 --- a/lib/controller/expense/expense_screen_controller.dart +++ b/lib/controller/expense/expense_screen_controller.dart @@ -1,28 +1,86 @@ +import 'dart:convert'; import 'package:get/get.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/model/expense/expense_list_model.dart'; +import 'package:marco/model/expense/payment_types_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; +import 'package:marco/model/expense/expense_status_model.dart'; +import 'package:marco/model/employee_model.dart'; class ExpenseController extends GetxController { final RxList expenses = [].obs; final RxBool isLoading = false.obs; final RxString errorMessage = ''.obs; + final RxList expenseTypes = [].obs; + final RxList paymentModes = [].obs; + final RxList expenseStatuses = [].obs; + final RxList globalProjects = [].obs; + final RxMap projectsMap = {}.obs; + RxList allEmployees = [].obs; - /// Fetch all expenses from API - Future fetchExpenses() async { + int _pageSize = 20; + int _pageNumber = 1; + + @override + void onInit() { + super.onInit(); + loadInitialMasterData(); + fetchAllEmployees(); + } + + /// Load projects, expense types, statuses, and payment modes on controller init + Future loadInitialMasterData() async { + await fetchGlobalProjects(); + await fetchMasterData(); + } + + /// Fetch expenses with filters and pagination (called explicitly when needed) + Future fetchExpenses({ + List? projectIds, + List? statusIds, + List? createdByIds, + List? paidByIds, + DateTime? startDate, + DateTime? endDate, + int pageSize = 20, + int pageNumber = 1, + }) async { isLoading.value = true; errorMessage.value = ''; + _pageSize = pageSize; + _pageNumber = pageNumber; + + final Map filterMap = { + "projectIds": projectIds ?? [], + "statusIds": statusIds ?? [], + "createdByIds": createdByIds ?? [], + "paidByIds": paidByIds ?? [], + "startDate": startDate?.toIso8601String(), + "endDate": endDate?.toIso8601String(), + }; + try { - final result = await ApiService.getExpenseListApi(); + logSafe("Fetching expenses with filter: ${jsonEncode(filterMap)}"); + + final result = await ApiService.getExpenseListApi( + filter: jsonEncode(filterMap), + pageSize: _pageSize, + pageNumber: _pageNumber, + ); if (result != null) { try { - // Convert the raw result (List) to List - final List parsed = List.from( - result.map((e) => ExpenseModel.fromJson(e))); + final List rawList = result['expenses'] ?? []; + final parsed = rawList + .map((e) => ExpenseModel.fromJson(e as Map)) + .toList(); + expenses.assignAll(parsed); logSafe("Expenses loaded: ${parsed.length}"); + logSafe( + "Pagination Info: Page ${result['currentPage']} of ${result['totalPages']} | Total: ${result['totalEntites']}"); } catch (e) { errorMessage.value = 'Failed to parse expenses: $e'; logSafe("Parse error in fetchExpenses: $e", level: LogLevel.error); @@ -40,6 +98,82 @@ class ExpenseController extends GetxController { } } + /// Fetch master data: expense types, payment modes, and expense status + Future fetchMasterData() async { + 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 expenseStatusData = await ApiService.getMasterExpenseStatus(); + if (expenseStatusData is List) { + expenseStatuses.value = expenseStatusData + .map((e) => ExpenseStatusModel.fromJson(e)) + .toList(); + } + } catch (e) { + Get.snackbar("Error", "Failed to fetch master data: $e"); + } + } + + /// Fetch list of all global projects + Future fetchGlobalProjects() async { + try { + final response = await ApiService.getGlobalProjects(); + if (response != null) { + final names = []; + for (var item in response) { + final name = item['name']?.toString().trim(); + final id = item['id']?.toString().trim(); + if (name != null && id != null && name.isNotEmpty) { + projectsMap[name] = id; + names.add(name); + } + } + globalProjects.assignAll(names); + logSafe("Fetched ${names.length} global projects"); + } + } catch (e) { + logSafe("Failed to fetch global projects: $e", level: LogLevel.error); + } + } + + /// Fetch all employees for Manage Bucket usage + Future fetchAllEmployees() async { + isLoading.value = true; + + try { + final response = await ApiService.getAllEmployees(); + if (response != null && response.isNotEmpty) { + allEmployees + .assignAll(response.map((json) => EmployeeModel.fromJson(json))); + logSafe( + "All Employees fetched for Manage Bucket: ${allEmployees.length}", + level: LogLevel.info, + ); + } else { + allEmployees.clear(); + logSafe("No employees found for Manage Bucket.", + level: LogLevel.warning); + } + } catch (e) { + allEmployees.clear(); + logSafe("Error fetching employees in Manage Bucket", + level: LogLevel.error, error: e); + } + + isLoading.value = false; + update(); + } + /// Update expense status and refresh the list Future updateExpenseStatus(String expenseId, String statusId) async { isLoading.value = true; @@ -54,7 +188,7 @@ class ExpenseController extends GetxController { if (success) { logSafe("Expense status updated successfully."); - await fetchExpenses(); + await fetchExpenses(); return true; } else { errorMessage.value = "Failed to update expense status."; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index c8428b0..cb2a84b 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -286,16 +286,52 @@ class ApiService { return false; } - static Future?> getExpenseListApi() async { - const endpoint = ApiEndpoints.getExpenseList; + static Future?> getExpenseListApi({ + String? filter, + int pageSize = 20, + int pageNumber = 1, + }) async { + // Build the endpoint with query parameters + String endpoint = ApiEndpoints.getExpenseList; + final queryParams = { + 'pageSize': pageSize.toString(), + 'pageNumber': pageNumber.toString(), + }; - logSafe("Fetching expense list..."); + if (filter != null && filter.isNotEmpty) { + queryParams['filter'] = filter; + } + + // Build the full URI + final uri = Uri.parse(endpoint).replace(queryParameters: queryParams); + logSafe("Fetching expense list with URI: $uri"); try { - final response = await _getRequest(endpoint); + final response = await _getRequest(uri.toString()); if (response == null) return null; - return _parseResponse(response, label: 'Expense List'); + final parsed = _parseResponseForAllData(response, label: 'Expense List'); + + if (parsed != null && parsed['data'] is Map) { + final dataObj = parsed['data'] as Map; + + if (dataObj['data'] is List) { + return { + 'currentPage': dataObj['currentPage'] ?? 1, + 'totalPages': dataObj['totalPages'] ?? 1, + 'totalEntites': dataObj['totalEntites'] ?? 0, + 'expenses': List.from(dataObj['data']), + }; + } else { + logSafe("Expense list 'data' is not a list: $dataObj", + level: LogLevel.error); + return null; + } + } else { + logSafe("Unexpected response structure: $parsed", + level: LogLevel.error); + return null; + } } catch (e, stack) { logSafe("Exception during getExpenseListApi: $e", level: LogLevel.error); logSafe("StackTrace: $stack", level: LogLevel.debug); diff --git a/lib/helpers/utils/date_time_utils.dart b/lib/helpers/utils/date_time_utils.dart index 2c6a732..1dca3da 100644 --- a/lib/helpers/utils/date_time_utils.dart +++ b/lib/helpers/utils/date_time_utils.dart @@ -1,7 +1,8 @@ import 'package:intl/intl.dart'; -import 'package:marco/helpers/services/app_logger.dart'; +import 'package:marco/helpers/services/app_logger.dart'; class DateTimeUtils { + /// Converts a UTC datetime string to local time and formats it. static String convertUtcToLocal(String utcTimeString, {String format = 'dd-MM-yyyy'}) { try { logSafe('convertUtcToLocal: input="$utcTimeString", format="$format"'); @@ -32,6 +33,17 @@ class DateTimeUtils { } } + /// Public utility for formatting any DateTime. + static String formatDate(DateTime date, String format) { + try { + return DateFormat(format).format(date); + } catch (e, stackTrace) { + logSafe('formatDate failed: $e', error: e, stackTrace: stackTrace); + return 'Invalid Date'; + } + } + + /// Internal formatter with default format. static String _formatDateTime(DateTime dateTime, {String format = 'dd-MM-yyyy'}) { return DateFormat(format).format(dateTime); } diff --git a/lib/model/expense/expense_list_model.dart b/lib/model/expense/expense_list_model.dart index 451ba72..b23468c 100644 --- a/lib/model/expense/expense_list_model.dart +++ b/lib/model/expense/expense_list_model.dart @@ -1,7 +1,11 @@ import 'dart:convert'; -List expenseModelFromJson(String str) => List.from( - json.decode(str).map((x) => ExpenseModel.fromJson(x))); +List expenseModelFromJson(String str) { + final jsonData = json.decode(str); + return List.from( + jsonData["data"]["data"].map((x) => ExpenseModel.fromJson(x)) + ); +} String expenseModelToJson(List data) => json.encode(List.from(data.map((x) => x.toJson()))); @@ -242,27 +246,35 @@ class CreatedBy { class Status { final String id; final String name; + final String displayName; final String description; + final String color; final bool isSystem; Status({ required this.id, required this.name, + required this.displayName, required this.description, + required this.color, required this.isSystem, }); factory Status.fromJson(Map json) => Status( id: json["id"], name: json["name"], + displayName: json["displayName"], description: json["description"], + color: json["color"], isSystem: json["isSystem"], ); Map toJson() => { "id": id, "name": name, + "displayName": displayName, "description": description, + "color": color, "isSystem": isSystem, }; } diff --git a/lib/view/expense/expense_filter_bottom_sheet.dart b/lib/view/expense/expense_filter_bottom_sheet.dart new file mode 100644 index 0000000..0165940 --- /dev/null +++ b/lib/view/expense/expense_filter_bottom_sheet.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:marco/controller/expense/expense_screen_controller.dart'; +import 'package:marco/helpers/utils/date_time_utils.dart'; +import 'package:marco/helpers/widgets/my_text.dart'; +import 'package:marco/model/employee_model.dart'; + +class ExpenseFilterBottomSheet extends StatelessWidget { + final ExpenseController expenseController; + final RxList selectedPaidByEmployees; + final RxList selectedCreatedByEmployees; + + ExpenseFilterBottomSheet({ + super.key, + required this.expenseController, + required this.selectedPaidByEmployees, + required this.selectedCreatedByEmployees, + }); + + final RxString selectedProject = ''.obs; + final Rx startDate = Rx(null); + final Rx endDate = Rx(null); + final RxString selectedEmployee = ''.obs; + + @override + Widget build(BuildContext context) { + return Obx(() { + return Container( + padding: const EdgeInsets.all(16), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + MyText.titleLarge('Filter Expenses', fontWeight: 700), + const SizedBox(height: 16), + + /// Project Filter + MyText.bodyMedium('Project', fontWeight: 600), + const SizedBox(height: 6), + DropdownButtonFormField( + value: selectedProject.value.isEmpty + ? null + : selectedProject.value, + items: expenseController.globalProjects + .map((proj) => DropdownMenuItem( + value: proj, + child: Text(proj), + )) + .toList(), + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + ), + hint: const Text('Select Project'), + onChanged: (value) { + selectedProject.value = value ?? ''; + }, + ), + + const SizedBox(height: 16), + + /// Date Range Filter + MyText.bodyMedium('Date Range', fontWeight: 600), + const SizedBox(height: 6), + Row( + children: [ + Expanded( + child: _DatePickerField( + label: startDate.value == null + ? 'Start Date' + : DateTimeUtils.formatDate( + startDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: startDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) startDate.value = picked; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _DatePickerField( + label: endDate.value == null + ? 'End Date' + : DateTimeUtils.formatDate( + endDate.value!, 'dd MMM yyyy'), + onTap: () async { + DateTime? picked = await showDatePicker( + context: context, + initialDate: endDate.value ?? DateTime.now(), + firstDate: DateTime(2020), + lastDate: + DateTime.now().add(const Duration(days: 365)), + ); + if (picked != null) endDate.value = picked; + }, + ), + ), + ], + ), + + const SizedBox(height: 16), + + /// Paid By Filter + _employeeFilterSection( + title: 'Paid By', + selectedEmployees: selectedPaidByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + + /// Created By Filter + _employeeFilterSection( + title: 'Created By', + selectedEmployees: selectedCreatedByEmployees, + expenseController: expenseController, + ), + const SizedBox(height: 24), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey.shade300, + foregroundColor: Colors.black, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () => Get.back(), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onPressed: () { + expenseController.fetchExpenses( + projectIds: selectedProject.value.isEmpty + ? null + : [ + expenseController + .projectsMap[selectedProject.value]! + ], + paidByIds: selectedPaidByEmployees.isEmpty + ? null + : selectedPaidByEmployees.map((e) => e.id).toList(), + createdByIds: selectedCreatedByEmployees.isEmpty + ? null + : selectedCreatedByEmployees + .map((e) => e.id) + .toList(), + startDate: startDate.value, + endDate: endDate.value, + ); + Get.back(); + }, + child: const Text('Apply'), + ), + ], + ), + ], + ), + ), + ); + }); + } + + /// Employee Filter Section + Widget _employeeFilterSection({ + required String title, + required RxList selectedEmployees, + required ExpenseController expenseController, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium(title, fontWeight: 600), + const SizedBox(height: 6), + Obx(() { + return Wrap( + spacing: 6, + runSpacing: -8, + children: selectedEmployees.map((emp) { + return Chip( + label: Text(emp.name), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () => selectedEmployees.remove(emp), + backgroundColor: Colors.grey.shade200, + ); + }).toList(), + ); + }), + const SizedBox(height: 6), + Autocomplete( + optionsBuilder: (TextEditingValue textEditingValue) { + if (textEditingValue.text.isEmpty) { + return const Iterable.empty(); + } + return expenseController.allEmployees.where((EmployeeModel emp) { + return emp.name + .toLowerCase() + .contains(textEditingValue.text.toLowerCase()); + }); + }, + displayStringForOption: (EmployeeModel emp) => emp.name, + onSelected: (EmployeeModel emp) { + if (!selectedEmployees.contains(emp)) { + selectedEmployees.add(emp); + } + }, + fieldViewBuilder: (context, controller, focusNode, onFieldSubmitted) { + return TextField( + controller: controller, + focusNode: focusNode, + decoration: InputDecoration( + 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) { + return Align( + alignment: Alignment.topLeft, + child: Material( + color: Colors.white, + elevation: 4.0, + child: SizedBox( + height: 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), + ); + }, + ), + ), + ), + ); + }, + ), + ], + ); + } +} + +/// Date Picker Field +class _DatePickerField extends StatelessWidget { + final String label; + final VoidCallback onTap; + + const _DatePickerField({ + required this.label, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.black87)), + const Icon(Icons.calendar_today, size: 16, color: Colors.grey), + ], + ), + ), + ); + } +} diff --git a/lib/view/expense/expense_screen.dart b/lib/view/expense/expense_screen.dart index 40460eb..874480a 100644 --- a/lib/view/expense/expense_screen.dart +++ b/lib/view/expense/expense_screen.dart @@ -8,6 +8,8 @@ import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/model/expense/add_expense_bottom_sheet.dart'; +import 'package:marco/model/employee_model.dart'; +import 'package:marco/view/expense/expense_filter_bottom_sheet.dart'; class ExpenseMainScreen extends StatefulWidget { const ExpenseMainScreen({super.key}); @@ -22,6 +24,9 @@ class _ExpenseMainScreenState extends State { final RxString searchQuery = ''.obs; final ProjectController projectController = Get.find(); final ExpenseController expenseController = Get.put(ExpenseController()); + final RxList selectedPaidByEmployees = [].obs; + final RxList selectedCreatedByEmployees = + [].obs; @override void initState() { @@ -45,7 +50,7 @@ class _ExpenseMainScreenState extends State { searchController: searchController, onChanged: (value) => searchQuery.value = value, onFilterTap: _openFilterBottomSheet, - onRefreshTap: _refreshExpenses, + onRefreshTap: _refreshExpenses, ), _ToggleButtons(isHistoryView: isHistoryView), Expanded( @@ -111,36 +116,10 @@ class _ExpenseMainScreenState extends State { void _openFilterBottomSheet() { Get.bottomSheet( - Container( - padding: const EdgeInsets.all(16), - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Wrap( - runSpacing: 10, - children: [ - MyText.bodyLarge( - 'Filter Expenses', - fontWeight: 700, - ), - ListTile( - leading: const Icon(Icons.date_range), - title: MyText.bodyMedium('Date Range'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.work_outline), - title: MyText.bodyMedium('Project'), - onTap: () {}, - ), - ListTile( - leading: const Icon(Icons.check_circle_outline), - title: MyText.bodyMedium('Status'), - onTap: () {}, - ), - ], - ), + ExpenseFilterBottomSheet( + expenseController: expenseController, + selectedPaidByEmployees: selectedPaidByEmployees, + selectedCreatedByEmployees: selectedCreatedByEmployees, ), ); } @@ -282,7 +261,7 @@ class _SearchAndFilter extends StatelessWidget { MySpacing.width(8), IconButton( icon: const Icon(Icons.tune, color: Colors.black), - onPressed: null, // Disabled as per request + onPressed: onFilterTap, ), ], ),