Vaibhav_Feature-#768 #59

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

View File

@ -0,0 +1,70 @@
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';
class ExpenseDetailController extends GetxController {
final Rx<ExpenseModel?> expense = Rx<ExpenseModel?>(null);
final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs;
/// Fetch expense details by ID
Future<void> fetchExpenseDetails(String expenseId) async {
isLoading.value = true;
errorMessage.value = '';
try {
logSafe("Fetching expense details for ID: $expenseId");
final result = await ApiService.getExpenseDetailsApi(expenseId: expenseId);
if (result != null) {
try {
expense.value = ExpenseModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}");
} catch (e) {
errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e",
level: LogLevel.error);
}
} else {
errorMessage.value = 'Failed to fetch expense details from server.';
logSafe("fetchExpenseDetails failed: null response",
level: LogLevel.error);
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in fetchExpenseDetails: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
} finally {
isLoading.value = false;
}
}
/// Update status for this specific expense
Future<bool> updateExpenseStatus(String expenseId, String statusId) async {
isLoading.value = true;
errorMessage.value = '';
try {
logSafe("Updating status for expense: $expenseId -> $statusId");
final success = await ApiService.updateExpenseStatusApi(
expenseId: expenseId,
statusId: statusId,
);
if (success) {
logSafe("Expense status updated successfully.");
await fetchExpenseDetails(expenseId); // Refresh details
return true;
} else {
errorMessage.value = "Failed to update expense status.";
return false;
}
} catch (e, stack) {
errorMessage.value = 'An unexpected error occurred.';
logSafe("Exception in updateExpenseStatus: $e", level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
return false;
} finally {
isLoading.value = false;
}
}
}

View File

