From 6d5137b103fefc4bfb85ca126b9f374e8e1d086c Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 30 Oct 2025 15:50:55 +0530 Subject: [PATCH] feat: Add Expense Type Report feature with chart visualization and API integration --- .../dashboard/dashboard_controller.dart | 56 ++++- lib/helpers/services/api_service.dart | 47 +++- lib/helpers/utils/utils.dart | 9 + .../dashbaord/expense_breakdown_chart.dart | 229 ++++++++++++++++++ .../dashbaord/expense_by_status_widget.dart | 187 ++++++-------- lib/view/dashboard/dashboard_screen.dart | 18 +- 6 files changed, 426 insertions(+), 120 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 6eb76f3..3fe4e65 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -4,6 +4,7 @@ import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; class DashboardController extends GetxController { // ========================= @@ -54,6 +55,15 @@ class DashboardController extends GetxController { final RxBool isPendingExpensesLoading = false.obs; final Rx pendingExpensesData = Rx(null); + // ========================= +// Expense Type Report +// ========================= + final RxBool isExpenseTypeReportLoading = false.obs; + final Rx expenseTypeReportData = + Rx(null); + final Rx expenseReportStartDate = DateTime.now().obs; + final Rx expenseReportEndDate = DateTime.now().obs; + @override void onInit() { super.onInit(); @@ -69,7 +79,12 @@ class DashboardController extends GetxController { ever(projectController.selectedProjectId, (id) { fetchAllDashboardData(); }); - + everAll([expenseReportStartDate, expenseReportEndDate], (_) { + fetchExpenseTypeReport( + startDate: expenseReportStartDate.value, + endDate: expenseReportEndDate.value, + ); + }); // React to range changes ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(projectSelectedRange, (_) => fetchProjectProgress()); @@ -152,7 +167,11 @@ class DashboardController extends GetxController { fetchProjectProgress(), fetchDashboardTasks(projectId: projectId), fetchDashboardTeams(projectId: projectId), - fetchPendingExpenses(), + fetchPendingExpenses(), + fetchExpenseTypeReport( + startDate: expenseReportStartDate.value, + endDate: expenseReportEndDate.value, + ) ]); } @@ -213,6 +232,39 @@ class DashboardController extends GetxController { } } + Future 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 fetchProjectProgress() async { final String projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 581c0e2..6dbfd33 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -20,13 +20,14 @@ import 'package:marco/model/document/document_details_model.dart'; import 'package:marco/model/document/document_version_model.dart'; import 'package:marco/model/attendance/organization_per_project_list_model.dart'; import 'package:marco/model/dashboard/pending_expenses_model.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; class ApiService { static const bool enableLogs = true; static const Duration extendedTimeout = Duration(seconds: 60); static Future _getToken() async { - final token = LocalStorage.getJwtToken(); + final token = LocalStorage.getJwtToken(); if (token == null) { logSafe("No JWT token found. Logging out..."); @@ -39,7 +40,7 @@ class ApiService { logSafe("Access token is expired. Attempting refresh..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed. Logging out immediately..."); await LocalStorage.logout(); @@ -56,7 +57,7 @@ class ApiService { "Access token is about to expire in ${difference.inSeconds}s. Refreshing..."); final refreshed = await AuthService.refreshToken(); if (refreshed) { - return LocalStorage.getJwtToken(); + return LocalStorage.getJwtToken(); } else { logSafe("Token refresh failed (near expiry). Logging out..."); await LocalStorage.logout(); @@ -289,6 +290,46 @@ class ApiService { } } + /// Get Expense Type Report + static Future 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 getPendingExpensesApi({ required String projectId, diff --git a/lib/helpers/utils/utils.dart b/lib/helpers/utils/utils.dart index a020a48..10ae5c5 100644 --- a/lib/helpers/utils/utils.dart +++ b/lib/helpers/utils/utils.dart @@ -1,3 +1,4 @@ +import 'package:intl/intl.dart'; import 'package:marco/helpers/extensions/date_time_extension.dart'; class Utils { @@ -76,4 +77,12 @@ class Utils { 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)}"; + } } diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart new file mode 100644 index 0000000..17a79b3 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -0,0 +1,229 @@ +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'; + +class ExpenseTypeReportChart extends StatelessWidget { + ExpenseTypeReportChart({Key? key}) : super(key: key); + + final DashboardController _controller = Get.find(); + + static const List _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; + + return Obx(() { + final isLoading = _controller.isExpenseTypeReportLoading.value; + final data = _controller.expenseTypeReportData.value; + + return Container( + decoration: _containerDecoration, + padding: EdgeInsets.symmetric( + vertical: 16, + horizontal: screenWidth < 600 ? 8 : 20, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Header(), + const SizedBox(height: 12), + // 👇 replace Expanded with fixed height + SizedBox( + height: 350, // choose based on your design + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : data == null || data.report.isEmpty + ? const _NoDataMessage() + : _ExpenseChart( + data: data, + getSeriesColor: _getSeriesColor, + ), + ), + ], + ), + ); + }); + } + + BoxDecoration get _containerDecoration => 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), + ), + ], + ); +} + +class _Header extends StatelessWidget { + const _Header({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Expense Type Overview', fontWeight: 700), + const SizedBox(height: 2), + MyText.bodySmall( + 'Project-wise approved, pending, rejected & processed expenses', + color: Colors.grey), + ], + ); + } +} + +// No data +class _NoDataMessage extends StatelessWidget { + const _NoDataMessage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.info_outline, color: Colors.grey.shade400, size: 48), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No expense data available.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], + ), + ), + ); + } +} + +// Chart +class _ExpenseChart extends StatelessWidget { + const _ExpenseChart({ + Key? key, + required this.data, + required this.getSeriesColor, + }) : super(key: key); + + final ExpenseTypeReportData data; + final Color Function(int index) getSeriesColor; + + @override + Widget build(BuildContext context) { + final List> chartSeries = [ + { + 'name': 'Approved', + 'color': getSeriesColor(0), + 'yValue': (ExpenseTypeReportItem e) => e.totalApprovedAmount, + }, + { + 'name': 'Pending', + 'color': getSeriesColor(1), + 'yValue': (ExpenseTypeReportItem e) => e.totalPendingAmount, + }, + { + 'name': 'Rejected', + 'color': getSeriesColor(2), + 'yValue': (ExpenseTypeReportItem e) => e.totalRejectedAmount, + }, + { + 'name': 'Processed', + 'color': getSeriesColor(3), + 'yValue': (ExpenseTypeReportItem e) => e.totalProcessedAmount, + }, + ]; + + return SfCartesianChart( + tooltipBehavior: TooltipBehavior( + enable: true, + shared: true, + builder: (data, point, series, pointIndex, seriesIndex) { + final ExpenseTypeReportItem item = data; + final value = chartSeries[seriesIndex]['yValue'](item); + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.black87, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${chartSeries[seriesIndex]['name']}: ${Utils.formatCurrency(value)}', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ); + }, + ), + legend: Legend(isVisible: true, position: LegendPosition.bottom), + primaryXAxis: CategoryAxis(labelRotation: 45), + primaryYAxis: NumericAxis( + // ✅ Format axis labels with Utils + axisLabelFormatter: (AxisLabelRenderDetails details) { + final num value = details.value; + return ChartAxisLabel( + Utils.formatCurrency(value), const TextStyle(fontSize: 10)); + }, + axisLine: const AxisLine(width: 0), + majorGridLines: const MajorGridLines(width: 0.5), + ), + series: chartSeries.map((seriesInfo) { + return ColumnSeries( + dataSource: data.report, + xValueMapper: (item, _) => item.projectName, + yValueMapper: (item, _) => seriesInfo['yValue'](item), + name: seriesInfo['name'], + color: seriesInfo['color'], + dataLabelSettings: const DataLabelSettings(isVisible: true), + // ✅ Format data labels as well + dataLabelMapper: (item, _) => + Utils.formatCurrency(seriesInfo['yValue'](item)), + ); + }).toList(), + ); + } +} diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart index f3406ea..264ab34 100644 --- a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -2,6 +2,7 @@ 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'; class ExpenseByStatusWidget extends StatelessWidget { final DashboardController controller; @@ -15,43 +16,28 @@ class ExpenseByStatusWidget extends StatelessWidget { required String amount, required String count, }) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - decoration: BoxDecoration( - color: Colors.grey.shade100, - borderRadius: BorderRadius.circular(12), - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ CircleAvatar( - backgroundColor: Colors.white, - radius: 20, - child: Icon(icon, color: color, size: 22), + 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.titleMedium( - title, - fontWeight: 600, - ), + MyText.bodyMedium(title, fontWeight: 600), const SizedBox(height: 2), - MyText.bodyMedium( - amount, - color: Colors.blue, - ), + MyText.titleMedium(amount, color: Colors.blue, fontWeight: 700), ], ), ), - MyText.titleMedium( - count, - color: Colors.blue, - fontWeight: 600, - ), - const Icon(Icons.chevron_right, color: Colors.blue, size: 22), + MyText.titleMedium(count, color: Colors.blue, fontWeight: 700), + const Icon(Icons.chevron_right, color: Colors.blue, size: 24), ], ), ); @@ -72,90 +58,75 @@ class ExpenseByStatusWidget extends StatelessWidget { ); } - return Card( - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.titleLarge( - "Expense - By Status", - fontWeight: 700, - ), - const SizedBox(height: 4), - MyText.bodyMedium( - controller.projectController.selectedProjectName.value, - color: Colors.grey.shade600, - ), - const SizedBox(height: 16), - - // Pending Payment - _buildStatusTile( - icon: Icons.currency_rupee, - color: Colors.blue, - title: "Pending Payment", - amount: "₹${data.processPending.amount.toStringAsFixed(1)}K", - count: data.processPending.count.toString(), - ), - - // Pending Approve - _buildStatusTile( - icon: Icons.check_circle_outline, - color: Colors.orange, - title: "Pending Approve", - amount: "₹${data.approvePending.amount.toStringAsFixed(1)}K", - count: data.approvePending.count.toString(), - ), - - // Pending Review - _buildStatusTile( - icon: Icons.search, - color: Colors.grey.shade700, - title: "Pending Review", - amount: "₹${data.reviewPending.amount.toStringAsFixed(1)}K", - count: data.reviewPending.count.toString(), - ), - - // Draft - _buildStatusTile( - icon: Icons.insert_drive_file_outlined, - color: Colors.cyan, - title: "Draft", - amount: "₹${data.draft.amount.toStringAsFixed(1)}K", - count: data.draft.count.toString(), - ), - - const SizedBox(height: 8), - Divider(color: Colors.grey.shade300), - const SizedBox(height: 8), - - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodyMedium( - "Project Spendings:", - fontWeight: 600, - ), - MyText.bodySmall( - "(All Processed Payments)", - color: Colors.grey.shade600, - ), - ], - ), - MyText.titleLarge( - "₹${(data.totalAmount / 1000).toStringAsFixed(2)}K >", - color: Colors.blue, - fontWeight: 700, - ), - ], - ), - ], - ), + 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), + _buildStatusTile( + icon: Icons.currency_rupee, + color: Colors.blue, + title: "Pending Payment", + amount: Utils.formatCurrency(data.processPending.totalAmount), + count: data.processPending.count.toString(), + ), + _buildStatusTile( + icon: Icons.check_circle_outline, + color: Colors.orange, + title: "Pending Approve", + amount: Utils.formatCurrency(data.approvePending.totalAmount), + count: data.approvePending.count.toString(), + ), + _buildStatusTile( + icon: Icons.search, + color: Colors.grey.shade700, + title: "Pending Review", + amount: Utils.formatCurrency(data.reviewPending.totalAmount), + count: data.reviewPending.count.toString(), + ), + _buildStatusTile( + icon: Icons.insert_drive_file_outlined, + color: Colors.cyan, + title: "Draft", + amount: Utils.formatCurrency(data.draft.totalAmount), + count: data.draft.count.toString(), + ), + const SizedBox(height: 16), + Divider(color: Colors.grey.shade300), + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium("Project Spendings:", fontWeight: 600), + MyText.bodySmall("(All Processed Payments)", + color: Colors.grey.shade600), + ], + ), + MyText.titleLarge( + "${Utils.formatCurrency(data.totalAmount)} >", + color: Colors.blue, + fontWeight: 700, + ) + ], + ), + ], ), ); }); diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index ff91892..cf5103f 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -10,7 +10,9 @@ import 'package:marco/helpers/widgets/my_container.dart'; import 'package:marco/helpers/widgets/my_spacing.dart'; import 'package:marco/helpers/widgets/my_text.dart'; import 'package:marco/helpers/widgets/dashbaord/attendance_overview_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; import 'package:marco/view/layouts/layout.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -51,14 +53,22 @@ class _DashboardScreenState extends State with UIMixin { children: [ _buildDashboardStats(context), MySpacing.height(24), + + // 📊 Attendance Section _buildAttendanceChartSection(), + MySpacing.height(24), + + ExpenseByStatusWidget(controller: dashboardController), + MySpacing.height(24), + + // Expense Type Report Chart + ExpenseTypeReportChart(), ], ), ), ); } - /// Attendance Chart Section Widget _buildAttendanceChartSection() { return GetBuilder( id: 'dashboard_controller', @@ -81,7 +91,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// No Project Assigned Message Widget _buildNoProjectMessage() { return Padding( padding: const EdgeInsets.symmetric(vertical: 12), @@ -106,8 +115,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - - /// Dashboard Statistics Section Widget _buildDashboardStats(BuildContext context) { final stats = [ _StatItem(LucideIcons.scan_face, "Attendance", contentTheme.success, @@ -150,7 +157,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Stat Card (Compact + Small) Widget _buildStatCard( _StatItem statItem, bool isProjectSelected, double width) { const double cardHeight = 60; @@ -195,7 +201,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Compact Icon (smaller) Widget _buildStatCardIconCompact(_StatItem statItem, {double size = 12}) { return MyContainer.rounded( paddingAll: 4, @@ -208,7 +213,6 @@ class _DashboardScreenState extends State with UIMixin { ); } - /// Handle Tap void _handleStatCardTap(_StatItem statItem, bool isEnabled) { if (!isEnabled) { Get.defaultDialog(