Vaibhav_Feature-#768 #59

Closed
vaibhav.surve wants to merge 74 commits from Vaibhav_Feature-#768 into Feature_Expense
7 changed files with 525 additions and 48 deletions
Showing only changes of commit 586d18565f - Show all commits

View File

@ -119,7 +119,7 @@ class AddExpenseController extends GetxController {
} catch (e) {
Get.snackbar("Error", "Failed to fetch master data: $e");
}
}
}
// === Fetch Current Location ===
Future<void> fetchCurrentLocation() async {

View File

@ -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<ExpenseModel> expenses = <ExpenseModel>[].obs;
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
final RxList<String> globalProjects = <String>[].obs;
final RxMap<String, String> projectsMap = <String, String>{}.obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
/// Fetch all expenses from API
Future<void> 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<void> loadInitialMasterData() async {
await fetchGlobalProjects();
await fetchMasterData();
}
/// Fetch expenses with filters and pagination (called explicitly when needed)
Future<void> fetchExpenses({
List<String>? projectIds,
List<String>? statusIds,
List<String>? createdByIds,
List<String>? paidByIds,
DateTime? startDate,
DateTime? endDate,
int pageSize = 20,
int pageNumber = 1,
}) async {
isLoading.value = true;
errorMessage.value = '';
_pageSize = pageSize;
_pageNumber = pageNumber;
final Map<String, dynamic> 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<dynamic>) to List<ExpenseModel>
final List<ExpenseModel> parsed = List<ExpenseModel>.from(
result.map((e) => ExpenseModel.fromJson(e)));
final List<dynamic> rawList = result['expenses'] ?? [];
final parsed = rawList
.map((e) => ExpenseModel.fromJson(e as Map<String, dynamic>))
.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<void> 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<void> fetchGlobalProjects() async {
try {
final response = await ApiService.getGlobalProjects();
if (response != null) {
final names = <String>[];
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<void> 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<bool> 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.";

View File

@ -286,16 +286,52 @@ class ApiService {
return false;
}
static Future<List<dynamic>?> getExpenseListApi() async {
const endpoint = ApiEndpoints.getExpenseList;
static Future<Map<String, dynamic>?> getExpenseListApi({
String? filter,
int pageSize = 20,
int pageNumber = 1,
}) async {
// Build the endpoint with query parameters
String endpoint = ApiEndpoints.getExpenseList;
final queryParams = <String, String>{
'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<String, dynamic>) {
final dataObj = parsed['data'] as Map<String, dynamic>;
if (dataObj['data'] is List) {
return {
'currentPage': dataObj['currentPage'] ?? 1,
'totalPages': dataObj['totalPages'] ?? 1,
'totalEntites': dataObj['totalEntites'] ?? 0,
'expenses': List<dynamic>.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);

View File

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

View File

@ -1,7 +1,11 @@
import 'dart:convert';
List<ExpenseModel> expenseModelFromJson(String str) => List<ExpenseModel>.from(
json.decode(str).map((x) => ExpenseModel.fromJson(x)));
List<ExpenseModel> expenseModelFromJson(String str) {
final jsonData = json.decode(str);
return List<ExpenseModel>.from(
jsonData["data"]["data"].map((x) => ExpenseModel.fromJson(x))
);
}
String expenseModelToJson(List<ExpenseModel> data) =>
json.encode(List<dynamic>.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<String, dynamic> json) => Status(
id: json["id"],
name: json["name"],
displayName: json["displayName"],
description: json["description"],
color: json["color"],
isSystem: json["isSystem"],
);
Map<String, dynamic> toJson() => {
"id": id,
"name": name,
"displayName": displayName,
"description": description,
"color": color,
"isSystem": isSystem,
};
}

View File

@ -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<EmployeeModel> selectedPaidByEmployees;
final RxList<EmployeeModel> selectedCreatedByEmployees;
ExpenseFilterBottomSheet({
super.key,
required this.expenseController,
required this.selectedPaidByEmployees,
required this.selectedCreatedByEmployees,
});
final RxString selectedProject = ''.obs;
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(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<String>(
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<EmployeeModel> 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<EmployeeModel>(
optionsBuilder: (TextEditingValue textEditingValue) {
if (textEditingValue.text.isEmpty) {
return const Iterable<EmployeeModel>.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),
],
),
),
);
}
}

View File

@ -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<ExpenseMainScreen> {
final RxString searchQuery = ''.obs;
final ProjectController projectController = Get.find<ProjectController>();
final ExpenseController expenseController = Get.put(ExpenseController());
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
@override
void initState() {
@ -45,7 +50,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
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<ExpenseMainScreen> {
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,
),
],
),