@ -12,6 +12,8 @@ class ExpenseController extends GetxController {
final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs; final RxList<ExpenseModel> expenses = <ExpenseModel>[].obs;
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
final RxString errorMessage = ''.obs; final RxString errorMessage = ''.obs;
// Master data
final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs; final RxList<ExpenseTypeModel> expenseTypes = <ExpenseTypeModel>[].obs;
final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs; final RxList<PaymentModeModel> paymentModes = <PaymentModeModel>[].obs;
final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs; final RxList<ExpenseStatusModel> expenseStatuses = <ExpenseStatusModel>[].obs;
@ -19,6 +21,15 @@ class ExpenseController extends GetxController {
final RxMap<String, String> projectsMap = <String, String>{}.obs; final RxMap<String, String> projectsMap = <String, String>{}.obs;
RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> allEmployees = <EmployeeModel>[].obs;
// Persistent Filter States
final RxString selectedProject = ''.obs;
final RxString selectedStatus = ''.obs;
final Rx<DateTime?> startDate = Rx<DateTime?>(null);
final Rx<DateTime?> endDate = Rx<DateTime?>(null);
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
int _pageSize = 20; int _pageSize = 20;
int _pageNumber = 1; int _pageNumber = 1;
@ -29,13 +40,22 @@ class ExpenseController extends GetxController {
fetchAllEmployees(); fetchAllEmployees();
} }
/// Load projects, expense types, statuses, and payment modes on controller init bool get isFilterApplied {
return selectedProject.value.isNotEmpty ||
selectedStatus.value.isNotEmpty ||
startDate.value != null ||
endDate.value != null ||
selectedPaidByEmployees.isNotEmpty ||
selectedCreatedByEmployees.isNotEmpty;
}
/// Load master data
Future<void> loadInitialMasterData() async { Future<void> loadInitialMasterData() async {
await fetchGlobalProjects(); await fetchGlobalProjects();
await fetchMasterData(); await fetchMasterData();
} }
/// Fetch expenses with filters and pagination (called explicitly when needed) /// Fetch expenses using filters
Future<void> fetchExpenses({ Future<void> fetchExpenses({
List<String>? projectIds, List<String>? projectIds,
List<String>? statusIds, List<String>? statusIds,
@ -53,12 +73,18 @@ class ExpenseController extends GetxController {
_pageNumber = pageNumber; _pageNumber = pageNumber;
final Map<String, dynamic> filterMap = { final Map<String, dynamic> filterMap = {
"projectIds": projectIds ?? [], "projectIds": projectIds ??
"statusIds": statusIds ?? [], (selectedProject.value.isEmpty
"createdByIds": createdByIds ?? [], ? []
"paidByIds": paidByIds ?? [], : [projectsMap[selectedProject.value] ?? '']),
"startDate": startDate?.toIso8601String(), "statusIds": statusIds ??
"endDate": endDate?.toIso8601String(), (selectedStatus.value.isEmpty ? [] : [selectedStatus.value]),
"createdByIds":
createdByIds ?? selectedCreatedByEmployees.map((e) => e.id).toList(),
"paidByIds":
paidByIds ?? selectedPaidByEmployees.map((e) => e.id).toList(),
"startDate": (startDate ?? this.startDate.value)?.toIso8601String(),
"endDate": (endDate ?? this.endDate.value)?.toIso8601String(),
}; };
try { try {
@ -95,6 +121,16 @@ class ExpenseController extends GetxController {
} }
} }
/// Clear all filters
void clearFilters() {
selectedProject.value = '';
selectedStatus.value = '';
startDate.value = null;
endDate.value = null;
selectedPaidByEmployees.clear();
selectedCreatedByEmployees.clear();
}
/// Fetch master data: expense types, payment modes, and expense status /// Fetch master data: expense types, payment modes, and expense status
Future<void> fetchMasterData() async { Future<void> fetchMasterData() async {
try { try {
@ -121,7 +157,7 @@ class ExpenseController extends GetxController {
} }
} }
/// Fetch list of all global projects /// Fetch global projects
Future<void> fetchGlobalProjects() async { Future<void> fetchGlobalProjects() async {
try { try {
final response = await ApiService.getGlobalProjects(); final response = await ApiService.getGlobalProjects();
@ -143,10 +179,9 @@ class ExpenseController extends GetxController {
} }
} }
/// Fetch all employees for Manage Bucket usage /// Fetch all employees
Future<void> fetchAllEmployees() async { Future<void> fetchAllEmployees() async {
isLoading.value = true; isLoading.value = true;
try { try {
final response = await ApiService.getAllEmployees(); final response = await ApiService.getAllEmployees();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
@ -166,23 +201,20 @@ class ExpenseController extends GetxController {
logSafe("Error fetching employees in Manage Bucket", logSafe("Error fetching employees in Manage Bucket",
level: LogLevel.error, error: e); level: LogLevel.error, error: e);
} }
isLoading.value = false; isLoading.value = false;
update(); update();
} }
/// Update expense status and refresh the list /// Update expense status
Future<bool> updateExpenseStatus(String expenseId, String statusId) async { Future<bool> updateExpenseStatus(String expenseId, 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 fetchExpenses(); await fetchExpenses();

View File

@ -241,6 +241,52 @@ class ApiService {
// === Expense APIs === // // === Expense APIs === //
/// Get Expense Details API
static Future<Map<String, dynamic>?> getExpenseDetailsApi({
required String expenseId,
}) async {
final endpoint = "${ApiEndpoints.getExpenseDetails}/$expenseId";
logSafe("Fetching expense details for ID: $expenseId");
try {
final response = await _getRequest(endpoint);
if (response == null) {
logSafe("Expense details request failed: null response",
level: LogLevel.error);
return null;
}
final body = response.body.trim();
if (body.isEmpty) {
logSafe("Expense details response body is empty",
level: LogLevel.error);
return null;
}
final jsonResponse = jsonDecode(body);
if (jsonResponse is Map<String, dynamic>) {
if (jsonResponse['success'] == true) {
logSafe("Expense details fetched successfully");
return jsonResponse['data']; // Return the expense details object
} else {
logSafe(
"Failed to fetch expense details: ${jsonResponse['message'] ?? 'Unknown error'}",
level: LogLevel.warning,
);
}
} else {
logSafe("Unexpected response structure: $jsonResponse",
level: LogLevel.error);
}
} catch (e, stack) {
logSafe("Exception during getExpenseDetailsApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Update Expense Status API /// Update Expense Status API
static Future<bool> updateExpenseStatusApi({ static Future<bool> updateExpenseStatusApi({
required String expenseId, required String expenseId,

View File

@ -4,36 +4,34 @@ import 'package:marco/helpers/widgets/my_spacing.dart';
import 'package:marco/helpers/utils/my_shadow.dart'; import 'package:marco/helpers/utils/my_shadow.dart';
class SkeletonLoaders { class SkeletonLoaders {
static Widget buildLoadingSkeleton() {
static Widget buildLoadingSkeleton() { return SizedBox(
return SizedBox( height: 360,
height: 360, child: Column(
child: Column( children: List.generate(5, (index) {
children: List.generate(5, (index) { return Padding(
return Padding( padding: const EdgeInsets.symmetric(vertical: 6),
padding: const EdgeInsets.symmetric(vertical: 6), child: SingleChildScrollView(
child: SingleChildScrollView( scrollDirection: Axis.horizontal,
scrollDirection: Axis.horizontal, child: Row(
child: Row( children: List.generate(6, (i) {
children: List.generate(6, (i) { return Container(
return Container( margin: const EdgeInsets.symmetric(horizontal: 4),
margin: const EdgeInsets.symmetric(horizontal: 4), width: 48,
width: 48, height: 16,
height: 16, decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.grey.shade300,
color: Colors.grey.shade300, borderRadius: BorderRadius.circular(6),
borderRadius: BorderRadius.circular(6), ),
), );
); }),
}), ),
), ),
), );
); }),
}), ),
), );
); }
}
// Employee List - Card Style // Employee List - Card Style
static Widget employeeListSkeletonLoader() { static Widget employeeListSkeletonLoader() {
@ -63,25 +61,37 @@ static Widget buildLoadingSkeleton() {
children: [ children: [
Row( Row(
children: [ children: [
Container(height: 14, width: 100, color: Colors.grey.shade300), Container(
height: 14,
width: 100,
color: Colors.grey.shade300),
MySpacing.width(8), MySpacing.width(8),
Container(height: 12, width: 60, color: Colors.grey.shade300), Container(
height: 12, width: 60, color: Colors.grey.shade300),
], ],
), ),
MySpacing.height(8), MySpacing.height(8),
Row( Row(
children: [ children: [
Icon(Icons.email, size: 16, color: Colors.grey.shade300), Icon(Icons.email,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4), MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300), Container(
height: 10,
width: 140,
color: Colors.grey.shade300),
], ],
), ),
MySpacing.height(8), MySpacing.height(8),
Row( Row(
children: [ children: [
Icon(Icons.phone, size: 16, color: Colors.grey.shade300), Icon(Icons.phone,
size: 16, color: Colors.grey.shade300),
MySpacing.width(4), MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300), Container(
height: 10,
width: 100,
color: Colors.grey.shade300),
], ],
), ),
], ],
@ -122,16 +132,28 @@ static Widget buildLoadingSkeleton() {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container(height: 12, width: 100, color: Colors.grey.shade300), Container(
height: 12,
width: 100,
color: Colors.grey.shade300),
MySpacing.height(8), MySpacing.height(8),
Container(height: 10, width: 80, color: Colors.grey.shade300), Container(
height: 10,
width: 80,
color: Colors.grey.shade300),
MySpacing.height(12), MySpacing.height(12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
Container(height: 28, width: 60, color: Colors.grey.shade300), Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
MySpacing.width(8), MySpacing.width(8),
Container(height: 28, width: 60, color: Colors.grey.shade300), Container(
height: 28,
width: 60,
color: Colors.grey.shade300),
], ],
), ),
], ],
@ -167,7 +189,8 @@ static Widget buildLoadingSkeleton() {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Container(height: 14, width: 120, color: Colors.grey.shade300), Container(
height: 14, width: 120, color: Colors.grey.shade300),
Icon(Icons.add_circle, color: Colors.grey.shade300), Icon(Icons.add_circle, color: Colors.grey.shade300),
], ],
), ),
@ -226,133 +249,198 @@ static Widget buildLoadingSkeleton() {
}), }),
); );
} }
static Widget employeeSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 12,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Name, org, email, phone static Widget expenseListSkeletonLoader() {
Expanded( return ListView.separated(
child: Column( padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
crossAxisAlignment: CrossAxisAlignment.start, itemCount: 6, // Show 6 skeleton items
children: [ separatorBuilder: (_, __) =>
Container(height: 12, width: 120, color: Colors.grey.shade300), Divider(color: Colors.grey.shade300, height: 20),
MySpacing.height(6), itemBuilder: (context, index) {
Container(height: 10, width: 80, color: Colors.grey.shade300), return Column(
MySpacing.height(8), crossAxisAlignment: CrossAxisAlignment.start,
// Email placeholder
Row(
children: [
Icon(Icons.email_outlined, size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
// Phone placeholder
Row(
children: [
Icon(Icons.phone_outlined, size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
MySpacing.height(8),
// Tags placeholder
Container(height: 8, width: 80, color: Colors.grey.shade300),
],
),
),
// Arrow
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
],
),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [ children: [
Container( // Title and Amount
height: 40, Row(
width: 40, mainAxisAlignment: MainAxisAlignment.spaceBetween,
decoration: BoxDecoration( children: [
color: Colors.grey.shade300, Container(
shape: BoxShape.circle, height: 14,
), width: 120,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
Container(
height: 14,
width: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
),
),
],
), ),
MySpacing.width(12), const SizedBox(height: 6),
Expanded( // Date and Status
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Container(
Container( height: 12,
height: 12, width: 100,
width: 100, decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
), ),
MySpacing.height(6), ),
Container( const Spacer(),
height: 10, Container(
width: 60, height: 12,
width: 50,
decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6),
), ),
], ),
), ],
), ),
], ],
), );
MySpacing.height(16), },
Container(height: 10, width: 150, color: Colors.grey.shade300), );
MySpacing.height(8), }
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
static Widget employeeSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 12,
borderRadiusAll: 12,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Avatar
Container(
height: 35,
width: 35,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
// Name, org, email, phone
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 12, width: 120, color: Colors.grey.shade300),
MySpacing.height(6),
Container(height: 10, width: 80, color: Colors.grey.shade300),
MySpacing.height(8),
// Email placeholder
Row(
children: [
Icon(Icons.email_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 140, color: Colors.grey.shade300),
],
),
MySpacing.height(8),
// Phone placeholder
Row(
children: [
Icon(Icons.phone_outlined,
size: 14, color: Colors.grey.shade300),
MySpacing.width(4),
Container(
height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.width(8),
Container(
height: 16,
width: 16,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
],
),
MySpacing.height(8),
// Tags placeholder
Container(height: 8, width: 80, color: Colors.grey.shade300),
],
),
),
// Arrow
Icon(Icons.arrow_forward_ios, size: 14, color: Colors.grey.shade300),
],
),
);
}
static Widget contactSkeletonCard() {
return MyCard.bordered(
margin: MySpacing.only(bottom: 12),
paddingAll: 16,
borderRadiusAll: 16,
shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle,
),
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: 100,
color: Colors.grey.shade300,
),
MySpacing.height(6),
Container(
height: 10,
width: 60,
color: Colors.grey.shade300,
),
],
),
),
],
),
MySpacing.height(16),
Container(height: 10, width: 150, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 100, color: Colors.grey.shade300),
MySpacing.height(8),
Container(height: 10, width: 120, color: Colors.grey.shade300),
],
),
);
}
} }

