diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 9e68ad3..850914c 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -8,119 +8,127 @@ import 'package:on_field_work/model/dashboard/expense_type_report_model.dart'; import 'package:on_field_work/model/dashboard/monthly_expence_model.dart'; import 'package:on_field_work/model/expense/expense_type_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart'; +import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; +import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart'; class DashboardController extends GetxController { - // ========================= - // Attendance overview - // ========================= - final RxList> roleWiseData = - >[].obs; - final RxString attendanceSelectedRange = '15D'.obs; - final RxBool attendanceIsChartView = true.obs; - final RxBool isAttendanceLoading = false.obs; - - // ========================= - // Project progress overview - // ========================= - final RxList projectChartData = [].obs; - final RxString projectSelectedRange = '15D'.obs; - final RxBool projectIsChartView = true.obs; - final RxBool isProjectLoading = false.obs; - - // ========================= - // Projects overview - // ========================= - final RxInt totalProjects = 0.obs; - final RxInt ongoingProjects = 0.obs; - final RxBool isProjectsLoading = false.obs; - - // ========================= - // Tasks overview - // ========================= - final RxInt totalTasks = 0.obs; - final RxInt completedTasks = 0.obs; - final RxBool isTasksLoading = false.obs; - - // ========================= - // Teams overview - // ========================= - final RxInt totalEmployees = 0.obs; - final RxInt inToday = 0.obs; - final RxBool isTeamsLoading = false.obs; - - // Common ranges - final List ranges = ['7D', '15D', '30D']; - - // Inject ProjectController + // Dependencies final ProjectController projectController = Get.put(ProjectController()); -// Pending Expenses overview -// ========================= - final RxBool isPendingExpensesLoading = false.obs; - final Rx pendingExpensesData = - Rx(null); - // ========================= -// Expense Category 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; - // ========================= - // 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); + // ========================= + // 1. STATE VARIABLES + // ========================= + + // Attendance + final roleWiseData = >[].obs; + final attendanceSelectedRange = '15D'.obs; + final attendanceIsChartView = true.obs; + final isAttendanceLoading = false.obs; + + // Project Progress + final projectChartData = [].obs; + final projectSelectedRange = '15D'.obs; + final projectIsChartView = true.obs; + final isProjectLoading = false.obs; + + // Overview Counts + final totalProjects = 0.obs; + final ongoingProjects = 0.obs; + final isProjectsLoading = false.obs; + + final totalTasks = 0.obs; + final completedTasks = 0.obs; + final isTasksLoading = false.obs; + + final totalEmployees = 0.obs; + final inToday = 0.obs; + final isTeamsLoading = false.obs; + + // Expenses & Reports + final isPendingExpensesLoading = false.obs; + final pendingExpensesData = Rx(null); + + final isExpenseTypeReportLoading = false.obs; + final expenseTypeReportData = Rx(null); + final expenseReportStartDate = + DateTime.now().subtract(const Duration(days: 15)).obs; + final expenseReportEndDate = DateTime.now().obs; + + final isMonthlyExpenseLoading = false.obs; + final monthlyExpenseList = [].obs; + final selectedMonthlyExpenseDuration = + MonthlyExpenseDuration.twelveMonths.obs; + final selectedMonthsCount = 12.obs; + + final expenseTypes = [].obs; + final selectedExpenseType = Rx(null); + + // Teams/Employees final isLoadingEmployees = true.obs; -// DashboardController - final RxList employees = [].obs; + final employees = [].obs; final uploadingStates = {}.obs; - void updateSelectedExpenseType(ExpenseTypeModel? type) { - selectedExpenseType.value = type; + // Collection + final isCollectionOverviewLoading = true.obs; + final collectionOverviewData = Rx(null); +// ========================= +// Purchase Invoice Overview +// ========================= + final isPurchaseInvoiceLoading = true.obs; + final purchaseInvoiceOverviewData = Rx(null); + // Constants + final List ranges = ['7D', '15D', '30D']; + static const _rangeDaysMap = { + '7D': 7, + '15D': 15, + '30D': 30, + '3M': 90, + '6M': 180 + }; - // Debug print to verify - print('Selected: ${type?.name ?? "All Types"}'); + // ========================= + // 2. COMPUTED PROPERTIES + // ========================= - if (type == null) { - fetchMonthlyExpenses(); - } else { - fetchMonthlyExpenses(categoryId: type.id); - } + int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7; + int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7; + + // DSO Calculation Constants + static const double _w0_30 = 15.0; + static const double _w30_60 = 45.0; + static const double _w60_90 = 75.0; + static const double _w90_plus = 105.0; + + double get calculatedDSO { + final data = collectionOverviewData.value; + if (data == null || data.totalDueAmount == 0) return 0.0; + + final double weightedDue = (data.bucket0To30Amount * _w0_30) + + (data.bucket30To60Amount * _w30_60) + + (data.bucket60To90Amount * _w60_90) + + (data.bucket90PlusAmount * _w90_plus); + + return weightedDue / data.totalDueAmount; } + // ========================= + // 3. LIFECYCLE + // ========================= + @override void onInit() { super.onInit(); + logSafe('DashboardController initialized', level: LogLevel.info); - logSafe( - 'DashboardController initialized', - level: LogLevel.info, - ); - - // React to project selection + // Project Selection Listener ever(projectController.selectedProjectId, (id) { if (id.isNotEmpty) { - logSafe('Project selected: $id', level: LogLevel.info); fetchAllDashboardData(); fetchTodaysAttendance(id); - } else { - logSafe('No project selected yet.', level: LogLevel.warning); } }); - // React to expense report date changes + // Expense Report Date Listener everAll([expenseReportStartDate, expenseReportEndDate], (_) { if (projectController.selectedProjectId.value.isNotEmpty) { fetchExpenseTypeReport( @@ -130,84 +138,70 @@ class DashboardController extends GetxController { } }); - // React to attendance range changes + // Chart Range Listeners ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); - - // React to project range changes ever(projectSelectedRange, (_) => fetchProjectProgress()); } // ========================= - // Helper Methods + // 4. USER ACTIONS // ========================= - int _getDaysFromRange(String range) { - switch (range) { - case '7D': - return 7; - case '15D': - return 15; - case '30D': - return 30; - case '3M': - return 90; - case '6M': - return 180; - default: - return 7; + + void updateAttendanceRange(String range) => + attendanceSelectedRange.value = range; + void updateProjectRange(String range) => projectSelectedRange.value = range; + void toggleAttendanceChartView(bool isChart) => + attendanceIsChartView.value = isChart; + void toggleProjectChartView(bool isChart) => + projectIsChartView.value = isChart; + + void updateSelectedExpenseType(ExpenseTypeModel? type) { + selectedExpenseType.value = type; + fetchMonthlyExpenses(categoryId: type?.id); + } + + void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { + selectedMonthlyExpenseDuration.value = duration; + + // Efficient Map lookup instead of Switch + const durationMap = { + MonthlyExpenseDuration.oneMonth: 1, + MonthlyExpenseDuration.threeMonths: 3, + MonthlyExpenseDuration.sixMonths: 6, + MonthlyExpenseDuration.twelveMonths: 12, + MonthlyExpenseDuration.all: 0, + }; + + selectedMonthsCount.value = durationMap[duration] ?? 12; + fetchMonthlyExpenses(); + } + + Future refreshDashboard() => fetchAllDashboardData(); + Future refreshAttendance() => fetchRoleWiseAttendance(); + Future refreshProjects() => fetchProjectProgress(); + Future refreshTasks() async { + final id = projectController.selectedProjectId.value; + if (id.isNotEmpty) await fetchDashboardTasks(projectId: id); + } + + // ========================= + // 5. DATA FETCHING (API) + // ========================= + + /// Wrapper to reduce try-finally boilerplate for loading states + Future _executeApiCall( + RxBool loader, Future Function() apiLogic) async { + loader.value = true; + try { + await apiLogic(); + } finally { + loader.value = false; } } - int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value); - int getProjectDays() => _getDaysFromRange(projectSelectedRange.value); - - void updateAttendanceRange(String range) { - attendanceSelectedRange.value = range; - logSafe('Attendance range updated to $range', level: LogLevel.debug); - } - - void updateProjectRange(String range) { - projectSelectedRange.value = range; - logSafe('Project range updated to $range', level: LogLevel.debug); - } - - void toggleAttendanceChartView(bool isChart) { - attendanceIsChartView.value = isChart; - logSafe('Attendance chart view toggled to: $isChart', - level: LogLevel.debug); - } - - void toggleProjectChartView(bool isChart) { - projectIsChartView.value = isChart; - logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug); - } - - // ========================= - // Manual Refresh Methods - // ========================= - Future refreshDashboard() async { - logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug); - await fetchAllDashboardData(); - } - - Future refreshAttendance() async => fetchRoleWiseAttendance(); - Future refreshTasks() async { - final projectId = projectController.selectedProjectId.value; - if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId); - } - - Future refreshProjects() async => fetchProjectProgress(); - - // ========================= - // Fetch All Dashboard Data - // ========================= Future fetchAllDashboardData() async { final String projectId = projectController.selectedProjectId.value; - - if (projectId.isEmpty) { - logSafe('No project selected. Skipping dashboard API calls.', - level: LogLevel.warning); - return; - } + if (projectId.isEmpty) return; await Future.wait([ fetchRoleWiseAttendance(), @@ -220,269 +214,150 @@ class DashboardController extends GetxController { endDate: expenseReportEndDate.value, ), fetchMonthlyExpenses(), - fetchMasterData() + fetchMasterData(), + fetchCollectionOverview(), + fetchPurchaseInvoiceOverview(), ]); } - Future fetchTodaysAttendance(String projectId) async { - isLoadingEmployees.value = true; + Future fetchCollectionOverview() async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isEmpty) return; - final response = await ApiService.getAttendanceForDashboard(projectId); - if (response != null) { - employees.value = response; - for (var emp in employees) { - uploadingStates[emp.id] = false.obs; - } - logSafe( - "Dashboard Attendance fetched: ${employees.length} for project $projectId"); - } else { - logSafe("Failed to fetch Dashboard Attendance for project $projectId", - level: LogLevel.error); - } - - isLoadingEmployees.value = false; - update(); + await _executeApiCall(isCollectionOverviewLoading, () async { + final response = + await ApiService.getCollectionOverview(projectId: projectId); + collectionOverviewData.value = + (response?.success == true) ? response!.data : null; + }); } - 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 fetchTodaysAttendance(String projectId) async { + await _executeApiCall(isLoadingEmployees, () async { + final response = await ApiService.getAttendanceForDashboard(projectId); + if (response != null) { + employees.value = response; + for (var emp in employees) { + uploadingStates.putIfAbsent(emp.id, () => false.obs); + } + } + }); } Future fetchMasterData() async { try { - final expenseTypesData = await ApiService.getMasterExpenseTypes(); - if (expenseTypesData is List) { + final data = await ApiService.getMasterExpenseTypes(); + if (data is List) { expenseTypes.value = - expenseTypesData.map((e) => ExpenseTypeModel.fromJson(e)).toList(); + data.map((e) => ExpenseTypeModel.fromJson(e)).toList(); } - } catch (e) { - logSafe('Error fetching master data', level: LogLevel.error, error: e); - } + } catch (_) {} } 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, - ); - + await _executeApiCall(isMonthlyExpenseLoading, () async { final response = await ApiService.getDashboardMonthlyExpensesApi( categoryId: categoryId, - months: months, + months: selectedMonthsCount.value, ); + monthlyExpenseList.value = + (response?.success == true) ? response!.data : []; + }); + } - 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 fetchPurchaseInvoiceOverview() async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isEmpty) return; + + await _executeApiCall(isPurchaseInvoiceLoading, () async { + final response = await ApiService.getPurchaseInvoiceOverview( + projectId: projectId, + ); + purchaseInvoiceOverviewData.value = + (response?.success == true) ? response!.data : null; + }); } Future fetchPendingExpenses() async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; + final id = projectController.selectedProjectId.value; + if (id.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; - } + await _executeApiCall(isPendingExpensesLoading, () async { + final response = await ApiService.getPendingExpensesApi(projectId: id); + pendingExpensesData.value = + (response?.success == true) ? response!.data : null; + }); } - // ========================= - // API Calls - // ========================= Future fetchRoleWiseAttendance() async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; + final id = projectController.selectedProjectId.value; + if (id.isEmpty) return; - try { - isAttendanceLoading.value = true; - final List? response = - await ApiService.getDashboardAttendanceOverview( - projectId, getAttendanceDays()); - - if (response != null) { - roleWiseData.value = - response.map((e) => Map.from(e)).toList(); - logSafe('Attendance overview fetched successfully.', - level: LogLevel.info); - } else { - roleWiseData.clear(); - logSafe('Failed to fetch attendance overview: response is null.', - level: LogLevel.error); - } - } catch (e, st) { - roleWiseData.clear(); - logSafe('Error fetching attendance overview', - level: LogLevel.error, error: e, stackTrace: st); - } finally { - isAttendanceLoading.value = false; - } + await _executeApiCall(isAttendanceLoading, () async { + final response = await ApiService.getDashboardAttendanceOverview( + id, getAttendanceDays()); + roleWiseData.value = + response?.map((e) => Map.from(e)).toList() ?? []; + }); } - Future fetchExpenseTypeReport({ - required DateTime startDate, - required DateTime endDate, - }) async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; - - try { - isExpenseTypeReportLoading.value = true; + Future fetchExpenseTypeReport( + {required DateTime startDate, required DateTime endDate}) async { + final id = projectController.selectedProjectId.value; + if (id.isEmpty) return; + await _executeApiCall(isExpenseTypeReportLoading, () async { final response = await ApiService.getExpenseTypeReportApi( - projectId: projectId, + projectId: id, startDate: startDate, endDate: endDate, ); - - if (response != null && response.success) { - expenseTypeReportData.value = response.data; - logSafe('Expense Category Report fetched successfully.', - level: LogLevel.info); - } else { - expenseTypeReportData.value = null; - logSafe('Failed to fetch Expense Category Report.', - level: LogLevel.error); - } - } catch (e, st) { - expenseTypeReportData.value = null; - logSafe('Error fetching Expense Category Report', - level: LogLevel.error, error: e, stackTrace: st); - } finally { - isExpenseTypeReportLoading.value = false; - } + expenseTypeReportData.value = + (response?.success == true) ? response!.data : null; + }); } Future fetchProjectProgress() async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; + final id = projectController.selectedProjectId.value; + if (id.isEmpty) return; - try { - isProjectLoading.value = true; + await _executeApiCall(isProjectLoading, () async { final response = await ApiService.getProjectProgress( - projectId: projectId, days: getProjectDays()); - - if (response != null && response.success) { - projectChartData.value = - response.data.map((d) => ChartTaskData.fromProjectData(d)).toList(); - logSafe('Project progress data mapped for chart', level: LogLevel.info); + projectId: id, days: getProjectDays()); + if (response?.success == true) { + projectChartData.value = response!.data + .map((d) => ChartTaskData.fromProjectData(d)) + .toList(); } else { projectChartData.clear(); - logSafe('Failed to fetch project progress', level: LogLevel.error); } - } catch (e, st) { - projectChartData.clear(); - logSafe('Error fetching project progress', - level: LogLevel.error, error: e, stackTrace: st); - } finally { - isProjectLoading.value = false; - } + }); } Future fetchDashboardTasks({required String projectId}) async { - if (projectId.isEmpty) return; - - try { - isTasksLoading.value = true; + await _executeApiCall(isTasksLoading, () async { final response = await ApiService.getDashboardTasks(projectId: projectId); - - if (response != null && response.success) { - totalTasks.value = response.data?.totalTasks ?? 0; + if (response?.success == true) { + totalTasks.value = response!.data?.totalTasks ?? 0; completedTasks.value = response.data?.completedTasks ?? 0; - logSafe('Dashboard tasks fetched', level: LogLevel.info); } else { totalTasks.value = 0; completedTasks.value = 0; - logSafe('Failed to fetch tasks', level: LogLevel.error); } - } catch (e, st) { - totalTasks.value = 0; - completedTasks.value = 0; - logSafe('Error fetching tasks', - level: LogLevel.error, error: e, stackTrace: st); - } finally { - isTasksLoading.value = false; - } + }); } Future fetchDashboardTeams({required String projectId}) async { - if (projectId.isEmpty) return; - - try { - isTeamsLoading.value = true; + await _executeApiCall(isTeamsLoading, () async { final response = await ApiService.getDashboardTeams(projectId: projectId); - - if (response != null && response.success) { - totalEmployees.value = response.data?.totalEmployees ?? 0; + if (response?.success == true) { + totalEmployees.value = response!.data?.totalEmployees ?? 0; inToday.value = response.data?.inToday ?? 0; - logSafe('Dashboard teams fetched', level: LogLevel.info); } else { totalEmployees.value = 0; inToday.value = 0; - logSafe('Failed to fetch teams', level: LogLevel.error); } - } catch (e, st) { - totalEmployees.value = 0; - inToday.value = 0; - logSafe('Error fetching teams', - level: LogLevel.error, error: e, stackTrace: st); - } finally { - isTeamsLoading.value = false; - } + }); } } diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 2738d9e..4d36d63 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -1,9 +1,9 @@ class ApiEndpoints { - // static const String baseUrl = "https://stageapi.marcoaiot.com/api"; + static const String baseUrl = "https://stageapi.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://mapi.marcoaiot.com/api"; - static const String baseUrl = "https://api.onfieldwork.com/api"; +// static const String baseUrl = "https://api.onfieldwork.com/api"; static const String getMasterCurrencies = "/Master/currencies/list"; @@ -36,6 +36,10 @@ class ApiEndpoints { "/Dashboard/expense/monthly"; static const String getExpenseTypeReport = "/Dashboard/expense/type"; static const String getPendingExpenses = "/Dashboard/expense/pendings"; + static const String getCollectionOverview = "/dashboard/collection-overview"; + + static const String getPurchaseInvoiceOverview = + "/dashboard/purchase-invoice-overview"; ///// Projects Module API Endpoints static const String createProject = "/project"; diff --git a/lib/helpers/services/api_service.dart b/lib/helpers/services/api_service.dart index 5adb89e..6f60a02 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -45,6 +45,9 @@ import 'package:on_field_work/model/service_project/job_comments.dart'; import 'package:on_field_work/model/employees/employee_model.dart'; import 'package:on_field_work/model/infra_project/infra_project_list.dart'; import 'package:on_field_work/model/infra_project/infra_project_details.dart'; +import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; +import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart'; + class ApiService { static const bool enableLogs = true; @@ -316,6 +319,82 @@ class ApiService { } } + + /// ============================================ + /// GET PURCHASE INVOICE OVERVIEW (Dashboard) + /// ============================================ + static Future getPurchaseInvoiceOverview({ + String? projectId, + }) async { + try { + final queryParams = {}; + if (projectId != null && projectId.isNotEmpty) { + queryParams['projectId'] = projectId; + } + + final response = await _getRequest( + ApiEndpoints.getPurchaseInvoiceOverview, + queryParams: queryParams, + ); + + if (response == null) { + _log("getPurchaseInvoiceOverview: No response from server", + level: LogLevel.error); + return null; + } + + final parsedJson = _parseResponseForAllData( + response, + label: "PurchaseInvoiceOverview", + ); + + if (parsedJson == null) return null; + + return PurchaseInvoiceOverviewResponse.fromJson(parsedJson); + } catch (e, stack) { + _log("Exception in getPurchaseInvoiceOverview: $e\n$stack", + level: LogLevel.error); + return null; + } + } + /// ============================================ + /// GET COLLECTION OVERVIEW (Dashboard) + /// ============================================ + static Future getCollectionOverview({ + String? projectId, + }) async { + try { + // Build query params (only add projectId if not null) + final queryParams = {}; + if (projectId != null && projectId.isNotEmpty) { + queryParams['projectId'] = projectId; + } + + final response = await _getRequest( + ApiEndpoints.getCollectionOverview, + queryParams: queryParams, + ); + + if (response == null) { + _log("getCollectionOverview: No response from server", + level: LogLevel.error); + return null; + } + + // Parse full JSON (success, message, data, etc.) + final parsedJson = + _parseResponseForAllData(response, label: "CollectionOverview"); + + if (parsedJson == null) return null; + + return CollectionOverviewResponse.fromJson(parsedJson); + } catch (e, stack) { + _log("Exception in getCollectionOverview: $e\n$stack", + level: LogLevel.error); + return null; + } + } + // Infra Project Module APIs /// ================================ diff --git a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart new file mode 100644 index 0000000..d52bb45 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; +import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; +import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; +import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; + +class CollectionsHealthWidget extends StatelessWidget { + @override + Widget build(BuildContext context) { + final DashboardController controller = Get.find(); + + return Obx(() { + final data = controller.collectionOverviewData.value; + final isLoading = controller.isCollectionOverviewLoading.value; + + // Loading state + if (isLoading) { + return Container( + decoration: _boxDecoration(), // Maintain the outer box decoration + padding: const EdgeInsets.all(16.0), + child: SkeletonLoaders.collectionHealthSkeleton(), + ); + } + + // No data + if (data == null) { + return Container( + decoration: _boxDecoration(), + padding: const EdgeInsets.all(16.0), + child: Center( + child: MyText.bodyMedium('No collection overview data available.'), + ), + ); + } + + // Data available + final double totalDue = data.totalDueAmount; + final double totalCollected = data.totalCollectedAmount; + final double pendingPercentage = data.pendingPercentage / 100.0; + final double dsoDays = controller.calculatedDSO; + + return Container( + decoration: _boxDecoration(), + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: _buildLeftChartSection( + totalDue: totalDue, + pendingPercentage: pendingPercentage, + totalCollected: totalCollected, + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 4, + child: _buildRightMetricsSection( + data: data, + dsoDays: dsoDays, + ), + ), + ], + ), + const SizedBox(height: 20), + _buildAgingAnalysis(data: data), + ], + ), + ); + }); + } + + // ============================== + // HEADER + // ============================== + Widget _buildHeader() { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Collections Health Overview', fontWeight: 700), + const SizedBox(height: 2), + MyText.bodySmall('View your collection health data.', + color: Colors.grey), + ], + ), + ), + ], + ); + } + + // ============================== + // LEFT SECTION (GAUGE + SUMMARY) + // ============================== + Widget _buildLeftChartSection({ + required double totalDue, + required double pendingPercentage, + required double totalCollected, + }) { + String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0); + String collectedPercentStr = + ((1 - pendingPercentage) * 100).toStringAsFixed(0); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _GaugeChartPlaceholder( + backgroundColor: Colors.white, + pendingPercentage: pendingPercentage, + ), + const SizedBox(width: 12), + ], + ), + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyLarge( + '₹${totalDue.toStringAsFixed(0)} DUE', + fontWeight: 700, + ), + const SizedBox(height: 4), + MyText.bodySmall( + '• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)', + color: Colors.black54, + ), + MyText.bodySmall( + '₹${totalCollected.toStringAsFixed(0)} Collected', + color: Colors.black54, + ), + ], + ), + ), + ], + ), + ], + ); + } + + // ============================== + // RIGHT METRICS SECTION + // ============================== + Widget _buildRightMetricsSection({ + required CollectionOverviewData data, + required double dsoDays, + }) { + final String topClientName = data.topClient?.name ?? 'N/A'; + final double topClientBalance = data.topClientBalance; + + return Column( + children: [ + _buildMetricCard( + title: 'Top Client Balance', + value: topClientName, + subValue: '₹${topClientBalance.toStringAsFixed(0)}', + valueColor: Colors.red, + isDetailed: true, + ), + const SizedBox(height: 10), + _buildMetricCard( + title: 'Total Collected (YTD)', + value: '₹${data.totalCollectedAmount.toStringAsFixed(0)}', + subValue: 'Collected', + valueColor: Colors.green, + isDetailed: false, + ), + ], + ); + } + + Widget _buildMetricCard({ + required String title, + required String value, + required String subValue, + required Color valueColor, + required bool isDetailed, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodySmall(title, color: Colors.black54), + const SizedBox(height: 2), + if (isDetailed) ...[ + MyText.bodySmall(value, fontWeight: 600), + MyText.bodyMedium(subValue, color: valueColor, fontWeight: 700), + ] else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall(value, fontWeight: 600), + MyText.bodySmall(subValue, color: valueColor, fontWeight: 600), + ], + ), + ], + ), + ); + } + + // ============================== + // AGING ANALYSIS + // ============================== + Widget _buildAgingAnalysis({required CollectionOverviewData data}) { + final buckets = [ + AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green, + data.bucket0To30Invoices), + AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange, + data.bucket30To60Invoices), + AgingBucketData('60-90 Days', data.bucket60To90Amount, + Colors.red.shade300, data.bucket60To90Invoices), + AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red, + data.bucket90PlusInvoices), + ]; + + final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium('Outstanding Collections Aging Analysis', + fontWeight: 700), + MyText.bodySmall( + 'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}', + color: Colors.black54), + const SizedBox(height: 10), + _AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding), + const SizedBox(height: 15), + Wrap( + spacing: 12, + runSpacing: 8, + children: buckets + .map((bucket) => _buildAgingLegendItem(bucket.title, + bucket.amount, bucket.color, bucket.invoiceCount)) + .toList(), + ), + ], + ); + } + + Widget _buildAgingLegendItem( + String title, double amount, Color color, int count) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 6), + MyText.bodySmall( + '$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'), + ], + ); + } + + BoxDecoration _boxDecoration() { + return BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ); + } +} + +// ===================================================================== +// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars) +// ===================================================================== + +// Gauge Chart +class _GaugeChartPlaceholder extends StatelessWidget { + final Color backgroundColor; + final double pendingPercentage; + + const _GaugeChartPlaceholder({ + required this.backgroundColor, + required this.pendingPercentage, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 120, + height: 80, + child: Stack( + children: [ + CustomPaint( + size: const Size(120, 70), + painter: _SemiCirclePainter( + canvasColor: backgroundColor, + pendingPercentage: pendingPercentage, + ), + ), + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FittedBox( + child: MyText.bodySmall('RISK LEVEL', fontWeight: 600), + ), + ), + ), + ], + ), + ); + } +} + +class _SemiCirclePainter extends CustomPainter { + final Color canvasColor; + final double pendingPercentage; + + _SemiCirclePainter( + {required this.canvasColor, required this.pendingPercentage}); + + @override + void paint(Canvas canvas, Size size) { + final rect = Rect.fromCircle( + center: Offset(size.width / 2, size.height), + radius: size.width / 2, + ); + + const double arc = 3.14159; + final double pendingSweep = arc * pendingPercentage; + final double collectedSweep = arc * (1 - pendingPercentage); + + final backgroundPaint = Paint() + ..color = Colors.black.withOpacity(0.1) + ..strokeWidth = 10 + ..style = PaintingStyle.stroke; + canvas.drawArc(rect, arc, arc, false, backgroundPaint); + + final pendingPaint = Paint() + ..strokeWidth = 10 + ..style = PaintingStyle.stroke + ..shader = const LinearGradient( + colors: [Colors.orange, Colors.red], + ).createShader(rect); + canvas.drawArc(rect, arc, pendingSweep, false, pendingPaint); + + final collectedPaint = Paint() + ..color = Colors.green + ..strokeWidth = 10 + ..style = PaintingStyle.stroke; + canvas.drawArc( + rect, arc + pendingSweep, collectedSweep, false, collectedPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// AGING BUCKET +class AgingBucketData { + final String title; + final double amount; + final Color color; + final int invoiceCount; // ADDED + + // UPDATED CONSTRUCTOR + AgingBucketData(this.title, this.amount, this.color, this.invoiceCount); +} + +class _AgingStackedBar extends StatelessWidget { + final List buckets; + final double totalOutstanding; + + const _AgingStackedBar({ + required this.buckets, + required this.totalOutstanding, + }); + + @override + Widget build(BuildContext context) { + if (totalOutstanding == 0) { + return Container( + height: 16, + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: MyText.bodySmall('No Outstanding Collections', + color: Colors.black54), + ), + ); + } + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: buckets.where((b) => b.amount > 0).map((bucket) { + final flexValue = bucket.amount / totalOutstanding; + return Expanded( + flex: (flexValue * 1000).toInt(), + child: Container(height: 16, color: bucket.color), + ); + }).toList(), + ), + ); + } +} diff --git a/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart new file mode 100644 index 0000000..b5f3fe8 --- /dev/null +++ b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart @@ -0,0 +1,717 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; +import 'package:on_field_work/helpers/widgets/my_spacing.dart'; +import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; + +class CompactPurchaseInvoiceDashboard extends StatelessWidget { + const CompactPurchaseInvoiceDashboard({super.key}); + + @override + Widget build(BuildContext context) { + final DashboardController controller = Get.find(); + + // Use Obx to reactively listen to data changes + return Obx(() { + final data = controller.purchaseInvoiceOverviewData.value; + + // Show loading state while API call is in progress + if (controller.isPurchaseInvoiceLoading.value) { + return SkeletonLoaders.purchaseInvoiceDashboardSkeleton(); + } + + // Show empty state if no data + if (data == null || data.totalInvoices == 0) { + return Center( + child: MyText.bodySmall('No purchase invoices found.'), + ); + } + + // Convert API response to internal PurchaseInvoiceData list + final invoices = (data.projectBreakdown ?? []) + .map((project) => PurchaseInvoiceData( + id: project.id ?? '', + title: project.name ?? 'Unknown', + proformaInvoiceAmount: project.totalValue ?? 0.0, + supplierName: data.topSupplier?.name ?? 'N/A', + projectName: project.name ?? 'Unknown', + statusName: 'Unknown', // API might have status if needed + )) + .toList(); + + final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices); + + return _buildDashboard(metrics); + }); + } + + Widget _buildDashboard(PurchaseInvoiceMetrics metrics) { + const double spacing = 16.0; + const double smallSpacing = 8.0; + + return Container( + padding: const EdgeInsets.all(spacing), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const _DashboardHeader(), + const SizedBox(height: spacing), + _TotalValueCard( + totalProformaAmount: metrics.totalProformaAmount, + totalCount: metrics.totalCount, + ), + const SizedBox(height: spacing), + _CondensedMetricsRow( + draftCount: metrics.draftCount, + avgInvoiceValue: metrics.avgInvoiceValue, + topSupplierName: metrics.topSupplierName, + spacing: smallSpacing, + ), + const SizedBox(height: spacing), + const Divider(height: 1, thickness: 0.5), + const SizedBox(height: spacing), + const _SectionTitle('Status Breakdown by Value'), + const SizedBox(height: smallSpacing), + _StatusDonutChart( + statusBuckets: metrics.statusBuckets, + totalAmount: metrics.totalProformaAmount, + ), + const SizedBox(height: spacing), + const Divider(height: 1, thickness: 0.5), + const SizedBox(height: spacing), + const _SectionTitle('Top Projects by Proforma Value'), + const SizedBox(height: smallSpacing), + _ProjectBreakdown( + projects: metrics.projectBuckets.take(3).toList(), + totalAmount: metrics.totalProformaAmount, + spacing: smallSpacing, + ), + ], + ), + ); + } +} + +/// Container object used internally +class PurchaseInvoiceDashboardData { + final List invoices; + final PurchaseInvoiceMetrics metrics; + + const PurchaseInvoiceDashboardData({ + required this.invoices, + required this.metrics, + }); +} + +/// ======================= +/// DATA MODELS +/// ======================= + +class PurchaseInvoiceData { + final String id; + final String title; + final double proformaInvoiceAmount; + final String supplierName; + final String projectName; + final String statusName; + + const PurchaseInvoiceData({ + required this.id, + required this.title, + required this.proformaInvoiceAmount, + required this.supplierName, + required this.projectName, + required this.statusName, + }); + + factory PurchaseInvoiceData.fromJson(Map json) { + final supplier = json['supplier'] as Map? ?? const {}; + final project = json['project'] as Map? ?? const {}; + final status = json['status'] as Map? ?? const {}; + + return PurchaseInvoiceData( + id: json['id']?.toString() ?? '', + title: json['title']?.toString() ?? '', + proformaInvoiceAmount: + (json['proformaInvoiceAmount'] as num?)?.toDouble() ?? 0.0, + supplierName: supplier['name']?.toString() ?? 'Unknown Supplier', + projectName: project['name']?.toString() ?? 'Unknown Project', + statusName: status['displayName']?.toString() ?? 'Unknown', + ); + } +} + +class StatusBucketData { + final String title; + final double amount; + final Color color; + final int count; + + const StatusBucketData({ + required this.title, + required this.amount, + required this.color, + required this.count, + }); +} + +class ProjectMetricData { + final String name; + final double amount; + + const ProjectMetricData({ + required this.name, + required this.amount, + }); +} + +class PurchaseInvoiceMetrics { + final double totalProformaAmount; + final int totalCount; + final int draftCount; + final String topSupplierName; + final double topSupplierAmount; + final List statusBuckets; + final List projectBuckets; + final double avgInvoiceValue; + + const PurchaseInvoiceMetrics({ + required this.totalProformaAmount, + required this.totalCount, + required this.draftCount, + required this.topSupplierName, + required this.topSupplierAmount, + required this.statusBuckets, + required this.projectBuckets, + required this.avgInvoiceValue, + }); +} + +/// ======================= +/// METRICS CALCULATOR +/// ======================= + +class PurchaseInvoiceMetricsCalculator { + PurchaseInvoiceMetrics calculate(List invoices) { + final double totalProformaAmount = + invoices.fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount); + final int totalCount = invoices.length; + final int draftCount = + invoices.where((item) => item.statusName == 'Draft').length; + + final Map supplierTotals = {}; + for (final invoice in invoices) { + supplierTotals.update( + invoice.supplierName, + (value) => value + invoice.proformaInvoiceAmount, + ifAbsent: () => invoice.proformaInvoiceAmount, + ); + } + + final MapEntry? topSupplierEntry = supplierTotals + .entries.isEmpty + ? null + : supplierTotals.entries.reduce((a, b) => a.value > b.value ? a : b); + + final String topSupplierName = topSupplierEntry?.key ?? 'N/A'; + final double topSupplierAmount = topSupplierEntry?.value ?? 0.0; + + final Map projectTotals = {}; + for (final invoice in invoices) { + projectTotals.update( + invoice.projectName, + (value) => value + invoice.proformaInvoiceAmount, + ifAbsent: () => invoice.proformaInvoiceAmount, + ); + } + + final List projectBuckets = projectTotals.entries + .map((e) => ProjectMetricData(name: e.key, amount: e.value)) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + final Map> statusGroups = + >{}; + for (final invoice in invoices) { + statusGroups.putIfAbsent( + invoice.statusName, + () => [], + ); + statusGroups[invoice.statusName]!.add(invoice); + } + + final List statusBuckets = statusGroups.entries.map( + (entry) { + final double statusTotal = entry.value + .fold(0.0, (sum, item) => sum + item.proformaInvoiceAmount); + return StatusBucketData( + title: entry.key, + amount: statusTotal, + color: getColorForStatus(entry.key), + count: entry.value.length, + ); + }, + ).toList(); + + final double avgInvoiceValue = + totalCount > 0 ? totalProformaAmount / totalCount : 0.0; + + return PurchaseInvoiceMetrics( + totalProformaAmount: totalProformaAmount, + totalCount: totalCount, + draftCount: draftCount, + topSupplierName: topSupplierName, + topSupplierAmount: topSupplierAmount, + statusBuckets: statusBuckets, + projectBuckets: projectBuckets, + avgInvoiceValue: avgInvoiceValue, + ); + } +} + +/// ======================= +/// UTILITIES +/// ======================= + +Color _getProjectColor(String name) { + final int hash = name.hashCode; + const List colors = [ + Color(0xFF42A5F5), // Blue + Color(0xFF66BB6A), // Green + Color(0xFFFFA726), // Orange + Color(0xFFEC407A), // Pink + Color(0xFF7E57C2), // Deep Purple + Color(0xFF26C6DA), // Cyan + Color(0xFFFFEE58), // Yellow + ]; + return colors[hash.abs() % colors.length]; +} + +Color getColorForStatus(String status) { + switch (status) { + case 'Draft': + return Colors.blueGrey; + case 'Pending Approval': + return Colors.orange; + case 'Approved': + return Colors.green; + case 'Paid': + return Colors.blue; + default: + return Colors.grey; + } +} + +/// ======================= +/// REDESIGNED INTERNAL UI WIDGETS +/// ======================= + +class _SectionTitle extends StatelessWidget { + final String title; + + const _SectionTitle(this.title); + + @override + Widget build(BuildContext context) { + return MyText.bodySmall( + title, + color: Colors.grey.shade700, + fontWeight: 700, + letterSpacing: 0.5, + ); + } +} + +class _DashboardHeader extends StatelessWidget { + const _DashboardHeader(); + + @override + Widget build(BuildContext context) { + return Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Expanded( + child: + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + MyText.bodyMedium( + 'Purchase Invoice ', + fontWeight: 700, + ), + SizedBox(height: 2), + MyText.bodySmall( + 'View your purchase invoice data.', + color: Colors.grey, + ), + ])) + ]); + } +} + +// Total Value Card - Refined Style +class _TotalValueCard extends StatelessWidget { + final double totalProformaAmount; + final int totalCount; + + const _TotalValueCard({ + required this.totalProformaAmount, + required this.totalCount, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: const Color(0xFFE3F2FD), // Lighter Blue + borderRadius: BorderRadius.circular(5), + border: Border.all(color: const Color(0xFFBBDEFB), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + MyText.bodySmall( + 'TOTAL PROFORMA VALUE (₹)', + color: Colors.blue.shade800, + fontWeight: 700, + letterSpacing: 1.0, + ), + Icon( + Icons.account_balance_wallet_outlined, + color: Colors.blue.shade700, + size: 20, + ), + ], + ), + MySpacing.height(8), + MyText.bodyMedium( + totalProformaAmount.toStringAsFixed(0), + ), + MySpacing.height(4), + MyText.bodySmall( + 'Over $totalCount Total Invoices', + color: Colors.blueGrey.shade600, + fontWeight: 500, + ), + ], + ), + ); + } +} + +// Condensed Metrics Row - Replaces the GridView +class _CondensedMetricsRow extends StatelessWidget { + final int draftCount; + final double avgInvoiceValue; + final String topSupplierName; + final double spacing; + + const _CondensedMetricsRow({ + required this.draftCount, + required this.avgInvoiceValue, + required this.topSupplierName, + required this.spacing, + }); + + @override + Widget build(BuildContext context) { + // Only showing 3 key metrics in a row for a tighter feel + return Row( + children: [ + Expanded( + child: _CondensedMetricCard( + title: 'Drafts', + value: draftCount.toString(), + caption: 'To Complete', + color: Colors.orange.shade700, + icon: Icons.edit_note_outlined, + ), + ), + SizedBox(width: spacing), + Expanded( + child: _CondensedMetricCard( + title: 'Avg. Value', + value: '₹${avgInvoiceValue.toStringAsFixed(0)}', + caption: 'Per Invoice', + color: Colors.purple.shade700, + icon: Icons.calculate_outlined, + ), + ), + SizedBox(width: spacing), + Expanded( + child: _CondensedMetricCard( + title: 'Top Supplier', + value: topSupplierName, + caption: 'By Value', + color: Colors.green.shade700, + icon: Icons.business_center_outlined, + ), + ), + ], + ); + } +} + +// Condensed Metric Card - Small, impactful display +class _CondensedMetricCard extends StatelessWidget { + final String title; + final String value; + final String caption; + final Color color; + final IconData icon; + + const _CondensedMetricCard({ + required this.title, + required this.value, + required this.caption, + required this.color, + required this.icon, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), + decoration: BoxDecoration( + color: color.withOpacity(0.05), + borderRadius: BorderRadius.circular(5), + border: Border.all(color: color.withOpacity(0.15), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 16), + const SizedBox(width: 4), + Expanded( + child: MyText.bodySmall( + title, + overflow: TextOverflow.ellipsis, + color: color, + fontWeight: 700, + ), + ), + ], + ), + MySpacing.height(6), + MyText.bodyMedium( + value, + overflow: TextOverflow.ellipsis, + fontWeight: 800, + ), + MyText.bodySmall( + caption, + color: Colors.grey.shade500, + fontWeight: 500, + ), + ], + ), + ); + } +} + +// Status Breakdown (Donut Chart + Legend) - Stronger Visualization +class _StatusDonutChart extends StatelessWidget { + final List statusBuckets; + final double totalAmount; + + const _StatusDonutChart({ + required this.statusBuckets, + required this.totalAmount, + }); + + @override + Widget build(BuildContext context) { + final List activeBuckets = statusBuckets + .where((b) => b.amount > 0) + .toList() + ..sort((a, b) => b.amount.compareTo(a.amount)); + + if (activeBuckets.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: MyText.bodySmall( + 'No active invoices to display status breakdown.', + color: Colors.grey.shade500, + ), + ); + } + + // Determine the percentage of the largest bucket for the center text + final double mainPercentage = + totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Simulated Donut Chart (Center Focus) + Container( + width: 120, + height: 120, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: activeBuckets.first.color.withOpacity(0.5), width: 6), + color: activeBuckets.first.color.withOpacity(0.05), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MyText.bodySmall( + activeBuckets.first.title, + color: activeBuckets.first.color, + fontWeight: 700, + ), + MyText.bodyMedium( + '${(mainPercentage * 100).toStringAsFixed(0)}%', + ), + ], + ), + ), + const SizedBox(width: 16), + // Legend/Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: activeBuckets.map((bucket) { + return Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + children: [ + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: bucket.color, + shape: BoxShape.circle, + ), + ), + Expanded( + child: MyText.bodySmall( + '${bucket.title} (${bucket.count})', + color: Colors.grey.shade800, + fontWeight: 500, + ), + ), + MyText.bodySmall( + '₹${bucket.amount.toStringAsFixed(0)}', + fontWeight: 700, + color: bucket.color.withOpacity(0.9), + ), + ], + ), + ); + }).toList(), + ), + ), + ], + ); + } +} + +// Project Breakdown - Denser and with clearer value +class _ProjectBreakdown extends StatelessWidget { + final List projects; + final double totalAmount; + final double spacing; + + const _ProjectBreakdown({ + required this.projects, + required this.totalAmount, + required this.spacing, + }); + + @override + Widget build(BuildContext context) { + if (projects.isEmpty) { + return Padding( + padding: const EdgeInsets.only(top: 8.0), + child: MyText.bodySmall( + 'No project data available.', + color: Colors.grey.shade500, + ), + ); + } + + return Column( + children: projects.map((project) { + final double percentage = + totalAmount > 0 ? (project.amount / totalAmount) : 0.0; + final Color color = _getProjectColor(project.name); + final String percentageText = (percentage * 100).toStringAsFixed(1); + + return Padding( + padding: EdgeInsets.only(bottom: spacing), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MyText.bodyMedium( + project.name, + overflow: TextOverflow.ellipsis, + fontWeight: 600, + ), + const SizedBox(height: 2), + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: LinearProgressIndicator( + value: percentage, + backgroundColor: Colors.grey.shade200, + valueColor: AlwaysStoppedAnimation(color), + minHeight: 4, // Smaller bar height + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MyText.bodyMedium( + '₹${project.amount.toStringAsFixed(0)}', + fontWeight: 700, + color: color.withOpacity(0.9), + ), + MyText.bodySmall( + '$percentageText%', + fontWeight: 500, + color: Colors.grey.shade600, + ), + ], + ), + ], + ), + ); + }).toList(), + ); + } +} diff --git a/lib/helpers/widgets/my_custom_skeleton.dart b/lib/helpers/widgets/my_custom_skeleton.dart index 13c858c..3da325e 100644 --- a/lib/helpers/widgets/my_custom_skeleton.dart +++ b/lib/helpers/widgets/my_custom_skeleton.dart @@ -1531,6 +1531,424 @@ class SkeletonLoaders { ), ); } + + // ==================================================================== + // NEW SKELETON LOADER METHODS + // ==================================================================== + + /// Skeleton for the CollectionsHealthWidget + static Widget collectionHealthSkeleton() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], + ), + padding: const EdgeInsets.all(16.0), + child: ShimmerEffect( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header Skeleton + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, width: 180, color: Colors.grey.shade300), + MySpacing.height(4), + Container( + height: 10, width: 140, color: Colors.grey.shade300), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Main Content Row (Left Chart + Right Metrics) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Chart Section + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Gauge Chart Placeholder + Container( + width: 120, + height: 80, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.vertical( + top: Radius.circular(60), bottom: Radius.zero), + ), + alignment: Alignment.bottomCenter, + child: Container( + height: 12, width: 60, color: Colors.grey.shade400), + ), + const SizedBox(height: 20), + // Summary Text Placeholders + Container( + height: 16, width: 150, color: Colors.grey.shade300), + MySpacing.height(6), + Container( + height: 12, width: 200, color: Colors.grey.shade300), + MySpacing.height(4), + Container( + height: 12, width: 100, color: Colors.grey.shade300), + ], + ), + ), + const SizedBox(width: 16), + // Right Metrics Section + Expanded( + flex: 4, + child: Column( + children: [ + // Metric Card 1 + _buildMetricCardSkeleton(), + const SizedBox(height: 10), + // Metric Card 2 + _buildMetricCardSkeleton(), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // Aging Analysis Section + Container(height: 14, width: 220, color: Colors.grey.shade300), + MySpacing.height(4), + Container(height: 10, width: 180, color: Colors.grey.shade300), + const SizedBox(height: 10), + + // Aging Stacked Bar Placeholder + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + children: List.generate( + 4, + (index) => Expanded( + flex: 1, + child: Container( + height: 16, color: Colors.grey.shade400), + )), + ), + ), + const SizedBox(height: 15), + + // Aging Legend Placeholders + Wrap( + spacing: 12, + runSpacing: 8, + children: List.generate( + 4, + (index) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle)), + const SizedBox(width: 6), + Container( + height: 12, + width: 120, + color: Colors.grey.shade300), + ], + )), + ), + ], + ), + ), + ); + } + + /// Helper for Metric Card Skeleton + static Widget _buildMetricCardSkeleton() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade200, // Background color for the card + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 10, width: 90, color: Colors.grey.shade300), + MySpacing.height(4), + Container(height: 12, width: 100, color: Colors.grey.shade300), + MySpacing.height(4), + Container(height: 14, width: 80, color: Colors.grey.shade300), + ], + ), + ); + } + + /// Skeleton for the CompactPurchaseInvoiceDashboard + static Widget purchaseInvoiceDashboardSkeleton() { + return Container( + padding: const EdgeInsets.all(16.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: ShimmerEffect( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + // Header Skeleton + Row(mainAxisAlignment: MainAxisAlignment.start, children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 14, width: 200, color: Colors.grey.shade300), + MySpacing.height(4), + Container( + height: 10, width: 150, color: Colors.grey.shade300), + ])) + ]), + const SizedBox(height: 16), + + // Total Value Card Skeleton + Container( + padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), + decoration: BoxDecoration( + color: Colors.grey.shade200, // Simulated light blue background + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 12, width: 160, color: Colors.grey.shade300), + Icon(Icons.account_balance_wallet_outlined, + color: Colors.grey.shade300, size: 20), + ], + ), + MySpacing.height(8), + Container( + height: 16, width: 120, color: Colors.grey.shade300), + MySpacing.height(4), + Container( + height: 12, width: 180, color: Colors.grey.shade300), + ], + ), + ), + const SizedBox(height: 16), + + // Condensed Metrics Row Skeleton + Row( + children: List.generate( + 3, + (index) => Expanded( + child: Padding( + padding: index == 1 + ? MySpacing.symmetric(horizontal: 8) + : EdgeInsets.zero, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 12), + decoration: BoxDecoration( + color: Colors.grey.shade200, // Card background + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.circle, + color: Colors.grey.shade300, size: 16), + MySpacing.width(4), + Container( + height: 10, + width: 50, + color: Colors.grey.shade300), + ], + ), + MySpacing.height(6), + Container( + height: 14, + width: 80, + color: Colors.grey.shade300), + Container( + height: 10, + width: 60, + color: Colors.grey.shade300), + ], + ), + ), + ), + )), + ), + const SizedBox(height: 16), + const Divider( + height: 1, + thickness: 0.5, + color: Colors.transparent), // Hidden divider for spacing + const SizedBox(height: 16), + + // Status Breakdown Section Skeleton (Chart + Legend) + Container(height: 12, width: 180, color: Colors.grey.shade300), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Donut Chart Placeholder + Container( + width: 120, + height: 120, + alignment: Alignment.center, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.grey.shade300, width: 6), + color: Colors.grey.shade200, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + height: 10, width: 60, color: Colors.grey.shade300), + MySpacing.height(4), + Container( + height: 14, width: 40, color: Colors.grey.shade300), + ], + ), + ), + const SizedBox(width: 16), + // Legend/Details Placeholder + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 8, + height: 8, + margin: + const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle)), + Container( + height: 12, + width: 80, + color: Colors.grey.shade300), + ], + ), + Container( + height: 14, + width: 50, + color: Colors.grey.shade300), + ], + ), + )), + ), + ), + ], + ), + const SizedBox(height: 16), + const Divider( + height: 1, + thickness: 0.5, + color: Colors.transparent), // Hidden divider for spacing + const SizedBox(height: 16), + + // Top Projects Section Skeleton + Container(height: 12, width: 200, color: Colors.grey.shade300), + const SizedBox(height: 8), + Column( + children: List.generate( + 3, + (index) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.grey.shade300, + shape: BoxShape.circle)), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 12, + width: double.infinity, + color: Colors.grey.shade300), + MySpacing.height(4), + ClipRRect( + borderRadius: BorderRadius.circular(5), + child: Container( + height: 4, color: Colors.grey.shade300), + ), + ], + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: 14, + width: 70, + color: Colors.grey.shade300), + MySpacing.height(2), + Container( + height: 10, + width: 40, + color: Colors.grey.shade300), + ], + ), + ], + ), + )), + ), + ], + ), + ), + ); + } } /// A custom reusable Shimmer Effect widget. diff --git a/lib/model/dashboard/collection_overview_model.dart b/lib/model/dashboard/collection_overview_model.dart new file mode 100644 index 0000000..6d78b5a --- /dev/null +++ b/lib/model/dashboard/collection_overview_model.dart @@ -0,0 +1,192 @@ +import 'dart:convert'; + +/// =============================== +/// MAIN MODEL: CollectionOverview +/// =============================== + +class CollectionOverviewResponse { + final bool success; + final String message; + final CollectionOverviewData data; + final dynamic errors; + final int statusCode; + final DateTime timestamp; + + CollectionOverviewResponse({ + required this.success, + required this.message, + required this.data, + required this.errors, + required this.statusCode, + required this.timestamp, + }); + + factory CollectionOverviewResponse.fromJson(Map json) { + return CollectionOverviewResponse( + success: json['success'] ?? false, + message: json['message'] ?? '', + data: CollectionOverviewData.fromJson(json['data'] ?? {}), + errors: json['errors'], + statusCode: json['statusCode'] ?? 0, + timestamp: DateTime.tryParse(json['timestamp'] ?? '') ?? DateTime.now(), + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp.toIso8601String(), + }; + } +} + +/// =============================== +/// DATA BLOCK +/// =============================== + +class CollectionOverviewData { + final double totalDueAmount; + final double totalCollectedAmount; + final double totalValue; + final double pendingPercentage; + final double collectedPercentage; + + final int bucket0To30Invoices; + final int bucket30To60Invoices; + final int bucket60To90Invoices; + final int bucket90PlusInvoices; + + final double bucket0To30Amount; + final double bucket30To60Amount; + final double bucket60To90Amount; + final double bucket90PlusAmount; + + final double topClientBalance; + final TopClient? topClient; + + CollectionOverviewData({ + required this.totalDueAmount, + required this.totalCollectedAmount, + required this.totalValue, + required this.pendingPercentage, + required this.collectedPercentage, + required this.bucket0To30Invoices, + required this.bucket30To60Invoices, + required this.bucket60To90Invoices, + required this.bucket90PlusInvoices, + required this.bucket0To30Amount, + required this.bucket30To60Amount, + required this.bucket60To90Amount, + required this.bucket90PlusAmount, + required this.topClientBalance, + required this.topClient, + }); + + factory CollectionOverviewData.fromJson(Map json) { + return CollectionOverviewData( + totalDueAmount: (json['totalDueAmount'] ?? 0).toDouble(), + totalCollectedAmount: (json['totalCollectedAmount'] ?? 0).toDouble(), + totalValue: (json['totalValue'] ?? 0).toDouble(), + pendingPercentage: (json['pendingPercentage'] ?? 0).toDouble(), + collectedPercentage: (json['collectedPercentage'] ?? 0).toDouble(), + + bucket0To30Invoices: json['bucket0To30Invoices'] ?? 0, + bucket30To60Invoices: json['bucket30To60Invoices'] ?? 0, + bucket60To90Invoices: json['bucket60To90Invoices'] ?? 0, + bucket90PlusInvoices: json['bucket90PlusInvoices'] ?? 0, + + bucket0To30Amount: (json['bucket0To30Amount'] ?? 0).toDouble(), + bucket30To60Amount: (json['bucket30To60Amount'] ?? 0).toDouble(), + bucket60To90Amount: (json['bucket60To90Amount'] ?? 0).toDouble(), + bucket90PlusAmount: (json['bucket90PlusAmount'] ?? 0).toDouble(), + + topClientBalance: (json['topClientBalance'] ?? 0).toDouble(), + topClient: json['topClient'] != null + ? TopClient.fromJson(json['topClient']) + : null, + ); + } + + Map toJson() { + return { + 'totalDueAmount': totalDueAmount, + 'totalCollectedAmount': totalCollectedAmount, + 'totalValue': totalValue, + 'pendingPercentage': pendingPercentage, + 'collectedPercentage': collectedPercentage, + 'bucket0To30Invoices': bucket0To30Invoices, + 'bucket30To60Invoices': bucket30To60Invoices, + 'bucket60To90Invoices': bucket60To90Invoices, + 'bucket90PlusInvoices': bucket90PlusInvoices, + 'bucket0To30Amount': bucket0To30Amount, + 'bucket30To60Amount': bucket30To60Amount, + 'bucket60To90Amount': bucket60To90Amount, + 'bucket90PlusAmount': bucket90PlusAmount, + 'topClientBalance': topClientBalance, + 'topClient': topClient?.toJson(), + }; + } +} + +/// =============================== +/// NESTED MODEL: Top Client +/// =============================== + +class TopClient { + final String id; + final String name; + final String? email; + final String? contactPerson; + final String? address; + final String? gstNumber; + final String? contactNumber; + final int? sprid; + + TopClient({ + required this.id, + required this.name, + this.email, + this.contactPerson, + this.address, + this.gstNumber, + this.contactNumber, + this.sprid, + }); + + factory TopClient.fromJson(Map json) { + return TopClient( + id: json['id'] ?? '', + name: json['name'] ?? '', + email: json['email'], + contactPerson: json['contactPerson'], + address: json['address'], + gstNumber: json['gstNumber'], + contactNumber: json['contactNumber'], + sprid: json['sprid'], + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'email': email, + 'contactPerson': contactPerson, + 'address': address, + 'gstNumber': gstNumber, + 'contactNumber': contactNumber, + 'sprid': sprid, + }; + } +} + +/// =============================== +/// Optional: Quick decode method +/// =============================== +CollectionOverviewResponse parseCollectionOverview(String jsonString) { + return CollectionOverviewResponse.fromJson(jsonDecode(jsonString)); +} diff --git a/lib/model/dashboard/purchase_invoice_model.dart b/lib/model/dashboard/purchase_invoice_model.dart new file mode 100644 index 0000000..220ec4c --- /dev/null +++ b/lib/model/dashboard/purchase_invoice_model.dart @@ -0,0 +1,221 @@ +// ============================ +// PurchaseInvoiceOverviewModel.dart +// ============================ + +class PurchaseInvoiceOverviewResponse { + final bool? success; + final String? message; + final PurchaseInvoiceOverviewData? data; + final dynamic errors; + final int? statusCode; + final DateTime? timestamp; + + PurchaseInvoiceOverviewResponse({ + this.success, + this.message, + this.data, + this.errors, + this.statusCode, + this.timestamp, + }); + + factory PurchaseInvoiceOverviewResponse.fromJson(Map json) { + return PurchaseInvoiceOverviewResponse( + success: json['success'] as bool?, + message: json['message'] as String?, + data: json['data'] != null + ? PurchaseInvoiceOverviewData.fromJson(json['data']) + : null, + errors: json['errors'], + statusCode: json['statusCode'] as int?, + timestamp: json['timestamp'] != null + ? DateTime.tryParse(json['timestamp']) + : null, + ); + } + + Map toJson() { + return { + 'success': success, + 'message': message, + 'data': data?.toJson(), + 'errors': errors, + 'statusCode': statusCode, + 'timestamp': timestamp?.toIso8601String(), + }; + } +} + +class PurchaseInvoiceOverviewData { + final int? totalInvoices; + final double? totalValue; + final double? averageValue; + final List? statusBreakdown; + final List? projectBreakdown; + final TopSupplier? topSupplier; + + PurchaseInvoiceOverviewData({ + this.totalInvoices, + this.totalValue, + this.averageValue, + this.statusBreakdown, + this.projectBreakdown, + this.topSupplier, + }); + + factory PurchaseInvoiceOverviewData.fromJson(Map json) { + return PurchaseInvoiceOverviewData( + totalInvoices: json['totalInvoices'] as int?, + totalValue: (json['totalValue'] != null) + ? (json['totalValue'] as num).toDouble() + : null, + averageValue: (json['averageValue'] != null) + ? (json['averageValue'] as num).toDouble() + : null, + statusBreakdown: json['statusBreakdown'] != null + ? (json['statusBreakdown'] as List) + .map((e) => StatusBreakdown.fromJson(e)) + .toList() + : null, + projectBreakdown: json['projectBreakdown'] != null + ? (json['projectBreakdown'] as List) + .map((e) => ProjectBreakdown.fromJson(e)) + .toList() + : null, + topSupplier: json['topSupplier'] != null + ? TopSupplier.fromJson(json['topSupplier']) + : null, + ); + } + + Map toJson() { + return { + 'totalInvoices': totalInvoices, + 'totalValue': totalValue, + 'averageValue': averageValue, + 'statusBreakdown': statusBreakdown?.map((e) => e.toJson()).toList(), + 'projectBreakdown': projectBreakdown?.map((e) => e.toJson()).toList(), + 'topSupplier': topSupplier?.toJson(), + }; + } +} + +class StatusBreakdown { + final String? id; + final String? name; + final int? count; + final double? totalValue; + final double? percentage; + + StatusBreakdown({ + this.id, + this.name, + this.count, + this.totalValue, + this.percentage, + }); + + factory StatusBreakdown.fromJson(Map json) { + return StatusBreakdown( + id: json['id'] as String?, + name: json['name'] as String?, + count: json['count'] as int?, + totalValue: (json['totalValue'] != null) + ? (json['totalValue'] as num).toDouble() + : null, + percentage: (json['percentage'] != null) + ? (json['percentage'] as num).toDouble() + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'count': count, + 'totalValue': totalValue, + 'percentage': percentage, + }; + } +} + +class ProjectBreakdown { + final String? id; + final String? name; + final int? count; + final double? totalValue; + final double? percentage; + + ProjectBreakdown({ + this.id, + this.name, + this.count, + this.totalValue, + this.percentage, + }); + + factory ProjectBreakdown.fromJson(Map json) { + return ProjectBreakdown( + id: json['id'] as String?, + name: json['name'] as String?, + count: json['count'] as int?, + totalValue: (json['totalValue'] != null) + ? (json['totalValue'] as num).toDouble() + : null, + percentage: (json['percentage'] != null) + ? (json['percentage'] as num).toDouble() + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'count': count, + 'totalValue': totalValue, + 'percentage': percentage, + }; + } +} + +class TopSupplier { + final String? id; + final String? name; + final int? count; + final double? totalValue; + final double? percentage; + + TopSupplier({ + this.id, + this.name, + this.count, + this.totalValue, + this.percentage, + }); + + factory TopSupplier.fromJson(Map json) { + return TopSupplier( + id: json['id'] as String?, + name: json['name'] as String?, + count: json['count'] as int?, + totalValue: (json['totalValue'] != null) + ? (json['totalValue'] as num).toDouble() + : null, + percentage: (json['percentage'] != null) + ? (json['percentage'] as num).toDouble() + : null, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'count': count, + 'totalValue': totalValue, + 'percentage': percentage, + }; + } +} diff --git a/lib/view/dashboard/dashboard_screen.dart b/lib/view/dashboard/dashboard_screen.dart index 91a24a1..3f81f61 100644 --- a/lib/view/dashboard/dashboard_screen.dart +++ b/lib/view/dashboard/dashboard_screen.dart @@ -11,6 +11,8 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart'; import 'package:on_field_work/helpers/widgets/avatar.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart'; +import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart'; +import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart'; @@ -532,7 +534,6 @@ class _DashboardScreenState extends State with UIMixin { ), ); } - // --------------------------------------------------------------------------- // Build // --------------------------------------------------------------------------- @@ -554,6 +555,10 @@ class _DashboardScreenState extends State with UIMixin { _dashboardModules(), MySpacing.height(20), _sectionTitle('Reports & Analytics'), + CompactPurchaseInvoiceDashboard(), + MySpacing.height(20), + CollectionsHealthWidget(), + MySpacing.height(20), _cardWrapper( child: ExpenseTypeReportChart(), ),