diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 9e68ad3..e2a5cf1 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -8,6 +8,7 @@ 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'; class DashboardController extends GetxController { // ========================= @@ -53,46 +54,77 @@ class DashboardController extends GetxController { // Inject ProjectController final ProjectController projectController = Get.put(ProjectController()); -// Pending Expenses overview -// ========================= + + // ========================= + // Pending Expenses overview + // ========================= final RxBool isPendingExpensesLoading = false.obs; final Rx pendingExpensesData = Rx(null); + + // ========================= + // Expense Category Report // ========================= -// 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 - // ========================= + + // Filters final Rx selectedMonthlyExpenseDuration = MonthlyExpenseDuration.twelveMonths.obs; - final RxInt selectedMonthsCount = 12.obs; + final RxList expenseTypes = [].obs; final Rx selectedExpenseType = Rx(null); + final isLoadingEmployees = true.obs; -// DashboardController final RxList employees = [].obs; final uploadingStates = {}.obs; + // ========================= + // Collection Overview + // ========================= + final RxBool isCollectionOverviewLoading = false.obs; + final Rx collectionOverviewData = + Rx(null); + + // ============================================================ + // ⭐ NEW — DSO CALCULATION (Weighted Aging Method) + // ============================================================ + double get calculatedDSO { + final data = collectionOverviewData.value; + if (data == null || data.totalDueAmount == 0) return 0.0; + + final double totalDue = data.totalDueAmount; + + // Weighted aging midpoints + const d0_30 = 15.0; + const d30_60 = 45.0; + const d60_90 = 75.0; + const d90_plus = 105.0; // conservative estimate + + final double weightedDue = (data.bucket0To30Amount * d0_30) + + (data.bucket30To60Amount * d30_60) + + (data.bucket60To90Amount * d60_90) + + (data.bucket90PlusAmount * d90_plus); + + return weightedDue / totalDue; // Final DSO + } + + // Update selected expense type void updateSelectedExpenseType(ExpenseTypeModel? type) { selectedExpenseType.value = type; - // Debug print to verify - print('Selected: ${type?.name ?? "All Types"}'); - if (type == null) { fetchMonthlyExpenses(); } else { @@ -104,23 +136,17 @@ class DashboardController extends GetxController { void onInit() { super.onInit(); - logSafe( - 'DashboardController initialized', - level: LogLevel.info, - ); + logSafe('DashboardController initialized', level: LogLevel.info); // React to project selection 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 + // React to date range changes in expense report everAll([expenseReportStartDate, expenseReportEndDate], (_) { if (projectController.selectedProjectId.value.isNotEmpty) { fetchExpenseTypeReport( @@ -130,10 +156,10 @@ class DashboardController extends GetxController { } }); - // React to attendance range changes + // Attendance range ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); - // React to project range changes + // Project range ever(projectSelectedRange, (_) => fetchProjectProgress()); } @@ -160,39 +186,25 @@ class DashboardController extends GetxController { 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 updateAttendanceRange(String range) => + attendanceSelectedRange.value = range; - void updateProjectRange(String range) { - projectSelectedRange.value = range; - logSafe('Project range updated to $range', level: LogLevel.debug); - } + void updateProjectRange(String range) => projectSelectedRange.value = range; - void toggleAttendanceChartView(bool isChart) { - attendanceIsChartView.value = isChart; - logSafe('Attendance chart view toggled to: $isChart', - level: LogLevel.debug); - } + void toggleAttendanceChartView(bool isChart) => + attendanceIsChartView.value = isChart; - void toggleProjectChartView(bool isChart) { - projectIsChartView.value = isChart; - logSafe('Project chart view toggled to: $isChart', level: LogLevel.debug); - } + void toggleProjectChartView(bool isChart) => + projectIsChartView.value = isChart; // ========================= - // Manual Refresh Methods + // Manual Refresh // ========================= - Future refreshDashboard() async { - logSafe('Manual dashboard refresh triggered.', level: LogLevel.debug); - await fetchAllDashboardData(); - } - + Future refreshDashboard() async => fetchAllDashboardData(); Future refreshAttendance() async => fetchRoleWiseAttendance(); Future refreshTasks() async { - final projectId = projectController.selectedProjectId.value; - if (projectId.isNotEmpty) await fetchDashboardTasks(projectId: projectId); + final id = projectController.selectedProjectId.value; + if (id.isNotEmpty) await fetchDashboardTasks(projectId: id); } Future refreshProjects() async => fetchProjectProgress(); @@ -202,12 +214,7 @@ class DashboardController extends GetxController { // ========================= 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,24 +227,45 @@ class DashboardController extends GetxController { endDate: expenseReportEndDate.value, ), fetchMonthlyExpenses(), - fetchMasterData() + fetchMasterData(), + fetchCollectionOverview(), ]); } + // ========================= + // API Calls + // ========================= + + Future fetchCollectionOverview() async { + final projectId = projectController.selectedProjectId.value; + if (projectId.isEmpty) return; + + try { + isCollectionOverviewLoading.value = true; + + final response = + await ApiService.getCollectionOverview(projectId: projectId); + + if (response != null && response.success) { + collectionOverviewData.value = response.data; + } else { + collectionOverviewData.value = null; + } + } finally { + isCollectionOverviewLoading.value = false; + } + } + Future fetchTodaysAttendance(String projectId) async { isLoadingEmployees.value = true; 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; @@ -272,102 +300,70 @@ class DashboardController extends GetxController { 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, - ); - final response = await ApiService.getDashboardMonthlyExpensesApi( categoryId: categoryId, - months: months, + months: selectedMonthsCount.value, ); if (response != null && response.success) { monthlyExpenseList.value = response.data; - logSafe('Monthly Expense Report fetched successfully.', - level: LogLevel.info); } else { monthlyExpenseList.clear(); - logSafe('Failed to fetch Monthly Expense Report.', - level: LogLevel.error); } - } catch (e, st) { - monthlyExpenseList.clear(); - logSafe('Error fetching Monthly Expense Report', - level: LogLevel.error, error: e, stackTrace: st); } finally { isMonthlyExpenseLoading.value = false; } } Future fetchPendingExpenses() async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; + final id = projectController.selectedProjectId.value; + if (id.isEmpty) return; try { isPendingExpensesLoading.value = true; - final response = - await ApiService.getPendingExpensesApi(projectId: projectId); + + final response = await ApiService.getPendingExpensesApi(projectId: id); 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 - // ========================= 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()); + + final response = await ApiService.getDashboardAttendanceOverview( + id, + 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; } @@ -377,109 +373,82 @@ class DashboardController extends GetxController { required DateTime startDate, required DateTime endDate, }) async { - final String projectId = projectController.selectedProjectId.value; - if (projectId.isEmpty) return; + final id = projectController.selectedProjectId.value; + if (id.isEmpty) return; try { isExpenseTypeReportLoading.value = true; 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; } } 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; + final response = await ApiService.getProjectProgress( - projectId: projectId, days: getProjectDays()); + projectId: id, + 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); } 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; + final response = await ApiService.getDashboardTasks(projectId: projectId); if (response != null && response.success) { 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; + final response = await ApiService.getDashboardTeams(projectId: projectId); if (response != null && response.success) { 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 eb072a4..ce82b18 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -36,6 +36,7 @@ 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"; ///// 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..ad7c3c3 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -45,6 +45,8 @@ 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'; + class ApiService { static const bool enableLogs = true; @@ -315,6 +317,43 @@ class ApiService { 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 index afc5556..3ec43bd 100644 --- a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart +++ b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart @@ -1,118 +1,128 @@ 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'; -// --- MAIN WIDGET FILE --- +// =============================================================== +// MAIN WIDGET +// =============================================================== class CollectionsHealthWidget extends StatelessWidget { @override Widget build(BuildContext context) { - // Derived Metrics from the JSON Analysis: - const double totalDue = 34190.0; - const double totalCollected = 5000.0; - const double totalValue = totalDue + totalCollected; + return GetBuilder( + builder: (controller) { + final data = controller.collectionOverviewData.value; + final isLoading = controller.isCollectionOverviewLoading.value; - // Calculate Pending Percentage for Gauge - final double pendingPercentage = - totalValue > 0 ? totalDue / totalValue : 0.0; + if (isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.all(32.0), + child: CircularProgressIndicator(), + ), + ); + } - // 1. MAIN CARD CONTAINER (White Theme) - 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: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // 1. HEADER - _buildHeader(), - const SizedBox(height: 20), - // 2. MAIN CONTENT ROW (Layout) - Row( + if (data == null) { + return Container( + decoration: _boxDecoration(), + padding: const EdgeInsets.all(16.0), + child: Center( + child: MyText.bodyMedium( + 'No collection overview 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: [ - // Left Section: Gauge Chart, Due Amount, & Timelines - Expanded( - flex: 5, - child: _buildLeftChartSection( - totalDue: totalDue, - pendingPercentage: pendingPercentage, - ), - ), - const SizedBox(width: 16), - // Right Section: Metric Cards - Expanded( - flex: 4, - child: _buildRightMetricsSection( - totalCollected: totalCollected, - ), + _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), ], ), - const SizedBox(height: 20), - // 3. AGING ANALYSIS SECTION - _buildAgingAnalysis(), - ], - ), + ); + }, ); } - // --- HELPER METHOD 1: HEADER --- + // =============================================================== + // HEADER + // =============================================================== Widget _buildHeader() { - return Row(mainAxisAlignment: MainAxisAlignment.start, 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, - ), - ], + 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), + ], + ), ), - ), - ]); + ], + ); } - // --- HELPER METHOD 2: LEFT SECTION (CHARTS) --- + // =============================================================== + // LEFT SECTION (GAUGE + SUMMARY + TREND PLACEHOLDERS) + // =============================================================== Widget _buildLeftChartSection({ required double totalDue, required double pendingPercentage, + required double totalCollected, }) { - // Format the percentage for display String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0); - // Use the derived totalCollected for a better context - const double totalCollected = 5000.0; + String collectedPercentStr = + ((1 - pendingPercentage) * 100).toStringAsFixed(0); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top: Gauge Chart - Row( - children: [ - _GaugeChartPlaceholder( - backgroundColor: Colors.white, - pendingPercentage: pendingPercentage, - ), - const SizedBox(width: 12), - ], - ), + Row(children: [ + _GaugeChartPlaceholder( + backgroundColor: Colors.white, + pendingPercentage: pendingPercentage, + ), + const SizedBox(width: 12), + ]), const SizedBox(height: 20), - - // Total Due + Summary Row( children: [ Expanded( @@ -125,56 +135,11 @@ class CollectionsHealthWidget extends StatelessWidget { ), const SizedBox(height: 4), MyText.bodySmall( - '• Pending ($pendingPercentStr%) • ₹${totalCollected.toStringAsFixed(0)} Collected', + '• Pending ($pendingPercentStr%) • Collected ($collectedPercentStr%)', color: Colors.black54, ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - - // Bottom: Timeline Charts (Trend Analysis) - Row( - children: [ - // Expected Collections Timeline (Bar Chart Placeholder) - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ MyText.bodySmall( - 'Expected Collections Trend', - ), - const SizedBox(height: 8), - const _TimelineChartPlaceholder( - isBar: true, - barColor: Color(0xFF2196F3), - ), - MyText.bodySmall( - 'Week 16 Nov 2025', - color: Colors.black54, - ), - ], - ), - ), - const SizedBox(width: 10), - - // Collection Rate Trend (Area Chart Placeholder) - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - MyText.bodySmall( - 'Collection Rate Trend', - ), - const SizedBox(height: 8), - const _TimelineChartPlaceholder( - isBar: false, - areaColor: Color(0xFF4CAF50), - ), - MyText.bodySmall( - 'Week 14 Nov 2025', + '₹${totalCollected.toStringAsFixed(0)} Collected', color: Colors.black54, ), ], @@ -186,56 +151,42 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // --- HELPER METHOD 3: RIGHT SECTION (METRICS) --- + // =============================================================== + // RIGHT SIDE METRICS + // =============================================================== Widget _buildRightMetricsSection({ - required double totalCollected, + required CollectionOverviewData data, + required double dsoDays, }) { + final double totalCollected = data.totalCollectedAmount; + + final String topClientName = data.topClient?.name ?? 'N/A'; + final double topClientBalance = data.topClientBalance; + return Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Metric Card 1: Top Client _buildMetricCard( title: 'Top Client Balance', - value: 'Peninsula Land Limited', - subValue: '₹34,190', - valueColor: const Color(0xFFF44336), // Red (Pending/Due) + value: topClientName, + subValue: '₹${topClientBalance.toStringAsFixed(0)}', + valueColor: Colors.red, isDetailed: true, ), const SizedBox(height: 10), - - // Metric Card 2: Total Collected (YTD) _buildMetricCard( title: 'Total Collected (YTD)', value: '₹${totalCollected.toStringAsFixed(0)}', subValue: 'Collected', - valueColor: const Color(0xFF4CAF50), // Green (Positive Value) - isDetailed: false, - ), - const SizedBox(height: 10), - - // Metric Card 3: DSO - _buildMetricCard( - title: 'Days Sales Outstanding (DSO)', - value: '45 Days', - subValue: '↑ 5 Days', - valueColor: const Color(0xFFFF9800), // Orange (Needs improvement) - isDetailed: false, - ), - const SizedBox(height: 10), - - // Metric Card 4: Bad Debt Ratio - _buildMetricCard( - title: 'Bad Debt Ratio', - value: '0.8%', - subValue: '↓ 0.2%', - valueColor: const Color(0xFF4CAF50), // Green (Positive Change) + valueColor: Colors.green, isDetailed: false, ), ], ); } - // --- HELPER METHOD 4: METRIC CARD WIDGET --- + // =============================================================== + // METRIC CARD UI + // =============================================================== Widget _buildMetricCard({ required String title, required String value, @@ -252,34 +203,17 @@ class CollectionsHealthWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodySmall( - title, - color: Colors.black54, - ), + MyText.bodySmall(title, color: Colors.black54), const SizedBox(height: 2), if (isDetailed) ...[ - MyText.bodySmall( - value, - fontWeight: 600, - ), - MyText.bodyMedium( - subValue, - color: valueColor, - fontWeight: 700, - ), + 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, - ), + MyText.bodySmall(value, fontWeight: 600), + MyText.bodySmall(subValue, color: valueColor, fontWeight: 600), ], ), ], @@ -287,101 +221,109 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // --- NEW HELPER METHOD: AGING ANALYSIS --- - Widget _buildAgingAnalysis() { - // Hardcoded data - const double due0to20Days = 0.0; - const double due20to45Days = 34190.0; - const double due45to90Days = 0.0; - const double dueOver90Days = 0.0; - - final double totalOutstanding = - due0to20Days + due20to45Days + due45to90Days + dueOver90Days; - - // Define buckets with their risk color - final List buckets = [ + // =============================================================== + // AGING ANALYSIS (DYNAMIC) + // =============================================================== + Widget _buildAgingAnalysis({required CollectionOverviewData data}) { + final buckets = [ AgingBucketData( - '0-20 Days', - due0to20Days, - const Color(0xFF4CAF50), // Green (Low Risk) + '0-30 Days', + data.bucket0To30Amount, + Colors.green, + data.bucket0To30Invoices, ), AgingBucketData( - '20-45 Days', - due20to45Days, - const Color(0xFFFF9800), // Orange (Medium Risk) + '30-60 Days', + data.bucket30To60Amount, + Colors.orange, + data.bucket30To60Invoices, ), AgingBucketData( - '45-90 Days', - due45to90Days, - const Color(0xFFF44336).withOpacity(0.7), // Light Red + '60-90 Days', + data.bucket60To90Amount, + Colors.red.shade300, + data.bucket60To90Invoices, ), AgingBucketData( '> 90 Days', - dueOver90Days, - const Color(0xFFF44336), // Dark Red + data.bucket90PlusAmount, + Colors.red, + data.bucket90PlusInvoices, ), ]; + final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount); + return Column( crossAxisAlignment: CrossAxisAlignment.start, - children: [ + children: [ MyText.bodyMedium( 'Outstanding Collections Aging Analysis', fontWeight: 700, ), + MyText.bodySmall( + 'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}', + color: Colors.black54, + ), const SizedBox(height: 10), - - // Stacked bar visualization _AgingStackedBar( buckets: buckets, totalOutstanding: totalOutstanding, ), - const SizedBox(height: 15), - - // Legend / Bucket details Wrap( spacing: 12, runSpacing: 8, children: buckets - .map( - (bucket) => _buildAgingLegendItem( - bucket.title, - bucket.amount, - bucket.color, - ), - ) + .map((bucket) => _buildAgingLegendItem(bucket.title, + bucket.amount, bucket.color, bucket.invoiceCount)) .toList(), ), ], ); } - // Legend item for aging buckets - Widget _buildAgingLegendItem(String title, double amount, Color color) { + Widget _buildAgingLegendItem( + String title, double amount, Color color, int count // Updated parameter + ) { return Row( mainAxisSize: MainAxisSize.min, children: [ Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), + width: 10, + height: 10, + decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 6), MyText.bodySmall( - '$title: ₹${amount.toStringAsFixed(0)}', + '$title: ₹${amount.toStringAsFixed(0)} (${count} Invoices)' // Updated text + ), + ], + ); + } + + // =============================================================== + // COMMON BOX DECORATION + // =============================================================== + 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 PAINTERS / PLACEHOLDERS --- +// ===================================================================== +// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars) +// ===================================================================== -// Placeholder for the Semi-Circle Gauge Chart +// Gauge Chart class _GaugeChartPlaceholder extends StatelessWidget { final Color backgroundColor; final double pendingPercentage; @@ -398,7 +340,6 @@ class _GaugeChartPlaceholder extends StatelessWidget { height: 80, child: Stack( children: [ - // BACKGROUND GAUGE CustomPaint( size: const Size(120, 70), painter: _SemiCirclePainter( @@ -406,17 +347,12 @@ class _GaugeChartPlaceholder extends StatelessWidget { pendingPercentage: pendingPercentage, ), ), - - // CENTER TEXT Align( alignment: Alignment.bottomCenter, child: Padding( padding: const EdgeInsets.only(bottom: 8), child: FittedBox( - child: MyText.bodySmall( - 'RISK LEVEL', - fontWeight: 600, - ), + child: MyText.bodySmall('RISK LEVEL', fontWeight: 600), ), ), ), @@ -426,15 +362,12 @@ class _GaugeChartPlaceholder extends StatelessWidget { } } -// Painter for the semi-circular gauge chart visualization class _SemiCirclePainter extends CustomPainter { final Color canvasColor; final double pendingPercentage; - _SemiCirclePainter({ - required this.canvasColor, - required this.pendingPercentage, - }); + _SemiCirclePainter( + {required this.canvasColor, required this.pendingPercentage}); @override void paint(Canvas canvas, Size size) { @@ -443,184 +376,47 @@ class _SemiCirclePainter extends CustomPainter { radius: size.width / 2, ); - const double totalArc = 3.14159; - final double pendingSweepAngle = totalArc * pendingPercentage; - final double collectedSweepAngle = totalArc * (1.0 - pendingPercentage); + const double arc = 3.14159; + final double pendingSweep = arc * pendingPercentage; + final double collectedSweep = arc * (1 - pendingPercentage); - // Background Arc final backgroundPaint = Paint() ..color = Colors.black.withOpacity(0.1) - ..style = PaintingStyle.stroke - ..strokeWidth = 10; - canvas.drawArc(rect, totalArc, totalArc, false, backgroundPaint); - - // Pending Arc - final pendingPaint = Paint() - ..style = PaintingStyle.stroke ..strokeWidth = 10 - ..shader = const LinearGradient( - colors: [ - Color(0xFFFF9800), - Color(0xFFF44336), - ], - ).createShader(rect); - canvas.drawArc(rect, totalArc, pendingSweepAngle, false, pendingPaint); - - // Collected Arc - final collectedPaint = Paint() - ..color = const Color(0xFF4CAF50) - ..style = PaintingStyle.stroke - ..strokeWidth = 10; - canvas.drawArc( - rect, - totalArc + pendingSweepAngle, - collectedSweepAngle, - false, - collectedPaint, - ); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} - -// Placeholder for the Bar/Area Charts -class _TimelineChartPlaceholder extends StatelessWidget { - final bool isBar; - final Color? barColor; - final Color? areaColor; - - const _TimelineChartPlaceholder({ - required this.isBar, - this.barColor, - this.areaColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: 50, - width: double.infinity, - color: Colors.transparent, - child: isBar - ? _BarChartVisual(barColor: barColor!) - : _AreaChartVisual(areaColor: areaColor!), - ); - } -} - -class _BarChartVisual extends StatelessWidget { - final Color barColor; - - const _BarChartVisual({required this.barColor}); - - @override - Widget build(BuildContext context) { - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: const [ - _Bar(0.4), - _Bar(0.7), - _Bar(1.0), - _Bar(0.6), - _Bar(0.8), - ], - ); - } -} - -class _Bar extends StatelessWidget { - final double heightFactor; - - const _Bar(this.heightFactor); - - @override - Widget build(BuildContext context) { - // Bar color is taken from parent via DefaultTextStyle/Theme if needed; - // you can wrap with Theme if you want dynamic colors. - return Container( - width: 8, - height: 50 * heightFactor, - decoration: BoxDecoration( - color: Colors.grey.shade400, - borderRadius: BorderRadius.circular(5), - ), - ); - } -} - -class _AreaChartVisual extends StatelessWidget { - final Color areaColor; - - const _AreaChartVisual({required this.areaColor}); - - @override - Widget build(BuildContext context) { - return CustomPaint( - painter: _AreaChartPainter(areaColor: areaColor), - size: const Size(double.infinity, 50), - ); - } -} - -class _AreaChartPainter extends CustomPainter { - final Color areaColor; - - _AreaChartPainter({required this.areaColor}); - - @override - void paint(Canvas canvas, Size size) { - final points = [ - Offset(0, size.height * 0.5), - Offset(size.width * 0.25, size.height * 0.8), - Offset(size.width * 0.5, size.height * 0.3), - Offset(size.width * 0.75, size.height * 0.9), - Offset(size.width, size.height * 0.4), - ]; - - final path = Path() - ..moveTo(points.first.dx, size.height) - ..lineTo(points.first.dx, points.first.dy); - for (int i = 1; i < points.length; i++) { - path.lineTo(points[i].dx, points[i].dy); - } - path.lineTo(points.last.dx, size.height); - path.close(); - - final areaPaint = Paint() - ..shader = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - areaColor.withOpacity(0.5), - areaColor.withOpacity(0.0), - ], - ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) - ..style = PaintingStyle.fill; - canvas.drawPath(path, areaPaint); - - final linePaint = Paint() - ..color = areaColor - ..strokeWidth = 2 ..style = PaintingStyle.stroke; - canvas.drawPath(Path()..addPolygon(points, false), linePaint); + 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; } -// --- DATA MODEL --- +// AGING BUCKET class AgingBucketData { final String title; final double amount; final Color color; + final int invoiceCount; // ADDED - AgingBucketData(this.title, this.amount, this.color); + // UPDATED CONSTRUCTOR + AgingBucketData(this.title, this.amount, this.color, this.invoiceCount); } -// --- STACKED BAR VISUAL --- class _AgingStackedBar extends StatelessWidget { final List buckets; final double totalOutstanding; @@ -640,31 +436,22 @@ class _AgingStackedBar extends StatelessWidget { borderRadius: BorderRadius.circular(8), ), child: Center( - child: MyText.bodySmall( - 'No Outstanding Collections', - color: Colors.black54, - ), + child: MyText.bodySmall('No Outstanding Collections', + color: Colors.black54), ), ); } - final List segments = - buckets.where((b) => b.amount > 0).map((bucket) { - final double flexValue = bucket.amount / totalOutstanding; - return Expanded( - flex: (flexValue * 100).toInt(), - child: Container( - height: 16, - color: bucket.color, - ), - ); - }).toList(); - return ClipRRect( borderRadius: BorderRadius.circular(8), child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: segments, + 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/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)); +}