View File

@ -24,15 +24,19 @@ class ExpenseResponse {
required this.timestamp, required this.timestamp,
}); });
factory ExpenseResponse.fromJson(Map<String, dynamic> json) => factory ExpenseResponse.fromJson(Map<String, dynamic> json) {
ExpenseResponse( final dataField = json["data"];
success: json["success"], return ExpenseResponse(
message: json["message"], success: json["success"] ?? false,
data: ExpenseData.fromJson(json["data"]), message: json["message"] ?? '',
errors: json["errors"], data: (dataField is Map<String, dynamic>)
statusCode: json["statusCode"], ? ExpenseData.fromJson(dataField)
timestamp: DateTime.parse(json["timestamp"]), : ExpenseData.empty(),
); errors: json["errors"],
statusCode: json["statusCode"] ?? 0,
timestamp: DateTime.tryParse(json["timestamp"] ?? '') ?? DateTime.now(),
);
}
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"success": success, "success": success,
@ -45,12 +49,14 @@ class ExpenseResponse {
} }
class ExpenseData { class ExpenseData {
final Filter? filter;
final int currentPage; final int currentPage;
final int totalPages; final int totalPages;
final int totalEntites; final int totalEntites;
final List<ExpenseModel> data; final List<ExpenseModel> data;
ExpenseData({ ExpenseData({
required this.filter,
required this.currentPage, required this.currentPage,
required this.totalPages, required this.totalPages,
required this.totalEntites, required this.totalEntites,
@ -58,14 +64,25 @@ class ExpenseData {
}); });
factory ExpenseData.fromJson(Map<String, dynamic> json) => ExpenseData( factory ExpenseData.fromJson(Map<String, dynamic> json) => ExpenseData(
currentPage: json["currentPage"], filter: json["filter"] != null ? Filter.fromJson(json["filter"]) : null,
totalPages: json["totalPages"], currentPage: json["currentPage"] ?? 0,
totalEntites: json["totalEntites"], totalPages: json["totalPages"] ?? 0,
data: List<ExpenseModel>.from( totalEntites: json["totalEntites"] ?? 0,
json["data"].map((x) => ExpenseModel.fromJson(x))), data: (json["data"] as List<dynamic>? ?? [])
.map((x) => ExpenseModel.fromJson(x))
.toList(),
);
factory ExpenseData.empty() => ExpenseData(
filter: null,
currentPage: 0,
totalPages: 0,
totalEntites: 0,
data: [],
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
"filter": filter?.toJson(),
"currentPage": currentPage, "currentPage": currentPage,
"totalPages": totalPages, "totalPages": totalPages,
"totalEntites": totalEntites, "totalEntites": totalEntites,
@ -73,6 +90,47 @@ class ExpenseData {
}; };
} }
class Filter {
final List<String> projectIds;
final List<String> statusIds;
final List<String> createdByIds;
final List<String> paidById;
final DateTime? startDate;
final DateTime? endDate;
Filter({
required this.projectIds,
required this.statusIds,
required this.createdByIds,
required this.paidById,
required this.startDate,
required this.endDate,
});
factory Filter.fromJson(Map<String, dynamic> json) => Filter(
projectIds: List<String>.from(json["projectIds"] ?? []),
statusIds: List<String>.from(json["statusIds"] ?? []),
createdByIds: List<String>.from(json["createdByIds"] ?? []),
paidById: List<String>.from(json["paidById"] ?? []),
startDate:
json["startDate"] != null ? DateTime.tryParse(json["startDate"]) : null,
endDate:
json["endDate"] != null ? DateTime.tryParse(json["endDate"]) : null,
);
Map<String, dynamic> toJson() => {
"projectIds": projectIds,
"statusIds": statusIds,
"createdByIds": createdByIds,
"paidById": paidById,
"startDate": startDate?.toIso8601String(),
"endDate": endDate?.toIso8601String(),
};
}
// --- ExpenseModel and other classes remain same as you wrote ---
// I will include them here for completeness.
class ExpenseModel { class ExpenseModel {
final String id; final String id;
final Project project; final Project project;
@ -105,22 +163,22 @@ class ExpenseModel {
}); });
factory ExpenseModel.fromJson(Map<String, dynamic> json) => ExpenseModel( factory ExpenseModel.fromJson(Map<String, dynamic> json) => ExpenseModel(
id: json["id"], id: json["id"] ?? '',
project: Project.fromJson(json["project"]), project: Project.fromJson(json["project"] ?? {}),
expensesType: ExpenseType.fromJson(json["expensesType"]), expensesType: ExpenseType.fromJson(json["expensesType"] ?? {}),
paymentMode: PaymentMode.fromJson(json["paymentMode"]), paymentMode: PaymentMode.fromJson(json["paymentMode"] ?? {}),
paidBy: PaidBy.fromJson(json["paidBy"]), paidBy: PaidBy.fromJson(json["paidBy"] ?? {}),
createdBy: CreatedBy.fromJson(json["createdBy"]), createdBy: CreatedBy.fromJson(json["createdBy"] ?? {}),
transactionDate: DateTime.parse(json["transactionDate"]), transactionDate:
createdAt: DateTime.parse(json["createdAt"]), DateTime.tryParse(json["transactionDate"] ?? '') ?? DateTime.now(),
supplerName: json["supplerName"], createdAt:
amount: (json["amount"] as num).toDouble(), DateTime.tryParse(json["createdAt"] ?? '') ?? DateTime.now(),
status: Status.fromJson(json["status"]), supplerName: json["supplerName"] ?? '',
nextStatus: json["nextStatus"] != null amount: (json["amount"] ?? 0).toDouble(),
? List<Status>.from( status: Status.fromJson(json["status"] ?? {}),
json["nextStatus"].map((x) => Status.fromJson(x)), nextStatus: (json["nextStatus"] as List<dynamic>? ?? [])
) .map((x) => Status.fromJson(x))
: [], .toList(),
preApproved: json["preApproved"] ?? false, preApproved: json["preApproved"] ?? false,
); );
@ -163,14 +221,15 @@ class Project {
}); });
factory Project.fromJson(Map<String, dynamic> json) => Project( factory Project.fromJson(Map<String, dynamic> json) => Project(
id: json["id"], id: json["id"] ?? '',
name: json["name"], name: json["name"] ?? '',
shortName: json["shortName"], shortName: json["shortName"] ?? '',
projectAddress: json["projectAddress"], projectAddress: json["projectAddress"] ?? '',
contactPerson: json["contactPerson"], contactPerson: json["contactPerson"] ?? '',
startDate: DateTime.parse(json["startDate"]), startDate:
endDate: DateTime.parse(json["endDate"]), DateTime.tryParse(json["startDate"] ?? '') ?? DateTime.now(),
projectStatusId: json["projectStatusId"], endDate: DateTime.tryParse(json["endDate"] ?? '') ?? DateTime.now(),
projectStatusId: json["projectStatusId"] ?? '',
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -199,10 +258,10 @@ class ExpenseType {
}); });
factory ExpenseType.fromJson(Map<String, dynamic> json) => ExpenseType( factory ExpenseType.fromJson(Map<String, dynamic> json) => ExpenseType(
id: json["id"], id: json["id"] ?? '',
name: json["name"], name: json["name"] ?? '',
noOfPersonsRequired: json["noOfPersonsRequired"], noOfPersonsRequired: json["noOfPersonsRequired"] ?? false,
description: json["description"], description: json["description"] ?? '',
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -225,9 +284,9 @@ class PaymentMode {
}); });
factory PaymentMode.fromJson(Map<String, dynamic> json) => PaymentMode( factory PaymentMode.fromJson(Map<String, dynamic> json) => PaymentMode(
id: json["id"], id: json["id"] ?? '',
name: json["name"], name: json["name"] ?? '',
description: json["description"], description: json["description"] ?? '',
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {
@ -255,11 +314,11 @@ class PaidBy {
}); });
factory PaidBy.fromJson(Map<String, dynamic> json) => PaidBy( factory PaidBy.fromJson(Map<String, dynamic> json) => PaidBy(
id: json["id"], id: json["id"] ?? '',
firstName: json["firstName"], firstName: json["firstName"] ?? '',
lastName: json["lastName"], lastName: json["lastName"] ?? '',
photo: json["photo"], photo: json["photo"] ?? '',
jobRoleId: json["jobRoleId"], jobRoleId: json["jobRoleId"] ?? '',
jobRoleName: json["jobRoleName"], jobRoleName: json["jobRoleName"],
); );
@ -291,11 +350,11 @@ class CreatedBy {
}); });
factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy( factory CreatedBy.fromJson(Map<String, dynamic> json) => CreatedBy(
id: json["id"], id: json["id"] ?? '',
firstName: json["firstName"], firstName: json["firstName"] ?? '',
lastName: json["lastName"], lastName: json["lastName"] ?? '',
photo: json["photo"], photo: json["photo"] ?? '',
jobRoleId: json["jobRoleId"], jobRoleId: json["jobRoleId"] ?? '',
jobRoleName: json["jobRoleName"], jobRoleName: json["jobRoleName"],
); );
@ -327,12 +386,12 @@ class Status {
}); });
factory Status.fromJson(Map<String, dynamic> json) => Status( factory Status.fromJson(Map<String, dynamic> json) => Status(
id: json["id"], id: json["id"] ?? '',
name: json["name"], name: json["name"] ?? '',
displayName: json["displayName"], displayName: json["displayName"] ?? '',
description: json["description"], description: json["description"] ?? '',
color: json["color"], color: (json["color"] ?? '').replaceAll("'", ''),
isSystem: json["isSystem"], isSystem: json["isSystem"] ?? false,
); );
Map<String, dynamic> toJson() => { Map<String, dynamic> toJson() => {

View File

@ -1,27 +1,34 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:marco/controller/project_controller.dart'; import 'package:intl/intl.dart';
import 'package:marco/controller/expense/expense_screen_controller.dart'; import 'package:marco/controller/expense/expense_detail_controller.dart';
import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_spacing.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/model/expense/expense_list_model.dart'; import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/helpers/utils/date_time_utils.dart'; import 'package:marco/controller/project_controller.dart';
class ExpenseDetailScreen extends StatelessWidget { class ExpenseDetailScreen extends StatelessWidget {
const ExpenseDetailScreen({super.key}); final String expenseId;
static Color getStatusColor(String? status) { const ExpenseDetailScreen({super.key, required this.expenseId});
// Status color logic
static Color getStatusColor(String? status, {String? colorCode}) {
if (colorCode != null && colorCode.isNotEmpty) {
try {
return Color(int.parse(colorCode.replaceFirst('#', '0xff')));
} catch (_) {}
}
switch (status) { switch (status) {
case 'Requested': case 'Approval Pending':
return Colors.blue;
case 'Review':
return Colors.orange; return Colors.orange;
case 'Approved': case 'Process Pending':
return Colors.green; return Colors.blue;
case 'Rejected':
return Colors.red;
case 'Paid': case 'Paid':
return Colors.purple; return Colors.green;
case 'Closed':
return Colors.grey;
default: default:
return Colors.black; return Colors.black;
} }
@ -29,12 +36,9 @@ class ExpenseDetailScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ExpenseModel expense = Get.arguments['expense'] as ExpenseModel; final controller = Get.put(ExpenseDetailController());
final statusColor = getStatusColor(expense.status.name);
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final expenseController = Get.find<ExpenseController>(); controller.fetchExpenseDetails(expenseId);
print(
"Next Status List: ${expense.nextStatus.map((e) => e.toJson()).toList()}");
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
@ -48,12 +52,12 @@ class ExpenseDetailScreen extends StatelessWidget {
title: Padding( title: Padding(
padding: MySpacing.xy(16, 0), padding: MySpacing.xy(16, 0),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.arrow_back_ios_new, icon: const Icon(Icons.arrow_back_ios_new,
color: Colors.black, size: 20), color: Colors.black, size: 20),
onPressed: () => Get.back(), onPressed: () =>
Get.offAllNamed('/dashboard/expense-main-page'),
), ),
MySpacing.width(8), MySpacing.width(8),
Expanded( Expanded(
@ -98,84 +102,146 @@ class ExpenseDetailScreen extends StatelessWidget {
), ),
), ),
), ),
body: SingleChildScrollView( body: SafeArea(
padding: const EdgeInsets.all(16), child: Obx(() {
child: Column( if (controller.isLoading.value) {
crossAxisAlignment: CrossAxisAlignment.start, return _buildLoadingSkeleton();
children: [ }
_ExpenseHeader( if (controller.errorMessage.isNotEmpty) {
title: expense.expensesType.name, return Center(
amount: '${expense.amount.toStringAsFixed(2)}', child: Text(
status: expense.status.name, controller.errorMessage.value,
statusColor: statusColor, style: const TextStyle(color: Colors.red, fontSize: 16),
),
const SizedBox(height: 16),
_ExpenseDetailsList(expense: expense),
],
),
),
bottomNavigationBar: expense.nextStatus.isNotEmpty
? SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: expense.nextStatus.map((next) {
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
padding: const EdgeInsets.symmetric(
vertical: 8, horizontal: 12),
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: () async {
final success =
await expenseController.updateExpenseStatus(
expense.id,
next.id,
);
if (success) {
Get.snackbar(
'Success',
'Expense moved to ${next.name}',
backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white,
);
Get.back(result: true);
} else {
Get.snackbar(
'Error',
'Failed to update status.',
backgroundColor: Colors.red.withOpacity(0.8),
colorText: Colors.white,
);
}
},
child: Text(
next.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
),
), ),
) );
: null, }
final expense = controller.expense.value;
if (expense == null) {
return const Center(child: Text("No expense details found."));
}
final statusColor = getStatusColor(
expense.status.name,
colorCode: expense.status.color,
);
final formattedAmount = NumberFormat.currency(
locale: 'en_IN',
symbol: '',
decimalDigits: 2,
).format(expense.amount);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ExpenseHeader(
title: expense.expensesType.name,
amount: formattedAmount,
status: expense.status.name,
statusColor: statusColor,
),
const SizedBox(height: 16),
_ExpenseDetailsList(expense: expense),
const SizedBox(height: 100),
],
),
);
}),
),
bottomNavigationBar: Obx(() {
final expense = controller.expense.value;
if (expense == null || expense.nextStatus.isEmpty) {
return const SizedBox();
}
return SafeArea(
child: Container(
color: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
child: Wrap(
alignment: WrapAlignment.center,
spacing: 10,
runSpacing: 10,
children: expense.nextStatus.map((next) {
Color buttonColor = Colors.red;
if (next.color.isNotEmpty) {
try {
buttonColor =
Color(int.parse(next.color.replaceFirst('#', '0xff')));
} catch (_) {}
}
return ElevatedButton(
style: ElevatedButton.styleFrom(
minimumSize: const Size(100, 40),
padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 12),
backgroundColor: buttonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(6),
),
),
onPressed: () async {
final success = await controller.updateExpenseStatus(
expense.id, next.id);
if (success) {
Get.snackbar(
'Success',
'Expense moved to ${next.displayName.isNotEmpty ? next.displayName : next.name}',
backgroundColor: Colors.green.withOpacity(0.8),
colorText: Colors.white,
);
await controller.fetchExpenseDetails(expenseId);
} else {
Get.snackbar(
'Error',
'Failed to update status.',
backgroundColor: Colors.red.withOpacity(0.8),
colorText: Colors.white,
);
}
},
child: Text(
next.displayName.isNotEmpty ? next.displayName : next.name,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
),
),
);
}),
);
}
// Loading skeleton placeholder
Widget _buildLoadingSkeleton() {
return ListView(
padding: const EdgeInsets.all(16),
children: List.generate(5, (index) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
height: 80,
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(10),
),
);
}),
); );
} }
} }
// Expense header card
class _ExpenseHeader extends StatelessWidget { class _ExpenseHeader extends StatelessWidget {
final String title; final String title;
final String amount; final String amount;
@ -229,7 +295,7 @@ class _ExpenseHeader extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.black.withOpacity(0.15), color: statusColor.withOpacity(0.15),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Row( child: Row(
@ -239,8 +305,8 @@ class _ExpenseHeader extends StatelessWidget {
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
status, status,
style: const TextStyle( style: TextStyle(
color: Colors.black, color: statusColor,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
@ -253,6 +319,7 @@ class _ExpenseHeader extends StatelessWidget {
} }
} }
// Expense details list
class _ExpenseDetailsList extends StatelessWidget { class _ExpenseDetailsList extends StatelessWidget {
final ExpenseModel expense; final ExpenseModel expense;
@ -289,28 +356,41 @@ class _ExpenseDetailsList extends StatelessWidget {
_DetailRow(title: "Expense Type", value: expense.expensesType.name), _DetailRow(title: "Expense Type", value: expense.expensesType.name),
_DetailRow(title: "Payment Mode", value: expense.paymentMode.name), _DetailRow(title: "Payment Mode", value: expense.paymentMode.name),
_DetailRow( _DetailRow(
title: "Paid By", title: "Paid By",
value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}'), value: '${expense.paidBy.firstName} ${expense.paidBy.lastName}',
),
_DetailRow( _DetailRow(
title: "Created By", title: "Created By",
value: value:
'${expense.createdBy.firstName} ${expense.createdBy.lastName}'), '${expense.createdBy.firstName} ${expense.createdBy.lastName}',
),
_DetailRow(title: "Transaction Date", value: transactionDate), _DetailRow(title: "Transaction Date", value: transactionDate),
_DetailRow(title: "Created At", value: createdAt), _DetailRow(title: "Created At", value: createdAt),
_DetailRow(title: "Supplier Name", value: expense.supplerName), _DetailRow(title: "Supplier Name", value: expense.supplerName),
_DetailRow(title: "Amount", value: '${expense.amount}'), _DetailRow(
title: "Amount",
value: NumberFormat.currency(
locale: 'en_IN',
symbol: '',
decimalDigits: 2,
).format(expense.amount),
),
_DetailRow(title: "Status", value: expense.status.name), _DetailRow(title: "Status", value: expense.status.name),
_DetailRow( _DetailRow(
title: "Next Status", title: "Next Status",
value: expense.nextStatus.map((e) => e.name).join(", ")), value: expense.nextStatus.map((e) => e.name).join(", "),
),
_DetailRow( _DetailRow(
title: "Pre-Approved", value: expense.preApproved ? "Yes" : "No"), title: "Pre-Approved",
value: expense.preApproved ? "Yes" : "No",
),
], ],
), ),
); );
} }
} }
// A single row for expense details
class _DetailRow extends StatelessWidget { class _DetailRow extends StatelessWidget {
final String title; final String title;
final String value; final String value;
@ -343,6 +423,7 @@ class _DetailRow extends StatelessWidget {
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
softWrap: true,
), ),
), ),
], ],

View File

@ -5,178 +5,73 @@ import 'package:marco/helpers/utils/date_time_utils.dart';
import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/my_text.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 RxList<EmployeeModel> selectedPaidByEmployees; final ScrollController scrollController;
final RxList<EmployeeModel> selectedCreatedByEmployees;
ExpenseFilterBottomSheet({ const ExpenseFilterBottomSheet({
super.key, super.key,
required this.expenseController, required this.expenseController,
required this.selectedPaidByEmployees, required this.scrollController,
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
return Container( return SafeArea(
padding: const EdgeInsets.all(16), child: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)), borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
), ),
child: SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
MyText.titleLarge('Filter Expenses', fontWeight: 700), Expanded(
const SizedBox(height: 16), child: SingleChildScrollView(
controller: scrollController,
/// Project Filter padding:
MyText.bodyMedium('Project', fontWeight: 600), const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
const SizedBox(height: 6), child: _buildContent(context),
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'),
),
],
), ),
_buildBottomButtons(),
], ],
), ),
), ),
@ -184,17 +79,282 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
}); });
} }
/// Employee Filter Section /// Builds the filter content
Widget _employeeFilterSection({ Widget _buildContent(BuildContext context) {
required String title, return Column(
required RxList<EmployeeModel> selectedEmployees, crossAxisAlignment: CrossAxisAlignment.start,
required ExpenseController expenseController, children: [
Center(
child: Container(
width: 50,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(20),
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
MyText.titleLarge('Filter Expenses', fontWeight: 700),
TextButton(
onPressed: () => expenseController.clearFilters(),
child: const Text(
"Reset Filter",
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 16),
/// Project Filter
_buildCardSection(
title: "Project",
child: _popupSelector(
context,
currentValue: expenseController.selectedProject.value.isEmpty
? 'Select Project'
: expenseController.selectedProject.value,
items: expenseController.globalProjects,
onSelected: (value) =>
expenseController.selectedProject.value = value,
),
),
const SizedBox(height: 16),
/// Expense Status Filter
_buildCardSection(
title: "Expense Status",
child: _popupSelector(
context,
currentValue: expenseController.selectedStatus.value.isEmpty
? 'Select Expense Status'
: expenseController.expenseStatuses
.firstWhereOrNull((e) =>
e.id == expenseController.selectedStatus.value)
?.name ??
'Select Expense Status',
items:
expenseController.expenseStatuses.map((e) => e.name).toList(),
onSelected: (name) {
final status = expenseController.expenseStatuses
.firstWhere((e) => e.name == name);
expenseController.selectedStatus.value = status.id;
},
),
),
const SizedBox(height: 16),
/// Date Range Filter
_buildCardSection(
title: "Date Range",
child: Row(
children: [
Expanded(
child: _dateButton(
label: expenseController.startDate.value == null
? 'Start Date'
: DateTimeUtils.formatDate(
expenseController.startDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.startDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null)
expenseController.startDate.value = picked;
},
),
),
const SizedBox(width: 8),
Expanded(
child: _dateButton(
label: expenseController.endDate.value == null
? 'End Date'
: DateTimeUtils.formatDate(
expenseController.endDate.value!, 'dd MMM yyyy'),
onTap: () async {
DateTime? picked = await showDatePicker(
context: context,
initialDate:
expenseController.endDate.value ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (picked != null)
expenseController.endDate.value = picked;
},
),
),
],
),
),
const SizedBox(height: 16),
/// Paid By Filter
_buildCardSection(
title: "Paid By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedPaidByEmployees,
),
),
const SizedBox(height: 16),
/// Created By Filter
_buildCardSection(
title: "Created By",
child: _employeeFilterSection(
selectedEmployees: expenseController.selectedCreatedByEmployees,
),
),
const SizedBox(height: 24),
],
);
}
/// Bottom Action Buttons
Widget _buildBottomButtons() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(
children: [
// Cancel Button
Expanded(
child: ElevatedButton.icon(
onPressed: () {
Get.back();
},
icon: const Icon(Icons.close, color: Colors.white),
label: MyText.bodyMedium(
"Cancel",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
),
),
),
const SizedBox(width: 12),
// Submit Button
Expanded(
child: ElevatedButton.icon(
onPressed: () {
expenseController.fetchExpenses();
Get.back();
},
icon: const Icon(Icons.check_circle_outline, color: Colors.white),
label: MyText.bodyMedium(
"Submit",
color: Colors.white,
fontWeight: 600,
),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.indigo,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
),
),
),
],
),
);
}
/// Popup Selector
Widget _popupSelector(
BuildContext context, {
required String currentValue,
required List<String> items,
required ValueChanged<String> onSelected,
}) { }) {
return PopupMenuButton<String>(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onSelected: onSelected,
itemBuilder: (context) {
return items
.map((e) => PopupMenuItem<String>(
value: e,
child: Text(e),
))
.toList();
},
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: [
Expanded(
child: Text(
currentValue,
style: const TextStyle(color: Colors.black87),
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.arrow_drop_down, color: Colors.grey),
],
),
),
);
}
/// Card Section Wrapper
Widget _buildCardSection({required String title, required Widget child}) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium(title, fontWeight: 600), MyText.bodyMedium(title, fontWeight: 600),
const SizedBox(height: 6), const SizedBox(height: 6),
child,
],
);
}
/// Date Button
Widget _dateButton({required String label, required VoidCallback onTap}) {
return ElevatedButton.icon(
onPressed: onTap,
icon: const Icon(Icons.calendar_today, size: 16),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.grey.shade100,
foregroundColor: Colors.black,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
),
label: Text(label, overflow: TextOverflow.ellipsis),
);
}
/// Employee Filter Section
Widget _employeeFilterSection(
{required RxList<EmployeeModel> selectedEmployees}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Obx(() { Obx(() {
return Wrap( return Wrap(
spacing: 6, spacing: 6,
@ -270,35 +430,3 @@ class ExpenseFilterBottomSheet extends StatelessWidget {
); );
} }
} }
/// 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,8 +8,8 @@ import 'package:marco/helpers/widgets/my_text.dart';
import 'package:marco/model/expense/expense_list_model.dart'; import 'package:marco/model/expense/expense_list_model.dart';
import 'package:marco/view/expense/expense_detail_screen.dart'; import 'package:marco/view/expense/expense_detail_screen.dart';
import 'package:marco/model/expense/add_expense_bottom_sheet.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'; import 'package:marco/view/expense/expense_filter_bottom_sheet.dart';
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
class ExpenseMainScreen extends StatefulWidget { class ExpenseMainScreen extends StatefulWidget {
const ExpenseMainScreen({super.key}); const ExpenseMainScreen({super.key});
@ -22,22 +22,33 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
final RxBool isHistoryView = false.obs; final RxBool isHistoryView = false.obs;
final TextEditingController searchController = TextEditingController(); final TextEditingController searchController = TextEditingController();
final RxString searchQuery = ''.obs; final RxString searchQuery = ''.obs;
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.find<ProjectController>();
final ExpenseController expenseController = Get.put(ExpenseController()); final ExpenseController expenseController = Get.put(ExpenseController());
final RxList<EmployeeModel> selectedPaidByEmployees = <EmployeeModel>[].obs;
final RxList<EmployeeModel> selectedCreatedByEmployees =
<EmployeeModel>[].obs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
expenseController.fetchExpenses(); expenseController.fetchExpenses(); // Initial data load
} }
void _refreshExpenses() { void _refreshExpenses() {
expenseController.fetchExpenses(); expenseController.fetchExpenses();
} }
void _openFilterBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) {
return ExpenseFilterBottomSheetWrapper(
expenseController: expenseController,
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -49,14 +60,14 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
_SearchAndFilter( _SearchAndFilter(
searchController: searchController, searchController: searchController,
onChanged: (value) => searchQuery.value = value, onChanged: (value) => searchQuery.value = value,
onFilterTap: _openFilterBottomSheet, onFilterTap: () => _openFilterBottomSheet(context),
onRefreshTap: _refreshExpenses, onRefreshTap: _refreshExpenses,
), ),
_ToggleButtons(isHistoryView: isHistoryView), _ToggleButtons(isHistoryView: isHistoryView),
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (expenseController.isLoading.value) { if (expenseController.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return SkeletonLoaders.expenseListSkeletonLoader();
} }
if (expenseController.errorMessage.isNotEmpty) { if (expenseController.errorMessage.isNotEmpty) {
@ -81,7 +92,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
expense.paymentMode.name.toLowerCase().contains(query); expense.paymentMode.name.toLowerCase().contains(query);
}).toList(); }).toList();
// Sort by latest transaction date first // Sort by latest transaction date
filteredList.sort( filteredList.sort(
(a, b) => b.transactionDate.compareTo(a.transactionDate)); (a, b) => b.transactionDate.compareTo(a.transactionDate));
@ -113,19 +124,9 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen> {
), ),
); );
} }
void _openFilterBottomSheet() {
Get.bottomSheet(
ExpenseFilterBottomSheet(
expenseController: expenseController,
selectedPaidByEmployees: selectedPaidByEmployees,
selectedCreatedByEmployees: selectedCreatedByEmployees,
),
);
}
} }
// AppBar Widget ///---------------------- APP BAR ----------------------///
class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget { class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
final ProjectController projectController; final ProjectController projectController;
@ -170,7 +171,6 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
projectController.selectedProject?.name ?? projectController.selectedProject?.name ??
'Select Project'; 'Select Project';
return InkWell( return InkWell(
onTap: () => Get.toNamed('/project-selector'),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.work_outline, const Icon(Icons.work_outline,
@ -200,8 +200,7 @@ class _ExpenseAppBar extends StatelessWidget implements PreferredSizeWidget {
} }
} }
// Search and Filter Widget ///---------------------- SEARCH AND FILTER ----------------------///
class _SearchAndFilter extends StatelessWidget { class _SearchAndFilter extends StatelessWidget {
final TextEditingController searchController; final TextEditingController searchController;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
@ -217,6 +216,8 @@ class _SearchAndFilter extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ExpenseController expenseController = Get.find<ExpenseController>();
return Padding( return Padding(
padding: MySpacing.fromLTRB(12, 10, 12, 0), padding: MySpacing.fromLTRB(12, 10, 12, 0),
child: Row( child: Row(
@ -259,17 +260,42 @@ class _SearchAndFilter extends StatelessWidget {
), ),
), ),
MySpacing.width(8), MySpacing.width(8),
IconButton( Obx(() {
icon: const Icon(Icons.tune, color: Colors.black), final bool showRedDot = expenseController.isFilterApplied;
onPressed: onFilterTap, return IconButton(
), onPressed: onFilterTap,
icon: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.tune, color: Colors.black, size: 24),
if (showRedDot)
Positioned(
top: -1,
right: -1,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 1.5,
),
),
),
),
],
),
);
}),
], ],
), ),
); );
} }
} }
// Toggle Buttons Widget ///---------------------- TOGGLE BUTTONS ----------------------///
class _ToggleButtons extends StatelessWidget { class _ToggleButtons extends StatelessWidget {
final RxBool isHistoryView; final RxBool isHistoryView;
@ -360,7 +386,7 @@ class _ToggleButton extends StatelessWidget {
} }
} }
// Expense List Widget (Dynamic) ///---------------------- EXPENSE LIST ----------------------///
class _ExpenseList extends StatelessWidget { class _ExpenseList extends StatelessWidget {
final List<ExpenseModel> expenseList; final List<ExpenseModel> expenseList;
@ -371,7 +397,7 @@ class _ExpenseList extends StatelessWidget {
if (expenseList.isEmpty) { if (expenseList.isEmpty) {
return Center(child: MyText.bodyMedium('No expenses found.')); return Center(child: MyText.bodyMedium('No expenses found.'));
} }
final expenseController = Get.find<ExpenseController>();
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
itemCount: expenseList.length, itemCount: expenseList.length,
@ -386,10 +412,17 @@ class _ExpenseList extends StatelessWidget {
); );
return GestureDetector( return GestureDetector(
onTap: () => Get.to( onTap: () async {
() => const ExpenseDetailScreen(), final result = await Get.to(
arguments: {'expense': expense}, () => ExpenseDetailScreen(expenseId: expense.id),
), arguments: {'expense': expense},
);
// If status was updated, refresh expenses
if (result == true) {
expenseController.fetchExpenses();
}
},
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@ -398,13 +431,9 @@ class _ExpenseList extends StatelessWidget {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Row( MyText.bodyMedium(
children: [ expense.expensesType.name,
MyText.bodyMedium( fontWeight: 700,
expense.expensesType.name,
fontWeight: 700,
),
],
), ),
MyText.bodyMedium( MyText.bodyMedium(
'${expense.amount.toStringAsFixed(2)}', '${expense.amount.toStringAsFixed(2)}',