From 177f8c32e2fe2ab25e528ffe57ce30f8f68600be Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Sat, 1 Nov 2025 17:29:16 +0530 Subject: [PATCH] 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. --- .../dashboard/dashboard_controller.dart | 92 ++- lib/helpers/services/api_endpoints.dart | 4 + lib/helpers/services/api_service.dart | 78 ++- lib/helpers/utils/utils.dart | 13 + .../dashbaord/expense_breakdown_chart.dart | 653 ++++++++++++++++++ .../dashbaord/expense_by_status_widget.dart | 242 +++++++ lib/helpers/widgets/my_custom_skeleton.dart | 210 ++++-- .../expense_report_response_model.dart | 74 ++ .../dashboard/expense_type_report_model.dart | 105 +++ .../dashboard/master_expense_types_model.dart | 74 ++ .../dashboard/pending_expenses_model.dart | 169 +++++ lib/view/dashboard/dashboard_screen.dart | 9 + 12 files changed, 1676 insertions(+), 47 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart create mode 100644 lib/helpers/widgets/dashbaord/expense_by_status_widget.dart create mode 100644 lib/model/dashboard/expense_report_response_model.dart create mode 100644 lib/model/dashboard/expense_type_report_model.dart create mode 100644 lib/model/dashboard/master_expense_types_model.dart create mode 100644 lib/model/dashboard/pending_expenses_model.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 50a761c..03e4d56 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -3,6 +3,8 @@ import 'package:marco/helpers/services/app_logger.dart'; import 'package:marco/helpers/services/api_service.dart'; import 'package:marco/controller/project_controller.dart'; import 'package:marco/model/dashboard/project_progress_model.dart'; +import 'package:marco/model/dashboard/pending_expenses_model.dart'; +import 'package:marco/model/dashboard/expense_type_report_model.dart'; class DashboardController extends GetxController { // ========================= @@ -46,9 +48,23 @@ class DashboardController extends GetxController { // Common ranges final List ranges = ['7D', '15D', '30D']; -// Inside your DashboardController - final ProjectController projectController = - Get.put(ProjectController(), permanent: true); + // Inject ProjectController + final ProjectController projectController = Get.find(); +// Pending Expenses overview +// ========================= + 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().subtract(const Duration(days: 15)).obs; +final Rx expenseReportEndDate = DateTime.now().obs; + @override void onInit() { @@ -65,7 +81,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()); @@ -148,9 +169,39 @@ class DashboardController extends GetxController { fetchProjectProgress(), fetchDashboardTasks(projectId: projectId), fetchDashboardTeams(projectId: projectId), + fetchPendingExpenses(), + fetchExpenseTypeReport( + startDate: expenseReportStartDate.value, + endDate: expenseReportEndDate.value, + ) ]); } + Future 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 // ========================= @@ -183,6 +234,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_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 5d91f58..d4ad614 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -10,6 +10,10 @@ class ApiEndpoints { static const String getDashboardTasks = "/dashboard/tasks"; static const String getDashboardTeams = "/dashboard/teams"; static const String getDashboardProjects = "/dashboard/projects"; + static const String getDashboardMonthlyExpenses = + "/Dashboard/expense/monthly"; + static const String getExpenseTypeReport = "/Dashboard/expense/type"; + static const String getPendingExpenses = "/Dashboard/expense/pendings"; // Attendance Module API Endpoints static const String getProjects = "/project/list"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 8b20011..2f426ab 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -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_progress_report_filter_response_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 { static const bool enableLogs = true; @@ -292,6 +294,80 @@ 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, + }) 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 static Future getAssignedOrganizations( String projectId) async { @@ -1824,7 +1900,7 @@ class ApiService { _log("Deleting directory contact at $uri"); final response = await _deleteRequest( - "$endpoint?active=false", + "$endpoint?active=false", ); if (response != null && response.statusCode == 200) { diff --git a/lib/helpers/utils/utils.dart b/lib/helpers/utils/utils.dart index a020a48..8a72760 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 { @@ -44,6 +45,10 @@ class Utils { return "$hour:$minute${showSecond ? ":" : ""}$second$meridian"; } + static String formatDate(DateTime date) { + return DateFormat('d MMM yyyy').format(date); + } + static String getDateTimeStringFromDateTime(DateTime dateTime, {bool showSecond = true, bool showDate = true, @@ -76,4 +81,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..8204c16 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/expense_breakdown_chart.dart @@ -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(); + + // Extended color palette for multiple projects + 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; + 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( + 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 _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(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(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( + 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>( + 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); +} diff --git a/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart new file mode 100644 index 0000000..1e66fe6 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/expense_by_status_widget.dart @@ -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 _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(); + 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 _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(); + 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), + ], + ) + ], + ), + ), + ), + ], + ), + ); + }); + } +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 1d48e89..4537143 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -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 static Widget dateSkeletonLoader() { return Container( @@ -45,68 +104,135 @@ class SkeletonLoaders { ); } -// Chart Skeleton Loader - static Widget chartSkeletonLoader() { - return MyCard.bordered( - margin: MySpacing.only(bottom: 12), - paddingAll: 16, - borderRadiusAll: 16, - shadow: MyShadow( - elevation: 1.5, - position: MyShadowPosition.bottom, +// Expense By Status Skeleton Loader + static Widget expenseByStatusSkeletonLoader() { + 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: [ - // Chart Title Placeholder + // Title Container( - height: 14, - width: 120, + height: 16, + width: 160, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(6), ), ), - MySpacing.height(20), + const SizedBox(height: 16), - // Chart Bars (variable height for realism) - SizedBox( - height: 180, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: List.generate(6, (index) { - return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Container( - height: - (60 + (index * 20)).toDouble(), // fake chart shape - decoration: BoxDecoration( - color: Colors.grey.shade300, - borderRadius: BorderRadius.circular(6), - ), + // 4 Status Rows + ...List.generate(4, (index) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + // Icon placeholder + Container( + height: 44, + width: 44, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle, ), ), - ); - }), - ), - ), + const SizedBox(width: 12), - MySpacing.height(16), + // Title + Amount + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: 100, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 6), + Container( + height: 12, + width: 60, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + ], + ), + ), - // X-Axis Labels + // Count + arrow placeholder + Container( + height: 12, + width: 30, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(width: 6), + Icon(Icons.chevron_right, + color: Colors.grey.shade300, size: 24), + ], + ), + ); + }), + + const SizedBox(height: 16), + Divider(color: Colors.grey.shade300), + const SizedBox(height: 12), + + // Bottom Row (Project Spendings) Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: List.generate(6, (index) { - return Container( - height: 10, - width: 30, + 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), ), - ); - }), + ), + ], ), ], ), diff --git a/lib/model/dashboard/expense_report_response_model.dart b/lib/model/dashboard/expense_report_response_model.dart new file mode 100644 index 0000000..4887ac4 --- /dev/null +++ b/lib/model/dashboard/expense_report_response_model.dart @@ -0,0 +1,74 @@ +class ExpenseReportResponse { + final bool success; + final String message; + final List 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 json) { + return ExpenseReportResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? List.from( + json['data'].map((x) => ExpenseReportData.fromJson(x))) + : [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map 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 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 toJson() => { + 'monthName': monthName, + 'year': year, + 'total': total, + 'count': count, + }; +} diff --git a/lib/model/dashboard/expense_type_report_model.dart b/lib/model/dashboard/expense_type_report_model.dart new file mode 100644 index 0000000..7620ee0 --- /dev/null +++ b/lib/model/dashboard/expense_type_report_model.dart @@ -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 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 toJson() => { + 'success': success, + 'message': message, + 'data': data.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; +} + +class ExpenseTypeReportData { + final List report; + final double totalAmount; + + ExpenseTypeReportData({ + required this.report, + required this.totalAmount, + }); + + factory ExpenseTypeReportData.fromJson(Map json) { + return ExpenseTypeReportData( + report: json['report'] != null + ? List.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 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 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 toJson() => { + 'projectName': projectName, + 'totalApprovedAmount': totalApprovedAmount, + 'totalPendingAmount': totalPendingAmount, + 'totalRejectedAmount': totalRejectedAmount, + 'totalProcessedAmount': totalProcessedAmount, + }; +} diff --git a/lib/model/dashboard/master_expense_types_model.dart b/lib/model/dashboard/master_expense_types_model.dart new file mode 100644 index 0000000..cd569ab --- /dev/null +++ b/lib/model/dashboard/master_expense_types_model.dart @@ -0,0 +1,74 @@ +class ExpenseTypeResponse { + final bool success; + final String message; + final List 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 json) { + return ExpenseTypeResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: json['data'] != null + ? List.from( + json['data'].map((x) => ExpenseTypeData.fromJson(x))) + : [], + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.parse(json['timestamp']), + ); + } + + Map 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 json) { + return ExpenseTypeData( + id: json['id'] ?? '', + name: json['name'] ?? '', + noOfPersonsRequired: json['noOfPersonsRequired'] ?? false, + isAttachmentRequried: json['isAttachmentRequried'] ?? false, + description: json['description'] ?? '', + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'noOfPersonsRequired': noOfPersonsRequired, + 'isAttachmentRequried': isAttachmentRequried, + 'description': description, + }; +} diff --git a/lib/model/dashboard/pending_expenses_model.dart b/lib/model/dashboard/pending_expenses_model.dart new file mode 100644 index 0000000..0f59826 --- /dev/null +++ b/lib/model/dashboard/pending_expenses_model.dart @@ -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 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 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 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 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 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 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 json) { + return ExpenseStatus( + count: json['count'] ?? 0, + totalAmount: (json['totalAmount'] ?? 0).toDouble(), + ); + } + + Map toJson() { + return { + 'count': count, + 'totalAmount': totalAmount, + }; + } + + ExpenseStatus copyWith({ + int? count, + double? totalAmount, + }) { + return ExpenseStatus( + count: count ?? this.count, + totalAmount: totalAmount ?? this.totalAmount, + ); + } + + @override + List get props => [count, totalAmount]; +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 40794a8..1289e6c 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -14,6 +14,10 @@ import 'package:marco/helpers/widgets/dashbaord/project_progress_chart.dart'; import 'package:marco/view/layouts/layout.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/expense_breakdown_chart.dart'; +import 'package:marco/helpers/widgets/dashbaord/expense_by_status_widget.dart'; + + class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -75,6 +79,11 @@ class _DashboardScreenState extends State with UIMixin { width: double.infinity, child: DashboardOverviewWidgets.tasksOverview(), ), + ExpenseByStatusWidget(controller: dashboardController), + MySpacing.height(24), + + // Expense Type Report Chart + ExpenseTypeReportChart(), ], ), ),