feat: Add Expense By Status Widget and related models
- Implemented ExpenseByStatusWidget to display expenses categorized by status. - Added ExpenseReportResponse, ExpenseTypeReportResponse, and related models for handling expense data. - Introduced Skeleton loaders for expense status and charts for better UI experience during data loading. - Updated DashboardScreen to include the new ExpenseByStatusWidget and ensure proper integration with existing components.
This commit is contained in:
parent
d15d9f22df
commit
177f8c32e2
@ -3,6 +3,8 @@ import 'package:marco/helpers/services/app_logger.dart';
|
|||||||
import 'package:marco/helpers/services/api_service.dart';
|
import 'package:marco/helpers/services/api_service.dart';
|
||||||
import 'package:marco/controller/project_controller.dart';
|
import 'package:marco/controller/project_controller.dart';
|
||||||
import 'package:marco/model/dashboard/project_progress_model.dart';
|
import 'package:marco/model/dashboard/project_progress_model.dart';
|
||||||
|
import 'package:marco/model/dashboard/pending_expenses_model.dart';
|
||||||
|
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
||||||
|
|
||||||
class DashboardController extends GetxController {
|
class DashboardController extends GetxController {
|
||||||
// =========================
|
// =========================
|
||||||
@ -46,9 +48,23 @@ class DashboardController extends GetxController {
|
|||||||
// Common ranges
|
// Common ranges
|
||||||
final List<String> ranges = ['7D', '15D', '30D'];
|
final List<String> ranges = ['7D', '15D', '30D'];
|
||||||
|
|
||||||
// Inside your DashboardController
|
// Inject ProjectController
|
||||||
final ProjectController projectController =
|
final ProjectController projectController = Get.find<ProjectController>();
|
||||||
Get.put(ProjectController(), permanent: true);
|
// Pending Expenses overview
|
||||||
|
// =========================
|
||||||
|
final RxBool isPendingExpensesLoading = false.obs;
|
||||||
|
final Rx<PendingExpensesData?> pendingExpensesData =
|
||||||
|
Rx<PendingExpensesData?>(null);
|
||||||
|
// =========================
|
||||||
|
// Expense Type Report
|
||||||
|
// =========================
|
||||||
|
final RxBool isExpenseTypeReportLoading = false.obs;
|
||||||
|
final Rx<ExpenseTypeReportData?> expenseTypeReportData =
|
||||||
|
Rx<ExpenseTypeReportData?>(null);
|
||||||
|
final Rx<DateTime> expenseReportStartDate =
|
||||||
|
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||||
|
final Rx<DateTime> expenseReportEndDate = DateTime.now().obs;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
@ -65,7 +81,12 @@ class DashboardController extends GetxController {
|
|||||||
ever<String>(projectController.selectedProjectId, (id) {
|
ever<String>(projectController.selectedProjectId, (id) {
|
||||||
fetchAllDashboardData();
|
fetchAllDashboardData();
|
||||||
});
|
});
|
||||||
|
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||||
|
fetchExpenseTypeReport(
|
||||||
|
startDate: expenseReportStartDate.value,
|
||||||
|
endDate: expenseReportEndDate.value,
|
||||||
|
);
|
||||||
|
});
|
||||||
// React to range changes
|
// React to range changes
|
||||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||||
@ -148,9 +169,39 @@ class DashboardController extends GetxController {
|
|||||||
fetchProjectProgress(),
|
fetchProjectProgress(),
|
||||||
fetchDashboardTasks(projectId: projectId),
|
fetchDashboardTasks(projectId: projectId),
|
||||||
fetchDashboardTeams(projectId: projectId),
|
fetchDashboardTeams(projectId: projectId),
|
||||||
|
fetchPendingExpenses(),
|
||||||
|
fetchExpenseTypeReport(
|
||||||
|
startDate: expenseReportStartDate.value,
|
||||||
|
endDate: expenseReportEndDate.value,
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// API Calls
|
||||||
// =========================
|
// =========================
|
||||||
@ -183,6 +234,39 @@ class DashboardController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> fetchExpenseTypeReport({
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
final String projectId = projectController.selectedProjectId.value;
|
||||||
|
if (projectId.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
isExpenseTypeReportLoading.value = true;
|
||||||
|
|
||||||
|
final response = await ApiService.getExpenseTypeReportApi(
|
||||||
|
projectId: projectId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response != null && response.success) {
|
||||||
|
expenseTypeReportData.value = response.data;
|
||||||
|
logSafe('Expense Type Report fetched successfully.',
|
||||||
|
level: LogLevel.info);
|
||||||
|
} else {
|
||||||
|
expenseTypeReportData.value = null;
|
||||||
|
logSafe('Failed to fetch Expense Type Report.', level: LogLevel.error);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
expenseTypeReportData.value = null;
|
||||||
|
logSafe('Error fetching Expense Type Report',
|
||||||
|
level: LogLevel.error, error: e, stackTrace: st);
|
||||||
|
} finally {
|
||||||
|
isExpenseTypeReportLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchProjectProgress() async {
|
Future<void> fetchProjectProgress() async {
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
final String projectId = projectController.selectedProjectId.value;
|
||||||
if (projectId.isEmpty) return;
|
if (projectId.isEmpty) return;
|
||||||
|
|||||||
@ -10,6 +10,10 @@ class ApiEndpoints {
|
|||||||
static const String getDashboardTasks = "/dashboard/tasks";
|
static const String getDashboardTasks = "/dashboard/tasks";
|
||||||
static const String getDashboardTeams = "/dashboard/teams";
|
static const String getDashboardTeams = "/dashboard/teams";
|
||||||
static const String getDashboardProjects = "/dashboard/projects";
|
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";
|
||||||
|
|
||||||
// Attendance Module API Endpoints
|
// Attendance Module API Endpoints
|
||||||
static const String getProjects = "/project/list";
|
static const String getProjects = "/project/list";
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import 'package:marco/model/tenant/tenant_services_model.dart';
|
|||||||
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
|
import 'package:marco/model/dailyTaskPlanning/daily_task_model.dart';
|
||||||
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
|
import 'package:marco/model/dailyTaskPlanning/daily_progress_report_filter_response_model.dart';
|
||||||
import 'package:marco/model/all_organization_model.dart';
|
import 'package:marco/model/all_organization_model.dart';
|
||||||
|
import 'package:marco/model/dashboard/pending_expenses_model.dart';
|
||||||
|
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
||||||
|
|
||||||
class ApiService {
|
class ApiService {
|
||||||
static const bool enableLogs = true;
|
static const bool enableLogs = true;
|
||||||
@ -292,6 +294,80 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get Expense Type Report
|
||||||
|
static Future<ExpenseTypeReportResponse?> getExpenseTypeReportApi({
|
||||||
|
required String projectId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
const endpoint = ApiEndpoints.getExpenseTypeReport;
|
||||||
|
logSafe("Fetching Expense Type Report for projectId: $projectId");
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _getRequest(
|
||||||
|
endpoint,
|
||||||
|
queryParams: {
|
||||||
|
'projectId': projectId,
|
||||||
|
'startDate': startDate.toIso8601String(),
|
||||||
|
'endDate': endDate.toIso8601String(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response == null) {
|
||||||
|
logSafe("Expense Type Report request failed: null response",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final jsonResponse =
|
||||||
|
_parseResponseForAllData(response, label: "Expense Type Report");
|
||||||
|
|
||||||
|
if (jsonResponse != null) {
|
||||||
|
return ExpenseTypeReportResponse.fromJson(jsonResponse);
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe("Exception during getExpenseTypeReportApi: $e",
|
||||||
|
level: LogLevel.error);
|
||||||
|
logSafe("StackTrace: $stack", level: LogLevel.debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
/// Get Organizations assigned to a Project
|
/// Get Organizations assigned to a Project
|
||||||
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
static Future<OrganizationListResponse?> getAssignedOrganizations(
|
||||||
String projectId) async {
|
String projectId) async {
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:marco/helpers/extensions/date_time_extension.dart';
|
import 'package:marco/helpers/extensions/date_time_extension.dart';
|
||||||
|
|
||||||
class Utils {
|
class Utils {
|
||||||
@ -44,6 +45,10 @@ class Utils {
|
|||||||
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
|
return "$hour:$minute${showSecond ? ":" : ""}$second$meridian";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String formatDate(DateTime date) {
|
||||||
|
return DateFormat('d MMM yyyy').format(date);
|
||||||
|
}
|
||||||
|
|
||||||
static String getDateTimeStringFromDateTime(DateTime dateTime,
|
static String getDateTimeStringFromDateTime(DateTime dateTime,
|
||||||
{bool showSecond = true,
|
{bool showSecond = true,
|
||||||
bool showDate = true,
|
bool showDate = true,
|
||||||
@ -76,4 +81,12 @@ class Utils {
|
|||||||
return "${b.toStringAsFixed(2)} Bytes";
|
return "${b.toStringAsFixed(2)} Bytes";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String formatCurrency(num amount,
|
||||||
|
{String currency = "INR", String locale = "en_US"}) {
|
||||||
|
// Use en_US for standard K, M, B formatting
|
||||||
|
final symbol = NumberFormat.simpleCurrency(name: currency).currencySymbol;
|
||||||
|
final formatter = NumberFormat.compact(locale: 'en_US');
|
||||||
|
return "$symbol${formatter.format(amount)}";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
653
lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart
Normal file
653
lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:syncfusion_flutter_charts/charts.dart';
|
||||||
|
import 'package:marco/controller/dashboard/dashboard_controller.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_text.dart';
|
||||||
|
import 'package:marco/model/dashboard/expense_type_report_model.dart';
|
||||||
|
import 'package:marco/helpers/utils/utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
|
class ExpenseTypeReportChart extends StatelessWidget {
|
||||||
|
ExpenseTypeReportChart({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
final DashboardController _controller = Get.find<DashboardController>();
|
||||||
|
|
||||||
|
// Extended color palette for multiple projects
|
||||||
|
static const List<Color> _flatColors = [
|
||||||
|
Color(0xFFE57373), // Red 300
|
||||||
|
Color(0xFF64B5F6), // Blue 300
|
||||||
|
Color(0xFF81C784), // Green 300
|
||||||
|
Color(0xFFFFB74D), // Orange 300
|
||||||
|
Color(0xFFBA68C8), // Purple 300
|
||||||
|
Color(0xFFFF8A65), // Deep Orange 300
|
||||||
|
Color(0xFF4DB6AC), // Teal 300
|
||||||
|
Color(0xFFA1887F), // Brown 400
|
||||||
|
Color(0xFFDCE775), // Lime 300
|
||||||
|
Color(0xFF9575CD), // Deep Purple 300
|
||||||
|
Color(0xFF7986CB), // Indigo 300
|
||||||
|
Color(0xFFAED581), // Light Green 300
|
||||||
|
Color(0xFFFF7043), // Deep Orange 400
|
||||||
|
Color(0xFF4FC3F7), // Light Blue 300
|
||||||
|
Color(0xFFFFD54F), // Amber 300
|
||||||
|
Color(0xFF90A4AE), // Blue Grey 300
|
||||||
|
Color(0xFFE573BB), // Pink 300
|
||||||
|
Color(0xFF81D4FA), // Light Blue 200
|
||||||
|
Color(0xFFBCAAA4), // Brown 300
|
||||||
|
Color(0xFFA5D6A7), // Green 300
|
||||||
|
Color(0xFFCE93D8), // Purple 200
|
||||||
|
Color(0xFFFF8A65), // Deep Orange 300 (repeat to fill)
|
||||||
|
Color(0xFF80CBC4), // Teal 200
|
||||||
|
Color(0xFFFFF176), // Yellow 300
|
||||||
|
Color(0xFF90CAF9), // Blue 200
|
||||||
|
Color(0xFFE0E0E0), // Grey 300
|
||||||
|
Color(0xFFF48FB1), // Pink 200
|
||||||
|
Color(0xFFA1887F), // Brown 400 (repeat)
|
||||||
|
Color(0xFFB0BEC5), // Blue Grey 200
|
||||||
|
Color(0xFF81C784), // Green 300 (repeat)
|
||||||
|
Color(0xFFFFB74D), // Orange 300 (repeat)
|
||||||
|
Color(0xFF64B5F6), // Blue 300 (repeat)
|
||||||
|
];
|
||||||
|
|
||||||
|
Color _getSeriesColor(int index) => _flatColors[index % _flatColors.length];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final isMobile = screenWidth < 600;
|
||||||
|
|
||||||
|
return Obx(() {
|
||||||
|
final isLoading = _controller.isExpenseTypeReportLoading.value;
|
||||||
|
final data = _controller.expenseTypeReportData.value;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
spreadRadius: 1,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: isMobile ? 16 : 20,
|
||||||
|
horizontal: isMobile ? 12 : 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Chart Header
|
||||||
|
isLoading
|
||||||
|
? SkeletonLoaders.dateSkeletonLoader()
|
||||||
|
: _ChartHeader(controller: _controller),
|
||||||
|
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Date Range Picker
|
||||||
|
isLoading
|
||||||
|
? Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(child: SkeletonLoaders.dateSkeletonLoader()),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: _DateRangePicker(controller: _controller),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Chart Area
|
||||||
|
SizedBox(
|
||||||
|
height: isMobile ? 350 : 400,
|
||||||
|
child: isLoading
|
||||||
|
? SkeletonLoaders.chartSkeletonLoader()
|
||||||
|
: (data == null || data.report.isEmpty)
|
||||||
|
? const _NoDataMessage()
|
||||||
|
: _ExpenseDonutChart(
|
||||||
|
data: data,
|
||||||
|
getSeriesColor: _getSeriesColor,
|
||||||
|
isMobile: isMobile,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Chart Header
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ChartHeader extends StatelessWidget {
|
||||||
|
const _ChartHeader({Key? key, required this.controller}) : super(key: key);
|
||||||
|
|
||||||
|
final DashboardController controller;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final data = controller.expenseTypeReportData.value;
|
||||||
|
// Calculate total from totalApprovedAmount only
|
||||||
|
final total = data?.report.fold<double>(
|
||||||
|
0,
|
||||||
|
(sum, e) => sum + e.totalApprovedAmount,
|
||||||
|
) ??
|
||||||
|
0;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('Project Expense Analytics', fontWeight: 700),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
MyText.bodySmall('Approved expenses by project',
|
||||||
|
color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (total > 0)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(color: Colors.blueAccent, width: 1),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Total Approved',
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
Utils.formatCurrency(total),
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Date Range Picker
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _DateRangePicker extends StatelessWidget {
|
||||||
|
const _DateRangePicker({Key? key, required this.controller})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
|
final DashboardController controller;
|
||||||
|
|
||||||
|
Future<void> _selectDate(
|
||||||
|
BuildContext context, bool isStartDate, DateTime currentDate) async {
|
||||||
|
final DateTime? picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: currentDate,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
builder: (context, child) {
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
colorScheme: const ColorScheme.light(
|
||||||
|
primary: Colors.blueAccent,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
onSurface: Colors.black,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (picked != null) {
|
||||||
|
if (isStartDate) {
|
||||||
|
controller.expenseReportStartDate.value = picked;
|
||||||
|
} else {
|
||||||
|
controller.expenseReportEndDate.value = picked;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final startDate = controller.expenseReportStartDate.value;
|
||||||
|
final endDate = controller.expenseReportEndDate.value;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
_DateBox(
|
||||||
|
label: 'Start Date',
|
||||||
|
date: startDate,
|
||||||
|
onTap: () => _selectDate(context, true, startDate),
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_DateBox(
|
||||||
|
label: 'End Date',
|
||||||
|
date: endDate,
|
||||||
|
onTap: () => _selectDate(context, false, endDate),
|
||||||
|
icon: Icons.event_outlined,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DateBox extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final DateTime date;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
const _DateBox({
|
||||||
|
Key? key,
|
||||||
|
required this.label,
|
||||||
|
required this.date,
|
||||||
|
required this.onTap,
|
||||||
|
required this.icon,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Expanded(
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.08),
|
||||||
|
border: Border.all(color: Colors.blueAccent.withOpacity(0.3)),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 14,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
Utils.formatDate(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// No Data Message
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _NoDataMessage extends StatelessWidget {
|
||||||
|
const _NoDataMessage({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.donut_large_outlined,
|
||||||
|
color: Colors.grey.shade400, size: 48),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'No expense data available for this range.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Donut Chart
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ExpenseDonutChart extends StatefulWidget {
|
||||||
|
const _ExpenseDonutChart({
|
||||||
|
Key? key,
|
||||||
|
required this.data,
|
||||||
|
required this.getSeriesColor,
|
||||||
|
required this.isMobile,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
final ExpenseTypeReportData data;
|
||||||
|
final Color Function(int index) getSeriesColor;
|
||||||
|
final bool isMobile;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_ExpenseDonutChart> createState() => _ExpenseDonutChartState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpenseDonutChartState extends State<_ExpenseDonutChart> {
|
||||||
|
late TooltipBehavior _tooltipBehavior;
|
||||||
|
late SelectionBehavior _selectionBehavior;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_tooltipBehavior = TooltipBehavior(
|
||||||
|
enable: true,
|
||||||
|
builder: (dynamic data, dynamic point, dynamic series, int pointIndex,
|
||||||
|
int seriesIndex) {
|
||||||
|
final total = widget.data.report
|
||||||
|
.fold<double>(0, (sum, e) => sum + e.totalApprovedAmount);
|
||||||
|
final value = data.value as double;
|
||||||
|
final percentage = total > 0 ? (value / total * 100) : 0;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
Utils.formatCurrency(value),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${percentage.toStringAsFixed(1)}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
fontSize: 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
elevation: 4,
|
||||||
|
animationDuration: 300,
|
||||||
|
);
|
||||||
|
|
||||||
|
_selectionBehavior = SelectionBehavior(
|
||||||
|
enable: true,
|
||||||
|
selectedColor: Colors.white,
|
||||||
|
selectedBorderColor: Colors.blueAccent,
|
||||||
|
selectedBorderWidth: 3,
|
||||||
|
unselectedOpacity: 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Create donut data from project items using totalApprovedAmount
|
||||||
|
final List<_DonutData> donutData = widget.data.report
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map((entry) => _DonutData(
|
||||||
|
entry.value.projectName.isEmpty
|
||||||
|
? 'Project ${entry.key + 1}'
|
||||||
|
: entry.value.projectName,
|
||||||
|
entry.value.totalApprovedAmount,
|
||||||
|
widget.getSeriesColor(entry.key),
|
||||||
|
Icons.folder_outlined,
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Filter out zero values for cleaner visualization
|
||||||
|
final filteredData = donutData.where((data) => data.value > 0).toList();
|
||||||
|
|
||||||
|
if (filteredData.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No approved expense data for the selected range.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total for center display
|
||||||
|
final total = filteredData.fold<double>(0, (sum, item) => sum + item.value);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: SfCircularChart(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
legend: Legend(
|
||||||
|
isVisible: true,
|
||||||
|
position: LegendPosition.bottom,
|
||||||
|
overflowMode: LegendItemOverflowMode.wrap,
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
iconHeight: 10,
|
||||||
|
iconWidth: 10,
|
||||||
|
itemPadding: widget.isMobile ? 6 : 10,
|
||||||
|
padding: widget.isMobile ? 10 : 14,
|
||||||
|
),
|
||||||
|
tooltipBehavior: _tooltipBehavior,
|
||||||
|
// Center annotation showing total approved amount
|
||||||
|
annotations: <CircularChartAnnotation>[
|
||||||
|
CircularChartAnnotation(
|
||||||
|
widget: Container(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.check_circle_outline,
|
||||||
|
color: Colors.green.shade600,
|
||||||
|
size: widget.isMobile ? 28 : 32,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Total Approved',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 11 : 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
Utils.formatCurrency(total),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 16 : 18,
|
||||||
|
color: Colors.green.shade700,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${filteredData.length} ${filteredData.length == 1 ? 'Project' : 'Projects'}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 9 : 10,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
series: <DoughnutSeries<_DonutData, String>>[
|
||||||
|
DoughnutSeries<_DonutData, String>(
|
||||||
|
dataSource: filteredData,
|
||||||
|
xValueMapper: (datum, _) => datum.label,
|
||||||
|
yValueMapper: (datum, _) => datum.value,
|
||||||
|
pointColorMapper: (datum, _) => datum.color,
|
||||||
|
dataLabelMapper: (datum, _) {
|
||||||
|
final amount = Utils.formatCurrency(datum.value);
|
||||||
|
return widget.isMobile
|
||||||
|
? '$amount'
|
||||||
|
: '${datum.label}\n$amount';
|
||||||
|
},
|
||||||
|
dataLabelSettings: DataLabelSettings(
|
||||||
|
isVisible: true,
|
||||||
|
labelPosition: ChartDataLabelPosition.outside,
|
||||||
|
connectorLineSettings: ConnectorLineSettings(
|
||||||
|
type: ConnectorType.curve,
|
||||||
|
length: widget.isMobile ? '15%' : '18%',
|
||||||
|
width: 1.5,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
textStyle: TextStyle(
|
||||||
|
fontSize: widget.isMobile ? 10 : 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
labelIntersectAction: LabelIntersectAction.shift,
|
||||||
|
),
|
||||||
|
innerRadius: widget.isMobile ? '40%' : '45%',
|
||||||
|
radius: widget.isMobile ? '75%' : '80%',
|
||||||
|
explode: true,
|
||||||
|
explodeAll: false,
|
||||||
|
explodeIndex: 0,
|
||||||
|
explodeOffset: '5%',
|
||||||
|
explodeGesture: ActivationMode.singleTap,
|
||||||
|
startAngle: 90,
|
||||||
|
endAngle: 450,
|
||||||
|
strokeColor: Colors.white,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
enableTooltip: true,
|
||||||
|
animationDuration: 1000,
|
||||||
|
selectionBehavior: _selectionBehavior,
|
||||||
|
opacity: 0.95,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!widget.isMobile) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_ProjectSummary(donutData: filteredData),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Project Summary (Desktop only)
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
class _ProjectSummary extends StatelessWidget {
|
||||||
|
const _ProjectSummary({Key? key, required this.donutData}) : super(key: key);
|
||||||
|
|
||||||
|
final List<_DonutData> donutData;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
children: donutData.map((data) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 120),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: data.color.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border: Border.all(
|
||||||
|
color: data.color.withOpacity(0.4),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(data.icon, color: data.color, size: 18),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
data.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
Utils.formatCurrency(data.value),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: data.color,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DonutData {
|
||||||
|
final String label;
|
||||||
|
final double value;
|
||||||
|
final Color color;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
_DonutData(this.label, this.value, this.color, this.icon);
|
||||||
|
}
|
||||||
242
lib/helpers/widgets/dashbaord/expense_by_status_widget.dart
Normal file
242
lib/helpers/widgets/dashbaord/expense_by_status_widget.dart
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
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';
|
||||||
|
import 'package:marco/helpers/utils/utils.dart';
|
||||||
|
import 'package:marco/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
import 'package:marco/controller/expense/expense_screen_controller.dart';
|
||||||
|
import 'package:marco/view/expense/expense_screen.dart';
|
||||||
|
import 'package:collection/collection.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,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: color.withOpacity(0.15),
|
||||||
|
radius: 22,
|
||||||
|
child: Icon(icon, color: color, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium(title, fontWeight: 600),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
MyText.titleMedium(amount,
|
||||||
|
color: Colors.blue, fontWeight: 700),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MyText.titleMedium(count, color: Colors.blue, fontWeight: 700),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.blue, size: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate with status filter
|
||||||
|
Future<void> _navigateToExpenseWithFilter(
|
||||||
|
BuildContext context, String statusName) async {
|
||||||
|
final expenseController = Get.put(ExpenseController());
|
||||||
|
|
||||||
|
// 1️⃣ Ensure global projects and master data are loaded
|
||||||
|
if (expenseController.projectsMap.isEmpty) {
|
||||||
|
await expenseController.fetchGlobalProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expenseController.expenseStatuses.isEmpty) {
|
||||||
|
await expenseController.fetchMasterData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2️⃣ Auto-select current project from DashboardController
|
||||||
|
final dashboardController = Get.find<DashboardController>();
|
||||||
|
final currentProjectId =
|
||||||
|
dashboardController.projectController.selectedProjectId.value;
|
||||||
|
|
||||||
|
final projectName = expenseController.projectsMap.entries
|
||||||
|
.firstWhereOrNull((entry) => entry.value == currentProjectId)
|
||||||
|
?.key;
|
||||||
|
|
||||||
|
expenseController.selectedProject.value = projectName ?? '';
|
||||||
|
|
||||||
|
// 3️⃣ Select status filter
|
||||||
|
final matchedStatus = expenseController.expenseStatuses.firstWhereOrNull(
|
||||||
|
(e) => e.name.toLowerCase() == statusName.toLowerCase(),
|
||||||
|
);
|
||||||
|
expenseController.selectedStatus.value = matchedStatus?.id ?? '';
|
||||||
|
|
||||||
|
// 4️⃣ Fetch expenses immediately with applied filters
|
||||||
|
await expenseController.fetchExpenses();
|
||||||
|
|
||||||
|
// 5️⃣ Navigate to Expense screen
|
||||||
|
Get.to(() => const ExpenseMainScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate without status filter
|
||||||
|
Future<void> _navigateToExpenseWithoutFilter() async {
|
||||||
|
final expenseController = Get.put(ExpenseController());
|
||||||
|
|
||||||
|
// Ensure global projects loaded
|
||||||
|
if (expenseController.projectsMap.isEmpty) {
|
||||||
|
await expenseController.fetchGlobalProjects();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-select current project
|
||||||
|
final dashboardController = Get.find<DashboardController>();
|
||||||
|
final currentProjectId =
|
||||||
|
dashboardController.projectController.selectedProjectId.value;
|
||||||
|
|
||||||
|
final projectName = expenseController.projectsMap.entries
|
||||||
|
.firstWhereOrNull((entry) => entry.value == currentProjectId)
|
||||||
|
?.key;
|
||||||
|
|
||||||
|
expenseController.selectedProject.value = projectName ?? '';
|
||||||
|
expenseController.selectedStatus.value = '';
|
||||||
|
|
||||||
|
// Fetch expenses with project filter (no status)
|
||||||
|
await expenseController.fetchExpenses();
|
||||||
|
|
||||||
|
// Navigate to Expense screen
|
||||||
|
Get.to(() => const ExpenseMainScreen());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(() {
|
||||||
|
final data = controller.pendingExpensesData.value;
|
||||||
|
|
||||||
|
if (controller.isPendingExpensesLoading.value) {
|
||||||
|
return SkeletonLoaders.expenseByStatusSkeletonLoader();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data == null) {
|
||||||
|
return Center(
|
||||||
|
child: MyText.bodyMedium("No expense status data available"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
spreadRadius: 1,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium("Expense - By Status", fontWeight: 700),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// ✅ Status tiles
|
||||||
|
_buildStatusTile(
|
||||||
|
icon: Icons.currency_rupee,
|
||||||
|
color: Colors.blue,
|
||||||
|
title: "Pending Payment",
|
||||||
|
amount: Utils.formatCurrency(data.processPending.totalAmount),
|
||||||
|
count: data.processPending.count.toString(),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToExpenseWithFilter(context, 'Payment Pending');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildStatusTile(
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: Colors.orange,
|
||||||
|
title: "Pending Approve",
|
||||||
|
amount: Utils.formatCurrency(data.approvePending.totalAmount),
|
||||||
|
count: data.approvePending.count.toString(),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToExpenseWithFilter(context, 'Approval Pending');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildStatusTile(
|
||||||
|
icon: Icons.search,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
title: "Pending Review",
|
||||||
|
amount: Utils.formatCurrency(data.reviewPending.totalAmount),
|
||||||
|
count: data.reviewPending.count.toString(),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToExpenseWithFilter(context, 'Review Pending');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildStatusTile(
|
||||||
|
icon: Icons.insert_drive_file_outlined,
|
||||||
|
color: Colors.cyan,
|
||||||
|
title: "Draft",
|
||||||
|
amount: Utils.formatCurrency(data.draft.totalAmount),
|
||||||
|
count: data.draft.count.toString(),
|
||||||
|
onTap: () {
|
||||||
|
_navigateToExpenseWithFilter(context, 'Draft');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// ✅ Total row tap navigation (no filter)
|
||||||
|
InkWell(
|
||||||
|
onTap: _navigateToExpenseWithoutFilter,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 4),
|
||||||
|
child: 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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
MyText.titleLarge(
|
||||||
|
Utils.formatCurrency(data.totalAmount),
|
||||||
|
color: Colors.blue,
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(Icons.chevron_right,
|
||||||
|
color: Colors.blue, size: 22),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -33,6 +33,65 @@ class SkeletonLoaders {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chart Skeleton Loader (Donut Chart)
|
||||||
|
static Widget chartSkeletonLoader() {
|
||||||
|
return MyCard.bordered(
|
||||||
|
paddingAll: 16,
|
||||||
|
borderRadiusAll: 12,
|
||||||
|
shadow: MyShadow(
|
||||||
|
elevation: 1.5,
|
||||||
|
position: MyShadowPosition.bottom,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Chart Header Placeholder
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Donut Skeleton Placeholder
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.grey.shade300.withOpacity(0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Legend placeholders
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(5, (index) {
|
||||||
|
return Container(
|
||||||
|
width: 100,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Date Skeleton Loader
|
// Date Skeleton Loader
|
||||||
static Widget dateSkeletonLoader() {
|
static Widget dateSkeletonLoader() {
|
||||||
return Container(
|
return Container(
|
||||||
@ -45,68 +104,135 @@ class SkeletonLoaders {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chart Skeleton Loader
|
// Expense By Status Skeleton Loader
|
||||||
static Widget chartSkeletonLoader() {
|
static Widget expenseByStatusSkeletonLoader() {
|
||||||
return MyCard.bordered(
|
return Container(
|
||||||
margin: MySpacing.only(bottom: 12),
|
padding: const EdgeInsets.all(16),
|
||||||
paddingAll: 16,
|
decoration: BoxDecoration(
|
||||||
borderRadiusAll: 16,
|
color: Colors.white,
|
||||||
shadow: MyShadow(
|
borderRadius: BorderRadius.circular(5),
|
||||||
elevation: 1.5,
|
boxShadow: [
|
||||||
position: MyShadowPosition.bottom,
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(0.05),
|
||||||
|
blurRadius: 6,
|
||||||
|
spreadRadius: 1,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Chart Title Placeholder
|
// Title
|
||||||
Container(
|
Container(
|
||||||
height: 14,
|
height: 16,
|
||||||
width: 120,
|
width: 160,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(20),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Chart Bars (variable height for realism)
|
// 4 Status Rows
|
||||||
SizedBox(
|
...List.generate(4, (index) {
|
||||||
height: 180,
|
return Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
child: Row(
|
||||||
children: List.generate(6, (index) {
|
children: [
|
||||||
return Expanded(
|
// Icon placeholder
|
||||||
child: Padding(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
height: 44,
|
||||||
child: Container(
|
width: 44,
|
||||||
height:
|
decoration: BoxDecoration(
|
||||||
(60 + (index * 20)).toDouble(), // fake chart shape
|
color: Colors.grey.shade300,
|
||||||
decoration: BoxDecoration(
|
shape: BoxShape.circle,
|
||||||
color: Colors.grey.shade300,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(width: 12),
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
MySpacing.height(16),
|
// Title + Amount
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// X-Axis Labels
|
// Count + arrow placeholder
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Icon(Icons.chevron_right,
|
||||||
|
color: Colors.grey.shade300, size: 24),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Divider(color: Colors.grey.shade300),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
|
// Bottom Row (Project Spendings)
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: List.generate(6, (index) {
|
children: [
|
||||||
return Container(
|
Column(
|
||||||
height: 10,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
width: 30,
|
children: [
|
||||||
|
Container(
|
||||||
|
height: 12,
|
||||||
|
width: 120,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
height: 10,
|
||||||
|
width: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
height: 16,
|
||||||
|
width: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
74
lib/model/dashboard/expense_report_response_model.dart
Normal file
74
lib/model/dashboard/expense_report_response_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
105
lib/model/dashboard/expense_type_report_model.dart
Normal file
105
lib/model/dashboard/expense_type_report_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
74
lib/model/dashboard/master_expense_types_model.dart
Normal file
74
lib/model/dashboard/master_expense_types_model.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
169
lib/model/dashboard/pending_expenses_model.dart
Normal file
169
lib/model/dashboard/pending_expenses_model.dart
Normal 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];
|
||||||
|
}
|
||||||
@ -14,6 +14,10 @@ import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart';
|
|||||||
import 'package:marco/view/layouts/layout.dart';
|
import 'package:marco/view/layouts/layout.dart';
|
||||||
import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart';
|
import 'package:marco/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||||
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
import 'package:marco/helpers/widgets/dashbaord/dashboard_overview_widgets.dart';
|
||||||
|
import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||||
|
import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class DashboardScreen extends StatefulWidget {
|
class DashboardScreen extends StatefulWidget {
|
||||||
const DashboardScreen({super.key});
|
const DashboardScreen({super.key});
|
||||||
@ -75,6 +79,11 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: DashboardOverviewWidgets.tasksOverview(),
|
child: DashboardOverviewWidgets.tasksOverview(),
|
||||||
),
|
),
|
||||||
|
ExpenseByStatusWidget(controller: dashboardController),
|
||||||
|
MySpacing.height(24),
|
||||||
|
|
||||||
|
// Expense Type Report Chart
|
||||||
|
ExpenseTypeReportChart(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user