diff --git a/lib/controller/attendance/attendance_screen_controller.dart b/lib/controller/attendance/attendance_screen_controller.dart index 8e45d48..e0751e8 100644 --- a/lib/controller/attendance/attendance_screen_controller.dart +++ b/lib/controller/attendance/attendance_screen_controller.dart @@ -50,12 +50,6 @@ class AttendanceController extends GetxController { void onInit() { super.onInit(); _initializeDefaults(); - - // 🔹 Fetch organizations for the selected project - final projectId = Get.find().selectedProject?.id; - if (projectId != null) { - fetchOrganizations(projectId); - } } void _initializeDefaults() { diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 03e4d56..50e801a 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -5,6 +5,8 @@ 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'; +import 'package:marco/model/dashboard/monthly_expence_model.dart'; +import 'package:marco/model/expense/expense_type_model.dart'; class DashboardController extends GetxController { // ========================= @@ -49,7 +51,7 @@ class DashboardController extends GetxController { final List ranges = ['7D', '15D', '30D']; // Inject ProjectController - final ProjectController projectController = Get.find(); + final ProjectController projectController = Get.put(ProjectController()); // Pending Expenses overview // ========================= final RxBool isPendingExpensesLoading = false.obs; @@ -61,10 +63,37 @@ class DashboardController extends GetxController { final RxBool isExpenseTypeReportLoading = false.obs; final Rx expenseTypeReportData = Rx(null); - final Rx expenseReportStartDate = - DateTime.now().subtract(const Duration(days: 15)).obs; -final Rx expenseReportEndDate = DateTime.now().obs; + final Rx expenseReportStartDate = + DateTime.now().subtract(const Duration(days: 15)).obs; + final Rx expenseReportEndDate = DateTime.now().obs; + // ========================= + // Monthly Expense Report + // ========================= + final RxBool isMonthlyExpenseLoading = false.obs; + final RxList monthlyExpenseList = + [].obs; + // ========================= + // Monthly Expense Report Filters + // ========================= + final Rx selectedMonthlyExpenseDuration = + MonthlyExpenseDuration.twelveMonths.obs; + final RxInt selectedMonthsCount = 12.obs; + final RxList expenseTypes = [].obs; + final Rx selectedExpenseType = Rx(null); + + void updateSelectedExpenseType(ExpenseTypeModel? type) { + selectedExpenseType.value = type; + + // Debug print to verify + print('Selected: ${type?.name ?? "All Types"}'); + + if (type == null) { + fetchMonthlyExpenses(); + } else { + fetchMonthlyExpenses(categoryId: type.id); + } + } @override void onInit() { @@ -173,10 +202,84 @@ final Rx expenseReportEndDate = DateTime.now().obs; fetchExpenseTypeReport( startDate: expenseReportStartDate.value, endDate: expenseReportEndDate.value, - ) + ), + fetchMonthlyExpenses(), + fetchMasterData() ]); } + void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { + selectedMonthlyExpenseDuration.value = duration; + + // Set months count based on selection + switch (duration) { + case MonthlyExpenseDuration.oneMonth: + selectedMonthsCount.value = 1; + break; + case MonthlyExpenseDuration.threeMonths: + selectedMonthsCount.value = 3; + break; + case MonthlyExpenseDuration.sixMonths: + selectedMonthsCount.value = 6; + break; + case MonthlyExpenseDuration.twelveMonths: + selectedMonthsCount.value = 12; + break; + case MonthlyExpenseDuration.all: + selectedMonthsCount.value = 0; // 0 = All months in your API + break; + } + + // Re-fetch updated data + fetchMonthlyExpenses(); + } + + Future fetchMasterData() async { + try { + final expenseTypesData = await ApiService.getMasterExpenseTypes(); + if (expenseTypesData is List) { + expenseTypes.value = + expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + } + } catch (e) { + logSafe('Error fetching master data', level: LogLevel.error, error: e); + } + } + + Future fetchMonthlyExpenses({String? categoryId}) async { + try { + isMonthlyExpenseLoading.value = true; + + int months = selectedMonthsCount.value; + logSafe( + 'Fetching Monthly Expense Report for last $months months' + '${categoryId != null ? ' (categoryId: $categoryId)' : ''}', + level: LogLevel.info, + ); + + final response = await ApiService.getDashboardMonthlyExpensesApi( + categoryId: categoryId, + months: months, + ); + + if (response != null && response.success) { + monthlyExpenseList.value = response.data; + logSafe('Monthly Expense Report fetched successfully.', + level: LogLevel.info); + } else { + monthlyExpenseList.clear(); + logSafe('Failed to fetch Monthly Expense Report.', + level: LogLevel.error); + } + } catch (e, st) { + monthlyExpenseList.clear(); + logSafe('Error fetching Monthly Expense Report', + level: LogLevel.error, error: e, stackTrace: st); + } finally { + isMonthlyExpenseLoading.value = false; + } + } + Future fetchPendingExpenses() async { final String projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; @@ -345,3 +448,11 @@ final Rx expenseReportEndDate = DateTime.now().obs; } } } + +enum MonthlyExpenseDuration { + oneMonth, + threeMonths, + sixMonths, + twelveMonths, + all, +} diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 6dbfd33..13aecfa 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -21,6 +21,7 @@ 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'; +import 'package:marco/model/dashboard/monthly_expence_model.dart'; class ApiService { static const bool enableLogs = true; @@ -290,6 +291,48 @@ class ApiService { } } + /// Get Monthly Expense Report (categoryId is optional) + static Future + getDashboardMonthlyExpensesApi({ + String? categoryId, + int months = 12, + }) async { + const endpoint = ApiEndpoints.getDashboardMonthlyExpenses; + logSafe("Fetching Dashboard Monthly Expenses for last $months months"); + + try { + final queryParams = { + 'months': months.toString(), + if (categoryId != null && categoryId.isNotEmpty) + 'categoryId': categoryId, + }; + + final response = await _getRequest( + endpoint, + queryParams: queryParams, + ); + + if (response == null) { + logSafe("Monthly Expense request failed: null response", + level: LogLevel.error); + return null; + } + + final jsonResponse = _parseResponseForAllData(response, + label: "Dashboard Monthly Expenses"); + + if (jsonResponse != null) { + return DashboardMonthlyExpenseResponse.fromJson(jsonResponse); + } + } catch (e, stack) { + logSafe("Exception during getDashboardMonthlyExpensesApi: $e", + level: LogLevel.error); + logSafe("StackTrace: $stack", level: LogLevel.debug); + } + + return null; + } + /// Get Expense Type Report static Future getExpenseTypeReportApi({ required String projectId, diff --git a/lib/helpers/services/notification_action_handler.dart b/lib/helpers/services/notification_action_handler.dart index 3f8a54a..0293db9 100644 --- a/lib/helpers/services/notification_action_handler.dart +++ b/lib/helpers/services/notification_action_handler.dart @@ -66,7 +66,6 @@ class NotificationActionHandler { } break; case 'Team_Modified': - // Call method to handle team modifications and dashboard update _handleDashboardUpdate(data); break; /// 🔹 Expenses @@ -106,7 +105,6 @@ class NotificationActionHandler { /// ---------------------- HANDLERS ---------------------- - static bool _isAttendanceAction(String? action) { const validActions = { 'CHECK_IN', @@ -120,13 +118,17 @@ class NotificationActionHandler { } static void _handleExpenseUpdated(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("â„šī¸ Ignored expense update from another project."); + return; + } + final expenseId = data['ExpenseId']; if (expenseId == null) { _logger.w("âš ī¸ Expense update received without ExpenseId: $data"); return; } - // Update Expense List _safeControllerUpdate( onFound: (controller) async { await controller.fetchExpenses(); @@ -136,7 +138,6 @@ class NotificationActionHandler { '✅ ExpenseController refreshed from expense notification.', ); - // Update Expense Detail (if open and matches this expenseId) _safeControllerUpdate( onFound: (controller) async { if (controller.expense.value?.id == expenseId) { @@ -151,6 +152,11 @@ class NotificationActionHandler { } static void _handleAttendanceUpdated(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("â„šī¸ Ignored attendance update from another project."); + return; + } + _safeControllerUpdate( onFound: (controller) => controller.refreshDataFromNotification( projectId: data['ProjectId'], @@ -160,13 +166,18 @@ class NotificationActionHandler { ); } + /// ---------------------- DOCUMENT HANDLER ---------------------- static void _handleDocumentModified(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("â„šī¸ Ignored document update from another project."); + return; + } + String entityTypeId; String entityId; String? documentId = data['DocumentId']; - // Determine entity type and ID if (data['Keyword'] == 'Employee_Document_Modified') { entityTypeId = Permissions.employeeEntity; entityId = data['EmployeeId'] ?? ''; @@ -186,7 +197,6 @@ class NotificationActionHandler { _logger.i( "🔔 Document notification received: keyword=${data['Keyword']}, entityTypeId=$entityTypeId, entityId=$entityId, documentId=$documentId"); - // Refresh Document List if (Get.isRegistered()) { _safeControllerUpdate( onFound: (controller) async { @@ -204,11 +214,9 @@ class NotificationActionHandler { _logger.w('âš ī¸ DocumentController not registered, skipping list refresh.'); } - // Refresh Document Details (if open) if (documentId != null && Get.isRegistered()) { _safeControllerUpdate( onFound: (controller) async { - // Refresh details regardless of current document await controller.fetchDocumentDetails(documentId); _logger.i( "✅ DocumentDetailsController refreshed for Document $documentId"); @@ -225,13 +233,10 @@ class NotificationActionHandler { /// ---------------------- DIRECTORY HANDLERS ---------------------- static void _handleContactModified(Map data) { - final contactId = data['ContactId']; - - // Always refresh the contact list _safeControllerUpdate( onFound: (controller) { controller.fetchContacts(); - // If a specific contact is provided, refresh its notes as well + final contactId = data['ContactId']; if (contactId != null) { controller.fetchCommentsForContact(contactId); } @@ -242,7 +247,6 @@ class NotificationActionHandler { '✅ Directory contacts (and notes if applicable) refreshed from notification.', ); - // Refresh notes globally as well _safeControllerUpdate( onFound: (controller) => controller.fetchNotes(), notFoundMessage: 'âš ī¸ NotesController not found, cannot refresh notes.', @@ -251,7 +255,6 @@ class NotificationActionHandler { } static void _handleContactNoteModified(Map data) { - // Refresh both contacts and notes when a note is modified _handleContactModified(data); } @@ -273,6 +276,11 @@ class NotificationActionHandler { /// ---------------------- DASHBOARD HANDLER ---------------------- static void _handleDashboardUpdate(Map data) { + if (!_isCurrentProject(data)) { + _logger.i("â„šī¸ Ignored dashboard update from another project."); + return; + } + _safeControllerUpdate( onFound: (controller) async { final type = data['type'] ?? ''; @@ -296,11 +304,9 @@ class NotificationActionHandler { controller.projectController.selectedProjectId.value; final projectIdsString = data['ProjectIds'] ?? ''; - // Convert comma-separated string to List final notificationProjectIds = projectIdsString.split(',').map((e) => e.trim()).toList(); - // Refresh only if current project ID is in the list if (notificationProjectIds.contains(currentProjectId)) { await controller.fetchDashboardTeams(projectId: currentProjectId); } @@ -324,6 +330,24 @@ class NotificationActionHandler { /// ---------------------- UTILITY ---------------------- + static bool _isCurrentProject(Map data) { + try { + final dashboard = Get.find(); + final currentProjectId = + dashboard.projectController.selectedProjectId.value; + final notificationProjectId = data['ProjectId']?.toString(); + + if (notificationProjectId == null || notificationProjectId.isEmpty) { + return true; // No project info → allow global refresh + } + + return notificationProjectId == currentProjectId; + } catch (e) { + _logger.w("âš ī¸ Could not verify project context: $e"); + return true; + } + } + static void _safeControllerUpdate({ required void Function(T controller) onFound, required String notFoundMessage, diff --git a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart index 8204c16..fda9928 100644 --- a/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -480,8 +480,8 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), iconHeight: 10, iconWidth: 10, - itemPadding: widget.isMobile ? 6 : 10, - padding: widget.isMobile ? 10 : 14, + itemPadding: widget.isMobile ? 12 : 20, + padding: widget.isMobile ? 20 : 28, ), tooltipBehavior: _tooltipBehavior, // Center annotation showing total approved amount @@ -556,7 +556,7 @@ class _ExpenseDonutChartState extends State<_ExpenseDonutChart> { ), labelIntersectAction: LabelIntersectAction.shift, ), - innerRadius: widget.isMobile ? '40%' : '45%', + innerRadius: widget.isMobile ? '65%' : '70%', radius: widget.isMobile ? '75%' : '80%', explode: true, explodeAll: false, diff --git a/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart b/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart new file mode 100644 index 0000000..d3e27cf --- /dev/null +++ b/lib/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart @@ -0,0 +1,520 @@ +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/helpers/utils/utils.dart'; +import 'package:intl/intl.dart'; + +// ========================= +// CONSTANTS +// ========================= +class _ChartConstants { + static const List flatColors = [ + Color(0xFFE57373), + Color(0xFF64B5F6), + Color(0xFF81C784), + Color(0xFFFFB74D), + Color(0xFFBA68C8), + Color(0xFFFF8A65), + Color(0xFF4DB6AC), + Color(0xFFA1887F), + Color(0xFFDCE775), + Color(0xFF9575CD), + Color(0xFF7986CB), + Color(0xFFAED581), + Color(0xFFFF7043), + Color(0xFF4FC3F7), + Color(0xFFFFD54F), + Color(0xFF90A4AE), + Color(0xFFE573BB), + Color(0xFF81D4FA), + Color(0xFFBCAAA4), + Color(0xFFA5D6A7), + Color(0xFFCE93D8), + Color(0xFFFF8A65), + Color(0xFF80CBC4), + Color(0xFFFFF176), + Color(0xFF90CAF9), + Color(0xFFE0E0E0), + Color(0xFFF48FB1), + Color(0xFFA1887F), + Color(0xFFB0BEC5), + Color(0xFF81C784), + Color(0xFFFFB74D), + Color(0xFF64B5F6), + ]; + + static const Map durationLabels = { + MonthlyExpenseDuration.oneMonth: "1M", + MonthlyExpenseDuration.threeMonths: "3M", + MonthlyExpenseDuration.sixMonths: "6M", + MonthlyExpenseDuration.twelveMonths: "12M", + MonthlyExpenseDuration.all: "All", + }; + + static const double mobileBreakpoint = 600; + static const double mobileChartHeight = 350; + static const double desktopChartHeight = 400; + static const double mobilePadding = 12; + static const double desktopPadding = 20; + static const double mobileVerticalPadding = 16; + static const double desktopVerticalPadding = 20; + static const double noDataIconSize = 48; + static const double noDataContainerHeight = 220; + static const double labelRotation = 45; + static const int tooltipAnimationDuration = 300; +} + +// ========================= +// MAIN CHART WIDGET +// ========================= +class MonthlyExpenseDashboardChart extends StatelessWidget { + MonthlyExpenseDashboardChart({Key? key}) : super(key: key); + + final DashboardController _controller = Get.find(); + + Color _getColorForIndex(int index) => + _ChartConstants.flatColors[index % _ChartConstants.flatColors.length]; + + 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), + ), + ], + ); + + bool _isMobileLayout(double screenWidth) => + screenWidth < _ChartConstants.mobileBreakpoint; + + double _calculateTotalExpense(List data) => + data.fold(0, (sum, item) => sum + item.total); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isMobile = _isMobileLayout(screenWidth); + + return Obx(() { + final isLoading = _controller.isMonthlyExpenseLoading.value; + final expenseData = _controller.monthlyExpenseList; + final selectedDuration = _controller.selectedMonthlyExpenseDuration.value; + final totalExpense = _calculateTotalExpense(expenseData); + + return Container( + decoration: _containerDecoration, + padding: EdgeInsets.symmetric( + vertical: isMobile + ? _ChartConstants.mobileVerticalPadding + : _ChartConstants.desktopVerticalPadding, + horizontal: isMobile + ? _ChartConstants.mobilePadding + : _ChartConstants.desktopPadding, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _ChartHeader( + controller: _controller, // pass controller explicitly + selectedDuration: selectedDuration, + onDurationChanged: _controller.updateMonthlyExpenseDuration, + totalExpense: totalExpense, + ), + const SizedBox(height: 12), + SizedBox( + height: isMobile + ? _ChartConstants.mobileChartHeight + : _ChartConstants.desktopChartHeight, + child: _buildChartContent( + isLoading: isLoading, + data: expenseData, + isMobile: isMobile, + totalExpense: totalExpense, + ), + ), + ], + ), + ); + }); + } + + Widget _buildChartContent({ + required bool isLoading, + required List data, + required bool isMobile, + required double totalExpense, + }) { + if (isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (data.isEmpty) { + return const _EmptyDataWidget(); + } + + return _MonthlyExpenseChart( + data: data, + getColor: _getColorForIndex, + isMobile: isMobile, + totalExpense: totalExpense, + ); + } +} + +// ========================= +// HEADER WIDGET +// ========================= +class _ChartHeader extends StatelessWidget { + const _ChartHeader({ + Key? key, + required this.controller, // added + required this.selectedDuration, + required this.onDurationChanged, + required this.totalExpense, + }) : super(key: key); + + final DashboardController controller; // added + final MonthlyExpenseDuration selectedDuration; + final ValueChanged onDurationChanged; + final double totalExpense; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTitle(), + const SizedBox(height: 2), + _buildSubtitle(), + const SizedBox(height: 8), + // ========================== + // Row with popup menu on the right + // ========================== + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Obx(() { + final selectedType = controller.selectedExpenseType.value; + + return PopupMenuButton( + tooltip: 'Filter by Expense Type', + onSelected: (String value) { + if (value == 'all') { + controller.updateSelectedExpenseType(null); + } else { + final type = controller.expenseTypes + .firstWhere((t) => t.id == value); + controller.updateSelectedExpenseType(type); + } + }, + itemBuilder: (context) { + final types = controller.expenseTypes; + return [ + PopupMenuItem( + value: 'all', + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('All Types'), + if (selectedType == null) + const Icon(Icons.check, + size: 16, color: Colors.blueAccent), + ], + ), + ), + ...types.map((type) => PopupMenuItem( + value: type.id, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(type.name), + if (selectedType?.id == type.id) + const Icon(Icons.check, + size: 16, color: Colors.blueAccent), + ], + ), + )), + ]; + }, + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + selectedType?.name ?? 'All Types', + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_drop_down, size: 20), + ], + ), + ), + ); + }), + ], + ), + + const SizedBox(height: 8), + _buildDurationSelector(), + ], + ); + } + + Widget _buildTitle() => + MyText.bodyMedium('Monthly Expense Overview', fontWeight: 700); + + Widget _buildSubtitle() => + MyText.bodySmall('Month-wise total expense', color: Colors.grey); + + Widget _buildDurationSelector() { + return Row( + children: _ChartConstants.durationLabels.entries + .map((entry) => _DurationChip( + label: entry.value, + duration: entry.key, + isSelected: selectedDuration == entry.key, + onSelected: onDurationChanged, + )) + .toList(), + ); + } +} + +// ========================= +// DURATION CHIP WIDGET +// ========================= +class _DurationChip extends StatelessWidget { + const _DurationChip({ + Key? key, + required this.label, + required this.duration, + required this.isSelected, + required this.onSelected, + }) : super(key: key); + + final String label; + final MonthlyExpenseDuration duration; + final bool isSelected; + final ValueChanged onSelected; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: ChoiceChip( + label: Text(label, style: const TextStyle(fontSize: 12)), + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 0), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + selected: isSelected, + onSelected: (_) => onSelected(duration), + selectedColor: Colors.blueAccent.withOpacity(0.15), + backgroundColor: Colors.grey.shade200, + labelStyle: TextStyle( + color: isSelected ? Colors.blueAccent : Colors.black87, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + side: BorderSide( + color: isSelected ? Colors.blueAccent : Colors.grey.shade300, + ), + ), + ), + ); + } +} + +// ========================= +// EMPTY DATA WIDGET +// ========================= +class _EmptyDataWidget extends StatelessWidget { + const _EmptyDataWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: _ChartConstants.noDataContainerHeight, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outline, + color: Colors.grey.shade400, + size: _ChartConstants.noDataIconSize, + ), + const SizedBox(height: 10), + MyText.bodyMedium( + 'No monthly expense data available.', + textAlign: TextAlign.center, + color: Colors.grey.shade500, + ), + ], + ), + ), + ); + } +} + +// ========================= +// CHART WIDGET +// ========================= +class _MonthlyExpenseChart extends StatelessWidget { + const _MonthlyExpenseChart({ + Key? key, + required this.data, + required this.getColor, + required this.isMobile, + required this.totalExpense, + }) : super(key: key); + + final List data; + final Color Function(int index) getColor; + final bool isMobile; + final double totalExpense; + + @override + Widget build(BuildContext context) { + return SfCartesianChart( + tooltipBehavior: _buildTooltipBehavior(), + primaryXAxis: _buildXAxis(), + primaryYAxis: _buildYAxis(), + series: [_buildColumnSeries()], + ); + } + + TooltipBehavior _buildTooltipBehavior() { + return TooltipBehavior( + enable: true, + builder: _tooltipBuilder, + animationDuration: _ChartConstants.tooltipAnimationDuration, + ); + } + + Widget _tooltipBuilder( + dynamic data, + dynamic point, + dynamic series, + int pointIndex, + int seriesIndex, + ) { + final value = data.total as double; + final percentage = totalExpense > 0 ? (value / totalExpense * 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.monthName} ${data.year}', + 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, + ), + ), + ], + ), + ); + } + + CategoryAxis _buildXAxis() { + return CategoryAxis( + labelRotation: _ChartConstants.labelRotation.toInt(), + majorGridLines: + const MajorGridLines(width: 0), // removes X-axis grid lines + ); + } + + NumericAxis _buildYAxis() { + return NumericAxis( + numberFormat: NumberFormat.simpleCurrency( + locale: 'en_IN', + name: '₹', + decimalDigits: 0, + ), + axisLabelFormatter: (AxisLabelRenderDetails args) { + return ChartAxisLabel(Utils.formatCurrency(args.value), null); + }, + majorGridLines: + const MajorGridLines(width: 0), // removes Y-axis grid lines + ); + } + + ColumnSeries _buildColumnSeries() { + return ColumnSeries( + dataSource: data, + xValueMapper: (d, _) => _ChartFormatter.formatMonthYear(d), + yValueMapper: (d, _) => d.total, + pointColorMapper: (_, index) => getColor(index), + name: 'Monthly Expense', + borderRadius: BorderRadius.circular(4), + dataLabelSettings: _buildDataLabelSettings(), + ); + } + + DataLabelSettings _buildDataLabelSettings() { + return DataLabelSettings( + isVisible: true, + builder: (data, _, __, ___, ____) => Text( + Utils.formatCurrency(data.total), + style: const TextStyle(fontSize: 11), + ), + ); + } +} + +// ========================= +// FORMATTER HELPER +// ========================= +class _ChartFormatter { + static String formatMonthYear(dynamic data) { + try { + final month = data.month ?? 1; + final year = data.year ?? DateTime.now().year; + final date = DateTime(year, month, 1); + final monthName = DateFormat('MMM').format(date); + final shortYear = year % 100; + return '$shortYear $monthName'; + } catch (e) { + return '${data.monthName} ${data.year}'; + } + } +} diff --git a/lib/model/dashboard/monthly_expence_model.dart b/lib/model/dashboard/monthly_expence_model.dart new file mode 100644 index 0000000..f28082b --- /dev/null +++ b/lib/model/dashboard/monthly_expence_model.dart @@ -0,0 +1,70 @@ +class DashboardMonthlyExpenseResponse { + final bool success; + final String message; + final List data; + final dynamic errors; + final int statusCode; + final String timestamp; + + DashboardMonthlyExpenseResponse({ + required this.success, + required this.message, + required this.data, + this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory DashboardMonthlyExpenseResponse.fromJson(Map json) { + return DashboardMonthlyExpenseResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: (json['data'] as List?) + ?.map((e) => MonthlyExpenseData.fromJson(e)) + .toList() ?? + [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: json['timestamp'] ?? '', + ); + } + + Map toJson() => { + 'success': success, + 'message': message, + 'data': data.map((e) => e.toJson()).toList(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp, + }; +} + +class MonthlyExpenseData { + final String monthName; + final int year; + final double total; + final int count; + + MonthlyExpenseData({ + required this.monthName, + required this.year, + required this.total, + required this.count, + }); + + factory MonthlyExpenseData.fromJson(Map json) { + return MonthlyExpenseData( + monthName: json['monthName'] ?? '', + year: json['year'] ?? 0, + total: (json['total'] ?? 0).toDouble(), + count: json['count'] ?? 0, + ); + } + + Map toJson() => { + 'monthName': monthName, + 'year': year, + 'total': total, + 'count': count, + }; +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index a8f2b5d..1211068 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -13,10 +13,12 @@ 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'; +import 'package:marco/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; + class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); - + static const String employeesRoute = "/dashboard/employees"; static const String attendanceRoute = "/dashboard/attendance"; static const String directoryMainPageRoute = "/dashboard/directory-main-page"; @@ -63,6 +65,8 @@ class _DashboardScreenState extends State with UIMixin { // Expense Type Report Chart ExpenseTypeReportChart(), + MySpacing.height(24), + MonthlyExpenseDashboardChart(), ], ), ),