feat: Implement pending expenses feature with API integration and UI widget

This commit is contained in:
Vaibhav Surve 2025-10-30 10:53:38 +05:30
parent c78231d0fd
commit f01608e4e7
9 changed files with 661 additions and 7 deletions

View File

@ -3,6 +3,7 @@ import 'package:marco/helpers/services/app_logger.dart';
import 'package:marco/helpers/services/api_service.dart';
import 'package:marco/controller/project_controller.dart';
import 'package:marco/model/dashboard/project_progress_model.dart';
import 'package:marco/model/dashboard/pending_expenses_model.dart';
class DashboardController extends GetxController {
// =========================
@ -48,7 +49,11 @@ class DashboardController extends GetxController {
// Inject ProjectController
final ProjectController projectController = Get.find<ProjectController>();
// Pending Expenses overview
// =========================
final RxBool isPendingExpensesLoading = false.obs;
final Rx<PendingExpensesData?> pendingExpensesData =
Rx<PendingExpensesData?>(null);
@override
void onInit() {
super.onInit();
@ -147,9 +152,35 @@ class DashboardController extends GetxController {
fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(),
]);
}
Future<void> fetchPendingExpenses() async {
final String projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
try {
isPendingExpensesLoading.value = true;
final response =
await ApiService.getPendingExpensesApi(projectId: projectId);
if (response != null && response.success) {
pendingExpensesData.value = response.data;
logSafe('Pending expenses fetched successfully.', level: LogLevel.info);
} else {
pendingExpensesData.value = null;
logSafe('Failed to fetch pending expenses.', level: LogLevel.error);
}
} catch (e, st) {
pendingExpensesData.value = null;
logSafe('Error fetching pending expenses',
level: LogLevel.error, error: e, stackTrace: st);
} finally {
isPendingExpensesLoading.value = false;
}
}
// =========================
// API Calls
// =========================

View File

@ -1,5 +1,6 @@
class ApiEndpoints {
static const String baseUrl = "https://mapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
static const String baseUrl = "https://ofwapi.marcoaiot.com/api";
// static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
@ -10,12 +11,14 @@ class ApiEndpoints {
static const String getDashboardTasks = "/dashboard/tasks";
static const String getDashboardTeams = "/dashboard/teams";
static const String getDashboardProjects = "/dashboard/projects";
static const String getDashboardMonthlyExpenses =
"/Dashboard/expense/monthly";
static const String getExpenseTypeReport = "/Dashboard/expense/type";
static const String getPendingExpenses = "/Dashboard/expense/pendings";
///// Projects Module API Endpoints
static const String createProject = "/project";
// Attendance Module API Endpoints
static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic";

View File

@ -19,13 +19,14 @@ import 'package:marco/model/document/master_document_type_model.dart';
import 'package:marco/model/document/document_details_model.dart';
import 'package:marco/model/document/document_version_model.dart';
import 'package:marco/model/attendance/organization_per_project_list_model.dart';
import 'package:marco/model/dashboard/pending_expenses_model.dart';
class ApiService {
static const bool enableLogs = true;
static const Duration extendedTimeout = Duration(seconds: 60);
static Future<String?> _getToken() async {
final token = await LocalStorage.getJwtToken();
final token = LocalStorage.getJwtToken();
if (token == null) {
logSafe("No JWT token found. Logging out...");
@ -38,7 +39,7 @@ class ApiService {
logSafe("Access token is expired. Attempting refresh...");
final refreshed = await AuthService.refreshToken();
if (refreshed) {
return await LocalStorage.getJwtToken();
return LocalStorage.getJwtToken();
} else {
logSafe("Token refresh failed. Logging out immediately...");
await LocalStorage.logout();
@ -55,7 +56,7 @@ class ApiService {
"Access token is about to expire in ${difference.inSeconds}s. Refreshing...");
final refreshed = await AuthService.refreshToken();
if (refreshed) {
return await LocalStorage.getJwtToken();
return LocalStorage.getJwtToken();
} else {
logSafe("Token refresh failed (near expiry). Logging out...");
await LocalStorage.logout();
@ -288,6 +289,39 @@ class ApiService {
}
}
/// Get Pending Expenses
static Future<PendingExpensesResponse?> getPendingExpensesApi({
required String projectId,
}) async {
const endpoint = ApiEndpoints.getPendingExpenses;
logSafe("Fetching Pending Expenses for projectId: $projectId");
try {
final response = await _getRequest(
endpoint,
queryParams: {'projectId': projectId},
);
if (response == null) {
logSafe("Pending Expenses request failed: null response",
level: LogLevel.error);
return null;
}
final jsonResponse =
_parseResponseForAllData(response, label: "Pending Expenses");
if (jsonResponse != null) {
return PendingExpensesResponse.fromJson(jsonResponse);
}
} catch (e, stack) {
logSafe("Exception during getPendingExpensesApi: $e",
level: LogLevel.error);
logSafe("StackTrace: $stack", level: LogLevel.debug);
}
return null;
}
/// Create Project API
static Future<bool> createProjectApi({

View File

@ -0,0 +1,163 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:marco/controller/dashboard/dashboard_controller.dart';
import 'package:marco/helpers/widgets/my_text.dart';
class ExpenseByStatusWidget extends StatelessWidget {
final DashboardController controller;
const ExpenseByStatusWidget({super.key, required this.controller});
Widget _buildStatusTile({
required IconData icon,
required Color color,
required String title,
required String amount,
required String count,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: Colors.white,
radius: 20,
child: Icon(icon, color: color, size: 22),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
title,
fontWeight: 600,
),
const SizedBox(height: 2),
MyText.bodyMedium(
amount,
color: Colors.blue,
),
],
),
),
MyText.titleMedium(
count,
color: Colors.blue,
fontWeight: 600,
),
const Icon(Icons.chevron_right, color: Colors.blue, size: 22),
],
),
);
}
@override
Widget build(BuildContext context) {
return Obx(() {
final data = controller.pendingExpensesData.value;
if (controller.isPendingExpensesLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (data == null) {
return Center(
child: MyText.bodyMedium("No expense status data available"),
);
}
return Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleLarge(
"Expense - By Status",
fontWeight: 700,
),
const SizedBox(height: 4),
MyText.bodyMedium(
controller.projectController.selectedProjectName.value,
color: Colors.grey.shade600,
),
const SizedBox(height: 16),
// Pending Payment
_buildStatusTile(
icon: Icons.currency_rupee,
color: Colors.blue,
title: "Pending Payment",
amount: "${data.processPending.amount.toStringAsFixed(1)}K",
count: data.processPending.count.toString(),
),
// Pending Approve
_buildStatusTile(
icon: Icons.check_circle_outline,
color: Colors.orange,
title: "Pending Approve",
amount: "${data.approvePending.amount.toStringAsFixed(1)}K",
count: data.approvePending.count.toString(),
),
// Pending Review
_buildStatusTile(
icon: Icons.search,
color: Colors.grey.shade700,
title: "Pending Review",
amount: "${data.reviewPending.amount.toStringAsFixed(1)}K",
count: data.reviewPending.count.toString(),
),
// Draft
_buildStatusTile(
icon: Icons.insert_drive_file_outlined,
color: Colors.cyan,
title: "Draft",
amount: "${data.draft.amount.toStringAsFixed(1)}K",
count: data.draft.count.toString(),
),
const SizedBox(height: 8),
Divider(color: Colors.grey.shade300),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.bodyMedium(
"Project Spendings:",
fontWeight: 600,
),
MyText.bodySmall(
"(All Processed Payments)",
color: Colors.grey.shade600,
),
],
),
MyText.titleLarge(
"${(data.totalAmount / 1000).toStringAsFixed(2)}K >",
color: Colors.blue,
fontWeight: 700,
),
],
),
],
),
),
);
});
}
}

