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:
Vaibhav Surve 2025-11-01 17:29:16 +05:30
parent d15d9f22df
commit 177f8c32e2
12 changed files with 1676 additions and 47 deletions

View File

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

View File

@ -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";

View File

@ -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 {

View File

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

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

View 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),
],
)
],
),
),
),
],
),
);
});
}
}

View File

@ -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(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.end, children: [
children: List.generate(6, (index) { // Icon placeholder
return Expanded( Container(
child: Padding( height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4), width: 44,
child: Container(
height:
(60 + (index * 20)).toDouble(), // fake chart shape
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), shape: BoxShape.circle,
), ),
), ),
const SizedBox(width: 12),
// 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),
),
),
],
), ),
), ),
MySpacing.height(16), // Count + arrow placeholder
Container(
// X-Axis Labels height: 12,
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: List.generate(6, (index) {
return Container(
height: 10,
width: 30, width: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), 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(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4),
),
),
],
), ),
], ],
), ),

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

@ -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(),
], ],
), ),
), ),