View File

@ -0,0 +1,74 @@
class ExpenseReportResponse {
final bool success;
final String message;
final List<ExpenseReportData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseReportResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseReportResponse.fromJson(Map<String, dynamic> json) {
return ExpenseReportResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseReportData>.from(
json['data'].map((x) => ExpenseReportData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((x) => x.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseReportData {
final String monthName;
final int year;
final double total;
final int count;
ExpenseReportData({
required this.monthName,
required this.year,
required this.total,
required this.count,
});
factory ExpenseReportData.fromJson(Map<String, dynamic> json) {
return ExpenseReportData(
monthName: json['monthName'] ?? '',
year: json['year'] ?? 0,
total: json['total'] != null
? (json['total'] is int
? (json['total'] as int).toDouble()
: json['total'] as double)
: 0.0,
count: json['count'] ?? 0,
);
}
Map<String, dynamic> toJson() => {
'monthName': monthName,
'year': year,
'total': total,
'count': count,
};
}

View File

@ -0,0 +1,105 @@
class ExpenseTypeReportResponse {
final bool success;
final String message;
final ExpenseTypeReportData data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseTypeReportResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseTypeReportResponse.fromJson(Map<String, dynamic> json) {
return ExpenseTypeReportResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: ExpenseTypeReportData.fromJson(json['data'] ?? {}),
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseTypeReportData {
final List<ExpenseTypeReportItem> report;
final double totalAmount;
ExpenseTypeReportData({
required this.report,
required this.totalAmount,
});
factory ExpenseTypeReportData.fromJson(Map<String, dynamic> json) {
return ExpenseTypeReportData(
report: json['report'] != null
? List<ExpenseTypeReportItem>.from(
json['report'].map((x) => ExpenseTypeReportItem.fromJson(x)))
: [],
totalAmount: json['totalAmount'] != null
? (json['totalAmount'] is int
? (json['totalAmount'] as int).toDouble()
: json['totalAmount'] as double)
: 0.0,
);
}
Map<String, dynamic> toJson() => {
'report': report.map((x) => x.toJson()).toList(),
'totalAmount': totalAmount,
};
}
class ExpenseTypeReportItem {
final String projectName;
final double totalApprovedAmount;
final double totalPendingAmount;
final double totalRejectedAmount;
final double totalProcessedAmount;
ExpenseTypeReportItem({
required this.projectName,
required this.totalApprovedAmount,
required this.totalPendingAmount,
required this.totalRejectedAmount,
required this.totalProcessedAmount,
});
factory ExpenseTypeReportItem.fromJson(Map<String, dynamic> json) {
double parseAmount(dynamic value) {
if (value == null) return 0.0;
return value is int ? value.toDouble() : value as double;
}
return ExpenseTypeReportItem(
projectName: json['projectName'] ?? '',
totalApprovedAmount: parseAmount(json['totalApprovedAmount']),
totalPendingAmount: parseAmount(json['totalPendingAmount']),
totalRejectedAmount: parseAmount(json['totalRejectedAmount']),
totalProcessedAmount: parseAmount(json['totalProcessedAmount']),
);
}
Map<String, dynamic> toJson() => {
'projectName': projectName,
'totalApprovedAmount': totalApprovedAmount,
'totalPendingAmount': totalPendingAmount,
'totalRejectedAmount': totalRejectedAmount,
'totalProcessedAmount': totalProcessedAmount,
};
}

View File

@ -0,0 +1,74 @@
class ExpenseTypeResponse {
final bool success;
final String message;
final List<ExpenseTypeData> data;
final dynamic errors;
final int statusCode;
final DateTime timestamp;
ExpenseTypeResponse({
required this.success,
required this.message,
required this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory ExpenseTypeResponse.fromJson(Map<String, dynamic> json) {
return ExpenseTypeResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? List<ExpenseTypeData>.from(
json['data'].map((x) => ExpenseTypeData.fromJson(x)))
: [],
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: DateTime.parse(json['timestamp']),
);
}
Map<String, dynamic> toJson() => {
'success': success,
'message': message,
'data': data.map((x) => x.toJson()).toList(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp.toIso8601String(),
};
}
class ExpenseTypeData {
final String id;
final String name;
final bool noOfPersonsRequired;
final bool isAttachmentRequried;
final String description;
ExpenseTypeData({
required this.id,
required this.name,
required this.noOfPersonsRequired,
required this.isAttachmentRequried,
required this.description,
});
factory ExpenseTypeData.fromJson(Map<String, dynamic> json) {
return ExpenseTypeData(
id: json['id'] ?? '',
name: json['name'] ?? '',
noOfPersonsRequired: json['noOfPersonsRequired'] ?? false,
isAttachmentRequried: json['isAttachmentRequried'] ?? false,
description: json['description'] ?? '',
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'noOfPersonsRequired': noOfPersonsRequired,
'isAttachmentRequried': isAttachmentRequried,
'description': description,
};
}

View File

@ -0,0 +1,169 @@
import 'package:equatable/equatable.dart';
class PendingExpensesResponse extends Equatable {
final bool success;
final String message;
final PendingExpensesData? data;
final dynamic errors;
final int statusCode;
final String timestamp;
const PendingExpensesResponse({
required this.success,
required this.message,
this.data,
this.errors,
required this.statusCode,
required this.timestamp,
});
factory PendingExpensesResponse.fromJson(Map<String, dynamic> json) {
return PendingExpensesResponse(
success: json['success'] ?? false,
message: json['message'] ?? '',
data: json['data'] != null
? PendingExpensesData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'] ?? 0,
timestamp: json['timestamp'] ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp,
};
}
PendingExpensesResponse copyWith({
bool? success,
String? message,
PendingExpensesData? data,
dynamic errors,
int? statusCode,
String? timestamp,
}) {
return PendingExpensesResponse(
success: success ?? this.success,
message: message ?? this.message,
data: data ?? this.data,
errors: errors ?? this.errors,
statusCode: statusCode ?? this.statusCode,
timestamp: timestamp ?? this.timestamp,
);
}
@override
List<Object?> get props => [success, message, data, errors, statusCode, timestamp];
}
class PendingExpensesData extends Equatable {
final ExpenseStatus draft;
final ExpenseStatus reviewPending;
final ExpenseStatus approvePending;
final ExpenseStatus processPending;
final ExpenseStatus submited;
final double totalAmount;
const PendingExpensesData({
required this.draft,
required this.reviewPending,
required this.approvePending,
required this.processPending,
required this.submited,
required this.totalAmount,
});
factory PendingExpensesData.fromJson(Map<String, dynamic> json) {
return PendingExpensesData(
draft: ExpenseStatus.fromJson(json['draft']),
reviewPending: ExpenseStatus.fromJson(json['reviewPending']),
approvePending: ExpenseStatus.fromJson(json['approvePending']),
processPending: ExpenseStatus.fromJson(json['processPending']),
submited: ExpenseStatus.fromJson(json['submited']),
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'draft': draft.toJson(),
'reviewPending': reviewPending.toJson(),
'approvePending': approvePending.toJson(),
'processPending': processPending.toJson(),
'submited': submited.toJson(),
'totalAmount': totalAmount,
};
}
PendingExpensesData copyWith({
ExpenseStatus? draft,
ExpenseStatus? reviewPending,
ExpenseStatus? approvePending,
ExpenseStatus? processPending,
ExpenseStatus? submited,
double? totalAmount,
}) {
return PendingExpensesData(
draft: draft ?? this.draft,
reviewPending: reviewPending ?? this.reviewPending,
approvePending: approvePending ?? this.approvePending,
processPending: processPending ?? this.processPending,
submited: submited ?? this.submited,
totalAmount: totalAmount ?? this.totalAmount,
);
}
@override
List<Object?> get props => [
draft,
reviewPending,
approvePending,
processPending,
submited,
totalAmount,
];
}
class ExpenseStatus extends Equatable {
final int count;
final double totalAmount;
const ExpenseStatus({
required this.count,
required this.totalAmount,
});
factory ExpenseStatus.fromJson(Map<String, dynamic> json) {
return ExpenseStatus(
count: json['count'] ?? 0,
totalAmount: (json['totalAmount'] ?? 0).toDouble(),
);
}
Map<String, dynamic> toJson() {
return {
'count': count,
'totalAmount': totalAmount,
};
}
ExpenseStatus copyWith({
int? count,
double? totalAmount,
}) {
return ExpenseStatus(
count: count ?? this.count,
totalAmount: totalAmount ?? this.totalAmount,
);
}
@override
List<Object?> get props => [count, totalAmount];
}

View File

@ -80,6 +80,7 @@ dependencies:
googleapis_auth: ^2.0.0
device_info_plus: ^11.3.0
flutter_local_notifications: 19.4.0
equatable: ^2.0.7
timeline_tile: ^2.0.0
dev_dependencies: