From 717f0c92afd77480fe71b6515aef86f426ce77b4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 4 Dec 2025 17:54:48 +0530 Subject: [PATCH 1/5] feat: Add CompactPurchaseInvoiceDashboard widget and integrate into dashboard screen - Implemented a new widget for displaying purchase invoice metrics with internal dummy data. - Integrated the CompactPurchaseInvoiceDashboard into the dashboard screen layout. - Updated imports in dashboard_screen.dart to include the new purchase invoice dashboard widget. --- lib/helpers/services/api_endpoints.dart | 4 +- .../dashbaord/collection_dashboard_card.dart | 683 ++++++++++++++ .../dashbaord/purchase_invoice_dashboard.dart | 857 ++++++++++++++++++ lib/view/dashboard/dashboard_screen.dart | 7 +- 4 files changed, 1548 insertions(+), 3 deletions(-) create mode 100644 lib/helpers/widgets/dashbaord/collection_dashboard_card.dart create mode 100644 lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index 2738d9e..eb072a4 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"; 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..53fb95c --- /dev/null +++ b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart @@ -0,0 +1,683 @@ +// collections_health_widget.dart + +import 'package:flutter/material.dart'; + +// --- MAIN WIDGET FILE --- +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; + // Calculate Pending Percentage for Gauge + final double pendingPercentage = + totalValue > 0 ? totalDue / totalValue : 0.0; + + // 1. MAIN CARD CONTAINER (White Theme) + return Container( + decoration: BoxDecoration( + // Main card background color: WHITE + color: Colors.white, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + // Lighter shadow for white background + 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( + 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, + ), + ), + ], + ), + const SizedBox(height: 20), + // 3. NEW: AGING ANALYSIS SECTION + _buildAgingAnalysis(), + ], + ), + ); + } + + // --- HELPER METHOD 1: HEADER --- + Widget _buildHeader() { + return Row( + children: const [ + Text( + 'Collections Health Overview', + style: TextStyle( + // Text color: Dark for contrast on white background + color: Colors.black87, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + // --- HELPER METHOD 2: LEFT SECTION (CHARTS) --- + Widget _buildLeftChartSection({ + required double totalDue, + required double pendingPercentage, + }) { + // Format the percentage for display + String pendingPercentStr = (pendingPercentage * 100).toStringAsFixed(0); + // Use the derived totalCollected for a better context + const double totalCollected = 5000.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Top: Gauge Chart and Due Amount + Row( + children: [ + // Use White-Theme Gauge Placeholder with calculated percentage + _GaugeChartPlaceholder( + backgroundColor: Colors.white, + pendingPercentage: pendingPercentage), + const SizedBox(width: 12), + ], + ), + + const SizedBox(height: 20), + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '₹${totalDue.toStringAsFixed(0)} DUE', + style: const TextStyle( + // Text color: Dark for contrast + color: Colors.black87, + fontSize: 22, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + '• Pending ($pendingPercentStr%) • ₹${totalCollected.toStringAsFixed(0)} Collected', + // Sub-text color: Lighter dark color + style: const TextStyle(color: Colors.black54, fontSize: 12), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + // Bottom: Timeline Charts (Trend Analysis) + Row( + children: [ + // Expected Collections Timeline (Bar Chart Placeholder) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Expected Collections Trend', + // Text color: Dark for contrast + style: TextStyle(color: Colors.black87, fontSize: 12)), + const SizedBox(height: 8), + _TimelineChartPlaceholder( + isBar: true, + barColor: const Color(0xFF2196F3)), // Blue Bar Chart + const Text('Week 16 Nov 2025', + style: TextStyle(color: Colors.black54, fontSize: 10)), + ], + ), + ), + const SizedBox(width: 10), + // Collection Rate Trend (Area Chart Placeholder for Analysis) + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Collection Rate Trend', + // Text color: Dark for contrast + style: TextStyle(color: Colors.black87, fontSize: 12)), + const SizedBox(height: 8), + _TimelineChartPlaceholder( + isBar: false, + areaColor: + const Color(0xFF4CAF50)), // Green Area Chart (Rate) + const Text('Week 14 Nov 2025', + style: TextStyle(color: Colors.black54, fontSize: 10)), + ], + ), + ), + ], + ), + ], + ); + } + + // --- HELPER METHOD 3: RIGHT SECTION (METRICS) --- + Widget _buildRightMetricsSection({ + required double totalCollected, + }) { + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // Metric Card 1: Top Client (From Invoice 1) + _buildMetricCard( + title: 'Top Client Balance', + value: 'Peninsula Land Limited', + subValue: '₹34,190', + valueColor: const Color(0xFFF44336), // Red (Pending/Due) + isDetailed: true, + ), + const SizedBox(height: 10), + // Metric Card 2 (NEW): Total Collected (From Invoice 2) + _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: Days Sales Outstanding (DSO) (Analytical Placeholder) + _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: Last Metric Card in the image (Repeating rate for layout) + _buildMetricCard( + title: 'Bad Debt Ratio', + value: '0.8%', + subValue: '↓ 0.2%', + valueColor: const Color(0xFF4CAF50), // Green (Positive Change) + isDetailed: false, + ), + ], + ); + } + + // --- HELPER METHOD 4: METRIC CARD WIDGET (Unchanged) --- + 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( + // Light grey background for card separation on white theme + color: const Color(0xFFF5F5F5), + borderRadius: BorderRadius.circular(5), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + // Title text color: Soft dark grey + style: const TextStyle(color: Colors.black54, fontSize: 10), + ), + const SizedBox(height: 2), + // Conditional rendering for the detailed vs simple card structure + if (isDetailed) ...[ + Text( + value, // Client Name + style: const TextStyle( + // Value text color: Dark + color: Colors.black87, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + Text( + subValue, // Due Amount + style: TextStyle( + // Color remains dynamic (Red/Green/etc.) + color: valueColor, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ] else + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + value, + style: const TextStyle( + // Value text color: Dark + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + Text( + subValue, + style: TextStyle( + // Color remains dynamic (Red/Green/etc.) + color: valueColor, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ); + } + + // --- NEW HELPER METHOD: AGING ANALYSIS --- + // --- NEW HELPER METHOD: AGING ANALYSIS (MODIFIED) --- + Widget _buildAgingAnalysis() { + // Hardcoded data based on the JSON analysis + 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 = [ + AgingBucketData('0-20 Days', due0to20Days, + const Color(0xFF4CAF50)), // Green (Low Risk) + AgingBucketData('20-45 Days', due20to45Days, + const Color(0xFFFF9800)), // Orange (Medium Risk) + AgingBucketData('45-90 Days', due45to90Days, + const Color(0xFFF44336).withOpacity(0.7)), // Light Red (High Risk) + AgingBucketData('> 90 Days', dueOver90Days, + const Color(0xFFF44336)), // Dark Red (Very High Risk) + ]; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Outstanding Collections Aging Analysis', + style: TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 10), + + // NEW: STACKED BAR VISUALIZATION + _AgingStackedBar( + buckets: buckets, + totalOutstanding: totalOutstanding, + ), + + const SizedBox(height: 15), + + // NEW: LEGEND/BUCKET DETAILS (Replaces the old _buildAgingBucket layout) + Wrap( + spacing: 12, + runSpacing: 8, + children: buckets + .map((bucket) => _buildAgingLegendItem( + bucket.title, bucket.amount, bucket.color)) + .toList(), + ), + ], + ); + } + +// Keep the old _buildAgingBucket helper and rename it to a legend item +// This is more efficient than recreating the logic + Widget _buildAgingLegendItem(String title, double amount, Color color) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${title}: ₹${amount.toStringAsFixed(0)}', + style: const TextStyle(color: Colors.black87, fontSize: 12), + ), + ], + ); + } +} + +// --- CUSTOM PAINTERS / PLACEHOLDERS (Modified to accept percentage) --- + +// Placeholder for the Semi-Circle 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: [ + // BACKGROUND GAUGE + CustomPaint( + size: const Size(120, 70), + // Pass the pending percentage to the painter + painter: _SemiCirclePainter( + canvasColor: backgroundColor, + pendingPercentage: pendingPercentage), + ), + + // CENTER CIRCLE + Positioned( + bottom: 0, + left: 30, + child: Container( + height: 60, + width: 60, + decoration: BoxDecoration( + // Center circle background is the same as the main card background (white) + color: backgroundColor, + shape: BoxShape.circle, + border: Border.all(color: Colors.black.withOpacity(0.1)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: const [ + // Icon color: Dark + Icon(Icons.work, color: Colors.black87, size: 20), + SizedBox(height: 2), + Text( + 'RISK LEVEL', + // Text color: Soft dark grey + style: TextStyle(color: Colors.black54, fontSize: 8), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +// Painter for the semi-circular gauge chart visualization (Simplified) +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); + + // Radians for a semi-circle + const double totalArc = 3.14159; + // Calculate the sweep angles + final double pendingSweepAngle = totalArc * pendingPercentage; + final double collectedSweepAngle = totalArc * (1.0 - pendingPercentage); + + // Background Arc (Full semi-circle) + final backgroundPaint = Paint() + // Background arc is a soft grey on a white theme + ..color = Colors.black.withOpacity(0.1) + ..style = PaintingStyle.stroke + ..strokeWidth = 10; + canvas.drawArc(rect, totalArc, totalArc, false, + backgroundPaint); // Start at 180 deg (Pi) + + // Pending Arc (Red/Orange segment) + final pendingPaint = Paint() + ..color = const Color(0xFFF44336) // Red + ..style = PaintingStyle.stroke + ..strokeWidth = 10 + ..shader = const LinearGradient(colors: [ + Color(0xFFFF9800), // Orange + Color(0xFFF44336), // Red + ]).createShader(rect); + // Start angle: 3.14 (180 deg) + canvas.drawArc(rect, totalArc, pendingSweepAngle, false, pendingPaint); + + // Collected Arc (Green segment) + final collectedPaint = Paint() + ..color = const Color(0xFF4CAF50) // Green + ..style = PaintingStyle.stroke + ..strokeWidth = 10; + // Start angle: 3.14 + pendingSweepAngle + canvas.drawArc(rect, totalArc + pendingSweepAngle, collectedSweepAngle, + false, collectedPaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// Placeholder for the Bar/Area Charts (Unchanged) +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, // Transparent container for visual space + 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: [ + _Bar(0.4, barColor), + _Bar(0.7, barColor), + _Bar(1.0, barColor), + _Bar(0.6, barColor), + _Bar(0.8, barColor), + ], + ); + } +} + +class _Bar extends StatelessWidget { + final double heightFactor; + final Color color; + + const _Bar(this.heightFactor, this.color); + + @override + Widget build(BuildContext context) { + return Container( + width: 8, + height: 50 * heightFactor, + decoration: BoxDecoration( + color: color, + 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), + ]; + + // Path for the area + 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(); + + // Paint for the gradient fill + final areaPaint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + areaColor.withOpacity(0.5), // Lighter opacity on white theme + areaColor.withOpacity(0.0), // Transparent + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) + ..style = PaintingStyle.fill; + canvas.drawPath(path, areaPaint); + + // Paint for the line stroke + final linePaint = Paint() + ..color = areaColor + ..strokeWidth = 2 + ..style = PaintingStyle.stroke; + canvas.drawPath(Path()..addPolygon(points, false), linePaint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// --- NEW DATA MODEL --- +class AgingBucketData { + final String title; + final double amount; + final Color color; + + AgingBucketData(this.title, this.amount, this.color); +} + +// --- NEW HELPER WIDGET: STACKED BAR VISUAL --- +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: const Center( + child: Text( + 'No Outstanding Collections', + style: TextStyle(color: Colors.black54, fontSize: 10), + ), + ), + ); + } + + // Build the segments for the Row + final List segments = + buckets.where((b) => b.amount > 0).map((bucket) { + final double flexValue = bucket.amount / totalOutstanding; + return Expanded( + flex: (flexValue * 100).toInt(), // Use a scaled integer for flex + child: Container( + height: 16, + color: bucket.color, + ), + ); + }).toList(); + + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: segments, + ), + ); + } +} 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..35eda5e --- /dev/null +++ b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart @@ -0,0 +1,857 @@ +// lib/widgets/purchase_invoice_dashboard.dart +import 'dart:convert'; +import 'package:flutter/material.dart'; + +/// ======================= +/// INTERNAL DUMMY DATA +/// ======================= + +const String _purchaseInvoiceDummyData = ''' +{ + "success": true, + "message": "Purchase invoice list fetched successfully.", + "data": { + "currentPage": 1, + "pageSize": 20, + "totalPages": 1, + "totalCount": 6, + "hasPrevious": false, + "hasNext": false, + "data": [ + { + "id": "0950d08d-a98e-43e5-a414-3de1071d0615", + "title": "Purchase of Booster Pump", + "proformaInvoiceAmount": 100000.0, + "project": { "name": "Kohinoor Sportsville" }, + "supplier": { "name": "Fire Safety Solutions" }, + "status": { "displayName": "Draft" }, + "totalAmount": 0.0 + }, + { + "id": "c8828c81-d943-4f94-8c4f-f5e985ef5eb0", + "title": "Fire Panel Purchase", + "proformaInvoiceAmount": 50000.0, + "project": { "name": "Kohinoor Sportsville" }, + "supplier": { "name": "Fire Safety Solutions" }, + "status": { "displayName": "Draft" }, + "totalAmount": 0.0 + }, + { + "id": "8bbe811f-b0fd-4b36-8c0c-3df4dd0f8aee", + "title": "Fire Pump Purchase", + "proformaInvoiceAmount": 30000.0, + "project": { "name": "Kohinoor Sportsville" }, + "supplier": { "name": "Fire Safety Solutions" }, + "status": { "displayName": "Draft" }, + "totalAmount": 0.0 + }, + { + "id": "b485a0e8-1955-44a4-8062-e2004b4d4ada", + "title": "Pipe purchase at Lodha", + "proformaInvoiceAmount": 20000.0, + "project": { "name": "LODHA GIARDINO KHARADI FAPA" }, + "supplier": { "name": "Elite Tech Services" }, + "status": { "displayName": "Draft" }, + "totalAmount": 20000.0 + }, + { + "id": "67406cdc-2b16-4290-a333-adc4fe7c0f70", + "title": "Pipe purchase For ", + "proformaInvoiceAmount": 10000.0, + "project": { "name": "Raja Bahadur International Ltd." }, + "supplier": { "name": "Elite Tech Services" }, + "status": { "displayName": "Draft" }, + "totalAmount": 9800.0 + }, + { + "id": "4b8b60cb-65ac-4eff-8d35-dc3fa2ac37aa", + "title": "Cement material supply for Basement work", + "proformaInvoiceAmount": 10000.0, + "project": { "name": "ANP Ultimas Wakad" }, + "supplier": { "name": "Skyline Fire Safety Pvt Ltd" }, + "status": { "displayName": "Draft" }, + "totalAmount": 24000.0 + } + ] + }, + "errors": null, + "statusCode": 200, + "timestamp": "2025-12-04T09:50:43.6663252Z" +} +'''; + +/// ======================= +/// REDESIGNED PUBLIC DASHBOARD WIDGET +/// ======================= +/// Parent does NOT need to pass any data. +/// Optionally, parent can pass a JSON string if desired later. + +class CompactPurchaseInvoiceDashboard extends StatelessWidget { + final String? jsonString; + + const CompactPurchaseInvoiceDashboard({ + super.key, + this.jsonString, // if null, internal dummy JSON is used + }); + + @override + Widget build(BuildContext context) { + final PurchaseInvoiceDashboardData data = + _parsePurchaseInvoiceDashboardData( + jsonString ?? _purchaseInvoiceDummyData); + + final metrics = data.metrics; + + const double mainPadding = 16.0; + const double spacing = 16.0; + const double smallSpacing = 8.0; + + // Outer Container for a polished card effect + return Container( + padding: const EdgeInsets.all(mainPadding), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), // Slightly rounder corners + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), // Softer, more subtle shadow + blurRadius: 15, + offset: const Offset(0, 5), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.min, + children: [ + const _DashboardHeader(), + const SizedBox(height: spacing), + + // 1. Total Value Card (Dominant Metric) + _TotalValueCard( + totalProformaAmount: metrics.totalProformaAmount, + totalCount: metrics.totalCount, + ), + + const SizedBox(height: spacing), + + // 2. Key Metrics Row (Condensed, Broker-style display) + _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), + + // 3. Status Breakdown (Donut Chart Style) + 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), + + // 4. Top Projects Breakdown (Top 3) + const _SectionTitle('Top Projects by Proforma Value'), + const SizedBox(height: smallSpacing), + _ProjectBreakdown( + projects: metrics.projectBuckets.take(3).toList(), + totalAmount: metrics.totalProformaAmount, + spacing: smallSpacing, + ), + ], + ), + ); + } +} + +/// ======================= +/// INTERNAL PARSING +/// ======================= + +PurchaseInvoiceDashboardData _parsePurchaseInvoiceDashboardData( + String jsonStr, +) { + final Map root = + json.decode(jsonStr) as Map; + final List rawInvoices = + (root['data']?['data'] as List?) ?? const []; + + final List invoices = rawInvoices + .whereType>() + .map(PurchaseInvoiceData.fromJson) + .toList(); + + final PurchaseInvoiceMetrics metrics = + PurchaseInvoiceMetricsCalculator().calculate(invoices); + + return PurchaseInvoiceDashboardData( + invoices: invoices, + metrics: metrics, + ); +} + +/// 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 Text( + title, + style: TextStyle( + color: Colors.grey.shade700, + fontSize: 14, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ); + } +} + +class _DashboardHeader extends StatelessWidget { + const _DashboardHeader(); + + @override + Widget build(BuildContext context) { + return const Text( + 'Purchase Invoice Dashboard ', + style: TextStyle( + color: Colors.black, + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ); + } +} + +// 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(10), + border: Border.all(color: const Color(0xFFBBDEFB), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'TOTAL PROFORMA VALUE (₹)', + style: TextStyle( + color: Colors.blue.shade800, + fontSize: 10, + fontWeight: FontWeight.w700, + letterSpacing: 1.0, + ), + ), + Icon( + Icons.account_balance_wallet_outlined, + color: Colors.blue.shade700, + size: 20, + ), + ], + ), + const SizedBox(height: 8), + Text( + // Format number with commas if needed for large values + totalProformaAmount.toStringAsFixed(0), + style: const TextStyle( + color: Colors.black, + fontSize: 32, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(height: 4), + Text( + 'Over $totalCount Total Invoices', + style: TextStyle( + color: Colors.blueGrey.shade600, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +// 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(8), + 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: Text( + title, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: color, + fontSize: 10, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + value, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w800, + ), + ), + Text( + caption, + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 9, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} + +// 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: Text( + 'No active invoices to display status breakdown.', + style: TextStyle(fontSize: 12, 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; + + // Placeholder for Donut Chart (requires external package like fl_chart for true pie/donut chart) + // We simulate the key visual hierarchy here: + // + + 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: [ + Text( + '${activeBuckets.first.title}', // Top status name + style: TextStyle( + fontSize: 10, + color: activeBuckets.first.color, + fontWeight: FontWeight.bold, + ), + ), + Text( + '${(mainPercentage * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: Colors.black87, + ), + ), + ], + ), + ), + 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: Text( + '${bucket.title} (${bucket.count})', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade800, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + '₹${bucket.amount.toStringAsFixed(0)}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + 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: Text( + 'No project data available.', + style: TextStyle(fontSize: 12, 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: [ + Text( + project.name, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + ClipRRect( + borderRadius: BorderRadius.circular(4), + 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: [ + Text( + '₹${project.amount.toStringAsFixed(0)}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: color.withOpacity(0.9), + ), + ), + Text( + '$percentageText%', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ), + ); + }).toList(), + ); + } +} 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(), ), -- 2.43.0 From 1717cd5e2b9a53aecb2a9170f94c2d928b696c5f Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Dec 2025 10:53:36 +0530 Subject: [PATCH 2/5] added mytext --- .../dashbaord/collection_dashboard_card.dart | 374 +++++++++--------- .../dashbaord/purchase_invoice_dashboard.dart | 178 ++++----- 2 files changed, 248 insertions(+), 304 deletions(-) diff --git a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart index 53fb95c..afc5556 100644 --- a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart +++ b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart @@ -1,6 +1,5 @@ -// collections_health_widget.dart - import 'package:flutter/material.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; // --- MAIN WIDGET FILE --- class CollectionsHealthWidget extends StatelessWidget { @@ -10,6 +9,7 @@ class CollectionsHealthWidget extends StatelessWidget { const double totalDue = 34190.0; const double totalCollected = 5000.0; const double totalValue = totalDue + totalCollected; + // Calculate Pending Percentage for Gauge final double pendingPercentage = totalValue > 0 ? totalDue / totalValue : 0.0; @@ -17,12 +17,10 @@ class CollectionsHealthWidget extends StatelessWidget { // 1. MAIN CARD CONTAINER (White Theme) return Container( decoration: BoxDecoration( - // Main card background color: WHITE color: Colors.white, borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( - // Lighter shadow for white background color: Colors.black.withOpacity(0.1), blurRadius: 10, offset: const Offset(0, 5), @@ -60,7 +58,7 @@ class CollectionsHealthWidget extends StatelessWidget { ], ), const SizedBox(height: 20), - // 3. NEW: AGING ANALYSIS SECTION + // 3. AGING ANALYSIS SECTION _buildAgingAnalysis(), ], ), @@ -69,19 +67,24 @@ class CollectionsHealthWidget extends StatelessWidget { // --- HELPER METHOD 1: HEADER --- Widget _buildHeader() { - return Row( - children: const [ - Text( - 'Collections Health Overview', - style: TextStyle( - // Text color: Dark for contrast on white background - color: Colors.black87, - fontSize: 18, - fontWeight: FontWeight.bold, - ), + 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, + ), + ], ), - ], - ); + ), + ]); } // --- HELPER METHOD 2: LEFT SECTION (CHARTS) --- @@ -97,38 +100,33 @@ class CollectionsHealthWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Top: Gauge Chart and Due Amount + // Top: Gauge Chart Row( children: [ - // Use White-Theme Gauge Placeholder with calculated percentage _GaugeChartPlaceholder( - backgroundColor: Colors.white, - pendingPercentage: pendingPercentage), + backgroundColor: Colors.white, + pendingPercentage: pendingPercentage, + ), const SizedBox(width: 12), ], ), - const SizedBox(height: 20), + + // Total Due + Summary Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + MyText.bodyLarge( '₹${totalDue.toStringAsFixed(0)} DUE', - style: const TextStyle( - // Text color: Dark for contrast - color: Colors.black87, - fontSize: 22, - fontWeight: FontWeight.bold, - ), + fontWeight: 700, ), const SizedBox(height: 4), - Text( + MyText.bodySmall( '• Pending ($pendingPercentStr%) • ₹${totalCollected.toStringAsFixed(0)} Collected', - // Sub-text color: Lighter dark color - style: const TextStyle(color: Colors.black54, fontSize: 12), + color: Colors.black54, ), ], ), @@ -136,6 +134,7 @@ class CollectionsHealthWidget extends StatelessWidget { ], ), const SizedBox(height: 20), + // Bottom: Timeline Charts (Trend Analysis) Row( children: [ @@ -144,34 +143,40 @@ class CollectionsHealthWidget extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Expected Collections Trend', - // Text color: Dark for contrast - style: TextStyle(color: Colors.black87, fontSize: 12)), + MyText.bodySmall( + 'Expected Collections Trend', + ), const SizedBox(height: 8), - _TimelineChartPlaceholder( - isBar: true, - barColor: const Color(0xFF2196F3)), // Blue Bar Chart - const Text('Week 16 Nov 2025', - style: TextStyle(color: Colors.black54, fontSize: 10)), + 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 for Analysis) + + // Collection Rate Trend (Area Chart Placeholder) Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Collection Rate Trend', - // Text color: Dark for contrast - style: TextStyle(color: Colors.black87, fontSize: 12)), + MyText.bodySmall( + 'Collection Rate Trend', + ), const SizedBox(height: 8), - _TimelineChartPlaceholder( - isBar: false, - areaColor: - const Color(0xFF4CAF50)), // Green Area Chart (Rate) - const Text('Week 14 Nov 2025', - style: TextStyle(color: Colors.black54, fontSize: 10)), + const _TimelineChartPlaceholder( + isBar: false, + areaColor: Color(0xFF4CAF50), + ), + MyText.bodySmall( + 'Week 14 Nov 2025', + color: Colors.black54, + ), ], ), ), @@ -188,7 +193,7 @@ class CollectionsHealthWidget extends StatelessWidget { return Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - // Metric Card 1: Top Client (From Invoice 1) + // Metric Card 1: Top Client _buildMetricCard( title: 'Top Client Balance', value: 'Peninsula Land Limited', @@ -197,7 +202,8 @@ class CollectionsHealthWidget extends StatelessWidget { isDetailed: true, ), const SizedBox(height: 10), - // Metric Card 2 (NEW): Total Collected (From Invoice 2) + + // Metric Card 2: Total Collected (YTD) _buildMetricCard( title: 'Total Collected (YTD)', value: '₹${totalCollected.toStringAsFixed(0)}', @@ -206,7 +212,8 @@ class CollectionsHealthWidget extends StatelessWidget { isDetailed: false, ), const SizedBox(height: 10), - // Metric Card 3: Days Sales Outstanding (DSO) (Analytical Placeholder) + + // Metric Card 3: DSO _buildMetricCard( title: 'Days Sales Outstanding (DSO)', value: '45 Days', @@ -215,7 +222,8 @@ class CollectionsHealthWidget extends StatelessWidget { isDetailed: false, ), const SizedBox(height: 10), - // Metric Card 4: Last Metric Card in the image (Repeating rate for layout) + + // Metric Card 4: Bad Debt Ratio _buildMetricCard( title: 'Bad Debt Ratio', value: '0.8%', @@ -227,7 +235,7 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // --- HELPER METHOD 4: METRIC CARD WIDGET (Unchanged) --- + // --- HELPER METHOD 4: METRIC CARD WIDGET --- Widget _buildMetricCard({ required String title, required String value, @@ -238,60 +246,39 @@ class CollectionsHealthWidget extends StatelessWidget { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( - // Light grey background for card separation on white theme color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(5), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + MyText.bodySmall( title, - // Title text color: Soft dark grey - style: const TextStyle(color: Colors.black54, fontSize: 10), + color: Colors.black54, ), const SizedBox(height: 2), - // Conditional rendering for the detailed vs simple card structure if (isDetailed) ...[ - Text( - value, // Client Name - style: const TextStyle( - // Value text color: Dark - color: Colors.black87, - fontSize: 12, - fontWeight: FontWeight.w600, - ), + MyText.bodySmall( + value, + fontWeight: 600, ), - Text( - subValue, // Due Amount - style: TextStyle( - // Color remains dynamic (Red/Green/etc.) - color: valueColor, - fontSize: 16, - fontWeight: FontWeight.bold, - ), + MyText.bodyMedium( + subValue, + color: valueColor, + fontWeight: 700, ), ] else Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( + MyText.bodySmall( value, - style: const TextStyle( - // Value text color: Dark - color: Colors.black87, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + fontWeight: 600, ), - Text( + MyText.bodySmall( subValue, - style: TextStyle( - // Color remains dynamic (Red/Green/etc.) - color: valueColor, - fontSize: 14, - fontWeight: FontWeight.w600, - ), + color: valueColor, + fontWeight: 600, ), ], ), @@ -301,9 +288,8 @@ class CollectionsHealthWidget extends StatelessWidget { } // --- NEW HELPER METHOD: AGING ANALYSIS --- - // --- NEW HELPER METHOD: AGING ANALYSIS (MODIFIED) --- Widget _buildAgingAnalysis() { - // Hardcoded data based on the JSON analysis + // Hardcoded data const double due0to20Days = 0.0; const double due20to45Days = 34190.0; const double due45to90Days = 0.0; @@ -314,30 +300,38 @@ class CollectionsHealthWidget extends StatelessWidget { // Define buckets with their risk color final List buckets = [ - AgingBucketData('0-20 Days', due0to20Days, - const Color(0xFF4CAF50)), // Green (Low Risk) - AgingBucketData('20-45 Days', due20to45Days, - const Color(0xFFFF9800)), // Orange (Medium Risk) - AgingBucketData('45-90 Days', due45to90Days, - const Color(0xFFF44336).withOpacity(0.7)), // Light Red (High Risk) - AgingBucketData('> 90 Days', dueOver90Days, - const Color(0xFFF44336)), // Dark Red (Very High Risk) + AgingBucketData( + '0-20 Days', + due0to20Days, + const Color(0xFF4CAF50), // Green (Low Risk) + ), + AgingBucketData( + '20-45 Days', + due20to45Days, + const Color(0xFFFF9800), // Orange (Medium Risk) + ), + AgingBucketData( + '45-90 Days', + due45to90Days, + const Color(0xFFF44336).withOpacity(0.7), // Light Red + ), + AgingBucketData( + '> 90 Days', + dueOver90Days, + const Color(0xFFF44336), // Dark Red + ), ]; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + MyText.bodyMedium( 'Outstanding Collections Aging Analysis', - style: TextStyle( - color: Colors.black87, - fontSize: 14, - fontWeight: FontWeight.bold, - ), + fontWeight: 700, ), const SizedBox(height: 10), - // NEW: STACKED BAR VISUALIZATION + // Stacked bar visualization _AgingStackedBar( buckets: buckets, totalOutstanding: totalOutstanding, @@ -345,21 +339,25 @@ class CollectionsHealthWidget extends StatelessWidget { const SizedBox(height: 15), - // NEW: LEGEND/BUCKET DETAILS (Replaces the old _buildAgingBucket layout) + // 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, + ), + ) .toList(), ), ], ); } -// Keep the old _buildAgingBucket helper and rename it to a legend item -// This is more efficient than recreating the logic + // Legend item for aging buckets Widget _buildAgingLegendItem(String title, double amount, Color color) { return Row( mainAxisSize: MainAxisSize.min, @@ -373,24 +371,25 @@ class CollectionsHealthWidget extends StatelessWidget { ), ), const SizedBox(width: 6), - Text( - '${title}: ₹${amount.toStringAsFixed(0)}', - style: const TextStyle(color: Colors.black87, fontSize: 12), + MyText.bodySmall( + '$title: ₹${amount.toStringAsFixed(0)}', ), ], ); } } -// --- CUSTOM PAINTERS / PLACEHOLDERS (Modified to accept percentage) --- +// --- CUSTOM PAINTERS / PLACEHOLDERS --- // Placeholder for the Semi-Circle Gauge Chart class _GaugeChartPlaceholder extends StatelessWidget { final Color backgroundColor; final double pendingPercentage; - const _GaugeChartPlaceholder( - {required this.backgroundColor, required this.pendingPercentage}); + const _GaugeChartPlaceholder({ + required this.backgroundColor, + required this.pendingPercentage, + }); @override Widget build(BuildContext context) { @@ -402,37 +401,22 @@ class _GaugeChartPlaceholder extends StatelessWidget { // BACKGROUND GAUGE CustomPaint( size: const Size(120, 70), - // Pass the pending percentage to the painter painter: _SemiCirclePainter( - canvasColor: backgroundColor, - pendingPercentage: pendingPercentage), + canvasColor: backgroundColor, + pendingPercentage: pendingPercentage, + ), ), - // CENTER CIRCLE - Positioned( - bottom: 0, - left: 30, - child: Container( - height: 60, - width: 60, - decoration: BoxDecoration( - // Center circle background is the same as the main card background (white) - color: backgroundColor, - shape: BoxShape.circle, - border: Border.all(color: Colors.black.withOpacity(0.1)), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: const [ - // Icon color: Dark - Icon(Icons.work, color: Colors.black87, size: 20), - SizedBox(height: 2), - Text( - 'RISK LEVEL', - // Text color: Soft dark grey - style: TextStyle(color: Colors.black54, fontSize: 8), - ), - ], + // CENTER TEXT + Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.only(bottom: 8), + child: FittedBox( + child: MyText.bodySmall( + 'RISK LEVEL', + fontWeight: 600, + ), ), ), ), @@ -442,75 +426,82 @@ class _GaugeChartPlaceholder extends StatelessWidget { } } -// Painter for the semi-circular gauge chart visualization (Simplified) +// 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) { final rect = Rect.fromCircle( - center: Offset(size.width / 2, size.height), radius: size.width / 2); + center: Offset(size.width / 2, size.height), + radius: size.width / 2, + ); - // Radians for a semi-circle const double totalArc = 3.14159; - // Calculate the sweep angles final double pendingSweepAngle = totalArc * pendingPercentage; final double collectedSweepAngle = totalArc * (1.0 - pendingPercentage); - // Background Arc (Full semi-circle) + // Background Arc final backgroundPaint = Paint() - // Background arc is a soft grey on a white theme ..color = Colors.black.withOpacity(0.1) ..style = PaintingStyle.stroke ..strokeWidth = 10; - canvas.drawArc(rect, totalArc, totalArc, false, - backgroundPaint); // Start at 180 deg (Pi) + canvas.drawArc(rect, totalArc, totalArc, false, backgroundPaint); - // Pending Arc (Red/Orange segment) + // Pending Arc final pendingPaint = Paint() - ..color = const Color(0xFFF44336) // Red ..style = PaintingStyle.stroke ..strokeWidth = 10 - ..shader = const LinearGradient(colors: [ - Color(0xFFFF9800), // Orange - Color(0xFFF44336), // Red - ]).createShader(rect); - // Start angle: 3.14 (180 deg) + ..shader = const LinearGradient( + colors: [ + Color(0xFFFF9800), + Color(0xFFF44336), + ], + ).createShader(rect); canvas.drawArc(rect, totalArc, pendingSweepAngle, false, pendingPaint); - // Collected Arc (Green segment) + // Collected Arc final collectedPaint = Paint() - ..color = const Color(0xFF4CAF50) // Green + ..color = const Color(0xFF4CAF50) ..style = PaintingStyle.stroke ..strokeWidth = 10; - // Start angle: 3.14 + pendingSweepAngle - canvas.drawArc(rect, totalArc + pendingSweepAngle, collectedSweepAngle, - false, collectedPaint); + canvas.drawArc( + rect, + totalArc + pendingSweepAngle, + collectedSweepAngle, + false, + collectedPaint, + ); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -// Placeholder for the Bar/Area Charts (Unchanged) +// 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}); + const _TimelineChartPlaceholder({ + required this.isBar, + this.barColor, + this.areaColor, + }); @override Widget build(BuildContext context) { return Container( height: 50, width: double.infinity, - color: Colors.transparent, // Transparent container for visual space + color: Colors.transparent, child: isBar ? _BarChartVisual(barColor: barColor!) : _AreaChartVisual(areaColor: areaColor!), @@ -528,12 +519,12 @@ class _BarChartVisual extends StatelessWidget { return Row( crossAxisAlignment: CrossAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _Bar(0.4, barColor), - _Bar(0.7, barColor), - _Bar(1.0, barColor), - _Bar(0.6, barColor), - _Bar(0.8, barColor), + children: const [ + _Bar(0.4), + _Bar(0.7), + _Bar(1.0), + _Bar(0.6), + _Bar(0.8), ], ); } @@ -541,17 +532,18 @@ class _BarChartVisual extends StatelessWidget { class _Bar extends StatelessWidget { final double heightFactor; - final Color color; - const _Bar(this.heightFactor, this.color); + 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: color, + color: Colors.grey.shade400, borderRadius: BorderRadius.circular(5), ), ); @@ -587,7 +579,6 @@ class _AreaChartPainter extends CustomPainter { Offset(size.width, size.height * 0.4), ]; - // Path for the area final path = Path() ..moveTo(points.first.dx, size.height) ..lineTo(points.first.dx, points.first.dy); @@ -597,20 +588,18 @@ class _AreaChartPainter extends CustomPainter { path.lineTo(points.last.dx, size.height); path.close(); - // Paint for the gradient fill final areaPaint = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - areaColor.withOpacity(0.5), // Lighter opacity on white theme - areaColor.withOpacity(0.0), // Transparent + areaColor.withOpacity(0.5), + areaColor.withOpacity(0.0), ], ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)) ..style = PaintingStyle.fill; canvas.drawPath(path, areaPaint); - // Paint for the line stroke final linePaint = Paint() ..color = areaColor ..strokeWidth = 2 @@ -622,7 +611,7 @@ class _AreaChartPainter extends CustomPainter { bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } -// --- NEW DATA MODEL --- +// --- DATA MODEL --- class AgingBucketData { final String title; final double amount; @@ -631,7 +620,7 @@ class AgingBucketData { AgingBucketData(this.title, this.amount, this.color); } -// --- NEW HELPER WIDGET: STACKED BAR VISUAL --- +// --- STACKED BAR VISUAL --- class _AgingStackedBar extends StatelessWidget { final List buckets; final double totalOutstanding; @@ -650,21 +639,20 @@ class _AgingStackedBar extends StatelessWidget { color: Colors.grey[200], borderRadius: BorderRadius.circular(8), ), - child: const Center( - child: Text( + child: Center( + child: MyText.bodySmall( 'No Outstanding Collections', - style: TextStyle(color: Colors.black54, fontSize: 10), + color: Colors.black54, ), ), ); } - // Build the segments for the Row final List segments = buckets.where((b) => b.amount > 0).map((bucket) { final double flexValue = bucket.amount / totalOutstanding; return Expanded( - flex: (flexValue * 100).toInt(), // Use a scaled integer for flex + flex: (flexValue * 100).toInt(), child: Container( height: 16, color: bucket.color, diff --git a/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart index 35eda5e..218a5d0 100644 --- a/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart +++ b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart @@ -1,6 +1,8 @@ // lib/widgets/purchase_invoice_dashboard.dart import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:on_field_work/helpers/widgets/my_text.dart'; +import 'package:on_field_work/helpers/widgets/my_spacing.dart'; /// ======================= /// INTERNAL DUMMY DATA @@ -111,7 +113,7 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget { padding: const EdgeInsets.all(mainPadding), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(10), // Slightly rounder corners + borderRadius: BorderRadius.circular(5), // Slightly rounder corners boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.08), // Softer, more subtle shadow @@ -420,14 +422,11 @@ class _SectionTitle extends StatelessWidget { @override Widget build(BuildContext context) { - return Text( + return MyText.bodySmall( title, - style: TextStyle( - color: Colors.grey.shade700, - fontSize: 14, - fontWeight: FontWeight.bold, - letterSpacing: 0.5, - ), + color: Colors.grey.shade700, + fontWeight: 700, + letterSpacing: 0.5, ); } } @@ -437,14 +436,21 @@ class _DashboardHeader extends StatelessWidget { @override Widget build(BuildContext context) { - return const Text( - 'Purchase Invoice Dashboard ', - style: TextStyle( - color: Colors.black, - fontSize: 20, - fontWeight: FontWeight.w700, - ), - ); + 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, + ), + ])) + ]); } } @@ -464,7 +470,7 @@ class _TotalValueCard extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), decoration: BoxDecoration( color: const Color(0xFFE3F2FD), // Lighter Blue - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(5), border: Border.all(color: const Color(0xFFBBDEFB), width: 1), ), child: Column( @@ -473,14 +479,11 @@ class _TotalValueCard extends StatelessWidget { Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text( + MyText.bodySmall( 'TOTAL PROFORMA VALUE (₹)', - style: TextStyle( - color: Colors.blue.shade800, - fontSize: 10, - fontWeight: FontWeight.w700, - letterSpacing: 1.0, - ), + color: Colors.blue.shade800, + fontWeight: 700, + letterSpacing: 1.0, ), Icon( Icons.account_balance_wallet_outlined, @@ -489,24 +492,15 @@ class _TotalValueCard extends StatelessWidget { ), ], ), - const SizedBox(height: 8), - Text( - // Format number with commas if needed for large values + MySpacing.height(8), + MyText.bodyMedium( totalProformaAmount.toStringAsFixed(0), - style: const TextStyle( - color: Colors.black, - fontSize: 32, - fontWeight: FontWeight.w900, - ), ), - const SizedBox(height: 4), - Text( + MySpacing.height(4), + MyText.bodySmall( 'Over $totalCount Total Invoices', - style: TextStyle( - color: Colors.blueGrey.shade600, - fontSize: 12, - fontWeight: FontWeight.w500, - ), + color: Colors.blueGrey.shade600, + fontWeight: 500, ), ], ), @@ -589,7 +583,7 @@ class _CondensedMetricCard extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 12), decoration: BoxDecoration( color: color.withOpacity(0.05), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(5), border: Border.all(color: color.withOpacity(0.15), width: 1), ), child: Column( @@ -600,35 +594,25 @@ class _CondensedMetricCard extends StatelessWidget { Icon(icon, color: color, size: 16), const SizedBox(width: 4), Expanded( - child: Text( + child: MyText.bodySmall( title, overflow: TextOverflow.ellipsis, - style: TextStyle( - color: color, - fontSize: 10, - fontWeight: FontWeight.w700, - ), + color: color, + fontWeight: 700, ), ), ], ), - const SizedBox(height: 6), - Text( + MySpacing.height(6), + MyText.bodyMedium( value, overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w800, - ), + fontWeight: 800, ), - Text( + MyText.bodySmall( caption, - style: TextStyle( - color: Colors.grey.shade500, - fontSize: 9, - fontWeight: FontWeight.w500, - ), + color: Colors.grey.shade500, + fontWeight: 500, ), ], ), @@ -656,9 +640,9 @@ class _StatusDonutChart extends StatelessWidget { if (activeBuckets.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8.0), - child: Text( + child: MyText.bodySmall( 'No active invoices to display status breakdown.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + color: Colors.grey.shade500, ), ); } @@ -667,10 +651,6 @@ class _StatusDonutChart extends StatelessWidget { final double mainPercentage = totalAmount > 0 ? activeBuckets.first.amount / totalAmount : 0.0; - // Placeholder for Donut Chart (requires external package like fl_chart for true pie/donut chart) - // We simulate the key visual hierarchy here: - // - return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -688,21 +668,13 @@ class _StatusDonutChart extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - '${activeBuckets.first.title}', // Top status name - style: TextStyle( - fontSize: 10, - color: activeBuckets.first.color, - fontWeight: FontWeight.bold, - ), + MyText.bodySmall( + activeBuckets.first.title, + color: activeBuckets.first.color, + fontWeight: 700, ), - Text( + MyText.bodyMedium( '${(mainPercentage * 100).toStringAsFixed(0)}%', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: Colors.black87, - ), ), ], ), @@ -727,22 +699,16 @@ class _StatusDonutChart extends StatelessWidget { ), ), Expanded( - child: Text( + child: MyText.bodySmall( '${bucket.title} (${bucket.count})', - style: TextStyle( - fontSize: 12, - color: Colors.grey.shade800, - fontWeight: FontWeight.w500, - ), + color: Colors.grey.shade800, + fontWeight: 500, ), ), - Text( + MyText.bodySmall( '₹${bucket.amount.toStringAsFixed(0)}', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: bucket.color.withOpacity(0.9), - ), + fontWeight: 700, + color: bucket.color.withOpacity(0.9), ), ], ), @@ -772,9 +738,9 @@ class _ProjectBreakdown extends StatelessWidget { if (projects.isEmpty) { return Padding( padding: const EdgeInsets.only(top: 8.0), - child: Text( + child: MyText.bodySmall( 'No project data available.', - style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + color: Colors.grey.shade500, ), ); } @@ -804,18 +770,14 @@ class _ProjectBreakdown extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( + MyText.bodyMedium( project.name, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.black87, - ), + fontWeight: 600, ), const SizedBox(height: 2), ClipRRect( - borderRadius: BorderRadius.circular(4), + borderRadius: BorderRadius.circular(5), child: LinearProgressIndicator( value: percentage, backgroundColor: Colors.grey.shade200, @@ -830,21 +792,15 @@ class _ProjectBreakdown extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text( + MyText.bodyMedium( '₹${project.amount.toStringAsFixed(0)}', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: color.withOpacity(0.9), - ), + fontWeight: 700, + color: color.withOpacity(0.9), ), - Text( + MyText.bodySmall( '$percentageText%', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w500, - color: Colors.grey.shade600, - ), + fontWeight: 500, + color: Colors.grey.shade600, ), ], ), -- 2.43.0 From 5d73fd6f4f91a22524f5bd058e22916430defa51 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Dec 2025 16:52:24 +0530 Subject: [PATCH 3/5] added api for dashboard for collection widget --- .../dashboard/dashboard_controller.dart | 259 ++++--- lib/helpers/services/api_endpoints.dart | 1 + lib/helpers/services/api_service.dart | 39 ++ .../dashbaord/collection_dashboard_card.dart | 635 ++++++------------ .../dashboard/collection_overview_model.dart | 192 ++++++ 5 files changed, 557 insertions(+), 569 deletions(-) create mode 100644 lib/model/dashboard/collection_overview_model.dart 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)); +} -- 2.43.0 From 8686d696f073697279f92408c90e044fec01be58 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Dec 2025 17:00:22 +0530 Subject: [PATCH 4/5] improved dashboard controller --- .../dashboard/dashboard_controller.dart | 443 +++++++----------- 1 file changed, 165 insertions(+), 278 deletions(-) diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index e2a5cf1..3681b4c 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -11,134 +11,111 @@ 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 { - // ========================= - // 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 + // 1. STATE VARIABLES // ========================= - 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 = + // 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 Rx expenseReportEndDate = DateTime.now().obs; + final expenseReportEndDate = DateTime.now().obs; - // ========================= - // Monthly Expense Report - // ========================= - final RxBool isMonthlyExpenseLoading = false.obs; - final RxList monthlyExpenseList = - [].obs; - - // Filters - final Rx selectedMonthlyExpenseDuration = + final isMonthlyExpenseLoading = false.obs; + final monthlyExpenseList = [].obs; + final selectedMonthlyExpenseDuration = MonthlyExpenseDuration.twelveMonths.obs; - final RxInt selectedMonthsCount = 12.obs; + final selectedMonthsCount = 12.obs; - final RxList expenseTypes = [].obs; - final Rx selectedExpenseType = Rx(null); + final expenseTypes = [].obs; + final selectedExpenseType = Rx(null); + // Teams/Employees final isLoadingEmployees = true.obs; - final RxList employees = [].obs; + final employees = [].obs; final uploadingStates = {}.obs; - // ========================= - // Collection Overview - // ========================= - final RxBool isCollectionOverviewLoading = false.obs; - final Rx collectionOverviewData = - Rx(null); + // Collection + final isCollectionOverviewLoading = false.obs; + final collectionOverviewData = Rx(null); + + // Constants + final List ranges = ['7D', '15D', '30D']; + static const _rangeDaysMap = { + '7D': 7, + '15D': 15, + '30D': 30, + '3M': 90, + '6M': 180 + }; + + // ========================= + // 2. COMPUTED PROPERTIES + // ========================= + + 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; - // ============================================================ - // ⭐ 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; + final double weightedDue = (data.bucket0To30Amount * _w0_30) + + (data.bucket30To60Amount * _w30_60) + + (data.bucket60To90Amount * _w60_90) + + (data.bucket90PlusAmount * _w90_plus); - // 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 + return weightedDue / data.totalDueAmount; } - // Update selected expense type - void updateSelectedExpenseType(ExpenseTypeModel? type) { - selectedExpenseType.value = type; - - if (type == null) { - fetchMonthlyExpenses(); - } else { - fetchMonthlyExpenses(categoryId: type.id); - } - } + // ========================= + // 3. LIFECYCLE + // ========================= @override void onInit() { super.onInit(); - logSafe('DashboardController initialized', level: LogLevel.info); - // React to project selection + // Project Selection Listener ever(projectController.selectedProjectId, (id) { if (id.isNotEmpty) { fetchAllDashboardData(); @@ -146,7 +123,7 @@ class DashboardController extends GetxController { } }); - // React to date range changes in expense report + // Expense Report Date Listener everAll([expenseReportStartDate, expenseReportEndDate], (_) { if (projectController.selectedProjectId.value.isNotEmpty) { fetchExpenseTypeReport( @@ -156,62 +133,67 @@ class DashboardController extends GetxController { } }); - // Attendance range + // Chart Range Listeners ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); - - // Project range 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; - } - } - - int getAttendanceDays() => _getDaysFromRange(attendanceSelectedRange.value); - int getProjectDays() => _getDaysFromRange(projectSelectedRange.value); 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; - // ========================= - // Manual Refresh - // ========================= - Future refreshDashboard() async => fetchAllDashboardData(); - Future refreshAttendance() async => fetchRoleWiseAttendance(); + 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); } - Future refreshProjects() async => fetchProjectProgress(); + // ========================= + // 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; + } + } - // ========================= - // Fetch All Dashboard Data - // ========================= Future fetchAllDashboardData() async { final String projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; @@ -232,70 +214,28 @@ class DashboardController extends GetxController { ]); } - // ========================= - // API Calls - // ========================= - Future fetchCollectionOverview() async { final projectId = projectController.selectedProjectId.value; if (projectId.isEmpty) return; - try { - isCollectionOverviewLoading.value = true; - + await _executeApiCall(isCollectionOverviewLoading, () async { final response = await ApiService.getCollectionOverview(projectId: projectId); - - if (response != null && response.success) { - collectionOverviewData.value = response.data; - } else { - collectionOverviewData.value = null; - } - } finally { - isCollectionOverviewLoading.value = false; - } + collectionOverviewData.value = + (response?.success == true) ? response!.data : null; + }); } 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; + 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); + } } - } - - isLoadingEmployees.value = false; - update(); - } - - void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { - selectedMonthlyExpenseDuration.value = duration; - - // Set months count based on selection - switch (duration) { - case MonthlyExpenseDuration.oneMonth: - selectedMonthsCount.value = 1; - break; - case MonthlyExpenseDuration.threeMonths: - selectedMonthsCount.value = 3; - break; - case MonthlyExpenseDuration.sixMonths: - selectedMonthsCount.value = 6; - break; - case MonthlyExpenseDuration.twelveMonths: - selectedMonthsCount.value = 12; - break; - case MonthlyExpenseDuration.all: - selectedMonthsCount.value = 0; // 0 = All months in your API - break; - } - - // Re-fetch updated data - fetchMonthlyExpenses(); + }); } Future fetchMasterData() async { @@ -309,149 +249,96 @@ class DashboardController extends GetxController { } Future fetchMonthlyExpenses({String? categoryId}) async { - try { - isMonthlyExpenseLoading.value = true; - + await _executeApiCall(isMonthlyExpenseLoading, () async { final response = await ApiService.getDashboardMonthlyExpensesApi( categoryId: categoryId, months: selectedMonthsCount.value, ); - - if (response != null && response.success) { - monthlyExpenseList.value = response.data; - } else { - monthlyExpenseList.clear(); - } - } finally { - isMonthlyExpenseLoading.value = false; - } + monthlyExpenseList.value = + (response?.success == true) ? response!.data : []; + }); } Future fetchPendingExpenses() async { final id = projectController.selectedProjectId.value; if (id.isEmpty) return; - try { - isPendingExpensesLoading.value = true; - + await _executeApiCall(isPendingExpensesLoading, () async { final response = await ApiService.getPendingExpensesApi(projectId: id); - - if (response != null && response.success) { - pendingExpensesData.value = response.data; - } else { - pendingExpensesData.value = null; - } - } finally { - isPendingExpensesLoading.value = false; - } + pendingExpensesData.value = + (response?.success == true) ? response!.data : null; + }); } Future fetchRoleWiseAttendance() async { final id = projectController.selectedProjectId.value; if (id.isEmpty) return; - try { - isAttendanceLoading.value = true; - + await _executeApiCall(isAttendanceLoading, () async { final response = await ApiService.getDashboardAttendanceOverview( - id, - getAttendanceDays(), - ); - - if (response != null) { - roleWiseData.value = - response.map((e) => Map.from(e)).toList(); - } else { - roleWiseData.clear(); - } - } finally { - isAttendanceLoading.value = false; - } + id, getAttendanceDays()); + roleWiseData.value = + response?.map((e) => Map.from(e)).toList() ?? []; + }); } - Future fetchExpenseTypeReport({ - required DateTime startDate, - required DateTime endDate, - }) async { + Future fetchExpenseTypeReport( + {required DateTime startDate, required DateTime endDate}) async { final id = projectController.selectedProjectId.value; if (id.isEmpty) return; - try { - isExpenseTypeReportLoading.value = true; - + await _executeApiCall(isExpenseTypeReportLoading, () async { final response = await ApiService.getExpenseTypeReportApi( projectId: id, startDate: startDate, endDate: endDate, ); - - if (response != null && response.success) { - expenseTypeReportData.value = response.data; - } else { - expenseTypeReportData.value = null; - } - } finally { - isExpenseTypeReportLoading.value = false; - } + expenseTypeReportData.value = + (response?.success == true) ? response!.data : null; + }); } Future fetchProjectProgress() async { final id = projectController.selectedProjectId.value; if (id.isEmpty) return; - try { - isProjectLoading.value = true; - + await _executeApiCall(isProjectLoading, () async { final response = await ApiService.getProjectProgress( - projectId: id, - days: getProjectDays(), - ); - - if (response != null && response.success) { - projectChartData.value = - response.data.map((d) => ChartTaskData.fromProjectData(d)).toList(); + projectId: id, days: getProjectDays()); + if (response?.success == true) { + projectChartData.value = response!.data + .map((d) => ChartTaskData.fromProjectData(d)) + .toList(); } else { projectChartData.clear(); } - } finally { - isProjectLoading.value = false; - } + }); } Future fetchDashboardTasks({required String projectId}) async { - 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; } else { totalTasks.value = 0; completedTasks.value = 0; } - } finally { - isTasksLoading.value = false; - } + }); } Future fetchDashboardTeams({required String projectId}) async { - 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; } else { totalEmployees.value = 0; inToday.value = 0; } - } finally { - isTeamsLoading.value = false; - } + }); } } -- 2.43.0 From 48a96a703bd8c422faa3a6a035bb3a19dfd45391 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Fri, 5 Dec 2025 17:24:57 +0530 Subject: [PATCH 5/5] addedapi for purchase invoice --- .../dashboard/dashboard_controller.dart | 23 +- lib/helpers/services/api_endpoints.dart | 3 + lib/helpers/services/api_service.dart | 40 ++ .../dashbaord/collection_dashboard_card.dart | 224 ++++------ .../dashbaord/purchase_invoice_dashboard.dart | 180 ++------ lib/helpers/widgets/my_custom_skeleton.dart | 418 ++++++++++++++++++ .../dashboard/purchase_invoice_model.dart | 221 +++++++++ 7 files changed, 841 insertions(+), 268 deletions(-) create mode 100644 lib/model/dashboard/purchase_invoice_model.dart diff --git a/lib/controller/dashboard/dashboard_controller.dart b/lib/controller/dashboard/dashboard_controller.dart index 3681b4c..850914c 100644 --- a/lib/controller/dashboard/dashboard_controller.dart +++ b/lib/controller/dashboard/dashboard_controller.dart @@ -9,6 +9,7 @@ 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 { // Dependencies @@ -68,9 +69,13 @@ class DashboardController extends GetxController { final uploadingStates = {}.obs; // Collection - final isCollectionOverviewLoading = false.obs; + 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 = { @@ -211,6 +216,7 @@ class DashboardController extends GetxController { fetchMonthlyExpenses(), fetchMasterData(), fetchCollectionOverview(), + fetchPurchaseInvoiceOverview(), ]); } @@ -259,6 +265,19 @@ class DashboardController extends GetxController { }); } + 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 id = projectController.selectedProjectId.value; if (id.isEmpty) return; diff --git a/lib/helpers/services/api_endpoints.dart b/lib/helpers/services/api_endpoints.dart index ce82b18..4d36d63 100644 --- a/lib/helpers/services/api_endpoints.dart +++ b/lib/helpers/services/api_endpoints.dart @@ -38,6 +38,9 @@ class ApiEndpoints { 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 ad7c3c3..6f60a02 100644 --- a/lib/helpers/services/api_service.dart +++ b/lib/helpers/services/api_service.dart @@ -46,6 +46,7 @@ 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 { @@ -317,6 +318,45 @@ class ApiService { return null; } } + + + /// ============================================ + /// 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) /// ============================================ diff --git a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart index 3ec43bd..d52bb45 100644 --- a/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart +++ b/lib/helpers/widgets/dashbaord/collection_dashboard_card.dart @@ -3,85 +3,83 @@ 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'; -// =============================================================== -// MAIN WIDGET -// =============================================================== class CollectionsHealthWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return GetBuilder( - builder: (controller) { - final data = controller.collectionOverviewData.value; - final isLoading = controller.isCollectionOverviewLoading.value; + final DashboardController controller = Get.find(); - if (isLoading) { - return const Center( - child: Padding( - padding: EdgeInsets.all(32.0), - child: CircularProgressIndicator(), - ), - ); - } + return Obx(() { + final data = controller.collectionOverviewData.value; + final isLoading = controller.isCollectionOverviewLoading.value; - 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; + // 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: 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), - ], + 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: [ @@ -100,9 +98,9 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // =============================================================== - // LEFT SECTION (GAUGE + SUMMARY + TREND PLACEHOLDERS) - // =============================================================== + // ============================== + // LEFT SECTION (GAUGE + SUMMARY) + // ============================== Widget _buildLeftChartSection({ required double totalDue, required double pendingPercentage, @@ -115,13 +113,15 @@ class CollectionsHealthWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - 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), Row( children: [ @@ -151,15 +151,13 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // =============================================================== - // RIGHT SIDE METRICS - // =============================================================== + // ============================== + // RIGHT METRICS SECTION + // ============================== Widget _buildRightMetricsSection({ required CollectionOverviewData data, required double dsoDays, }) { - final double totalCollected = data.totalCollectedAmount; - final String topClientName = data.topClient?.name ?? 'N/A'; final double topClientBalance = data.topClientBalance; @@ -175,7 +173,7 @@ class CollectionsHealthWidget extends StatelessWidget { const SizedBox(height: 10), _buildMetricCard( title: 'Total Collected (YTD)', - value: '₹${totalCollected.toStringAsFixed(0)}', + value: '₹${data.totalCollectedAmount.toStringAsFixed(0)}', subValue: 'Collected', valueColor: Colors.green, isDetailed: false, @@ -184,9 +182,6 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // =============================================================== - // METRIC CARD UI - // =============================================================== Widget _buildMetricCard({ required String title, required String value, @@ -221,35 +216,19 @@ class CollectionsHealthWidget extends StatelessWidget { ); } - // =============================================================== - // AGING ANALYSIS (DYNAMIC) - // =============================================================== + // ============================== + // 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, - ), + 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); @@ -257,19 +236,13 @@ class CollectionsHealthWidget extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - MyText.bodyMedium( - 'Outstanding Collections Aging Analysis', - fontWeight: 700, - ), + MyText.bodyMedium('Outstanding Collections Aging Analysis', + fontWeight: 700), MyText.bodySmall( - 'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}', - color: Colors.black54, - ), + 'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}', + color: Colors.black54), const SizedBox(height: 10), - _AgingStackedBar( - buckets: buckets, - totalOutstanding: totalOutstanding, - ), + _AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding), const SizedBox(height: 15), Wrap( spacing: 12, @@ -284,8 +257,7 @@ class CollectionsHealthWidget extends StatelessWidget { } Widget _buildAgingLegendItem( - String title, double amount, Color color, int count // Updated parameter - ) { + String title, double amount, Color color, int count) { return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -295,15 +267,11 @@ class CollectionsHealthWidget extends StatelessWidget { decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 6), MyText.bodySmall( - '$title: ₹${amount.toStringAsFixed(0)} (${count} Invoices)' // Updated text - ), + '$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'), ], ); } - // =============================================================== - // COMMON BOX DECORATION - // =============================================================== BoxDecoration _boxDecoration() { return BoxDecoration( color: Colors.white, diff --git a/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart index 218a5d0..b5f3fe8 100644 --- a/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart +++ b/lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart @@ -1,122 +1,63 @@ -// lib/widgets/purchase_invoice_dashboard.dart -import 'dart:convert'; 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'; - -/// ======================= -/// INTERNAL DUMMY DATA -/// ======================= - -const String _purchaseInvoiceDummyData = ''' -{ - "success": true, - "message": "Purchase invoice list fetched successfully.", - "data": { - "currentPage": 1, - "pageSize": 20, - "totalPages": 1, - "totalCount": 6, - "hasPrevious": false, - "hasNext": false, - "data": [ - { - "id": "0950d08d-a98e-43e5-a414-3de1071d0615", - "title": "Purchase of Booster Pump", - "proformaInvoiceAmount": 100000.0, - "project": { "name": "Kohinoor Sportsville" }, - "supplier": { "name": "Fire Safety Solutions" }, - "status": { "displayName": "Draft" }, - "totalAmount": 0.0 - }, - { - "id": "c8828c81-d943-4f94-8c4f-f5e985ef5eb0", - "title": "Fire Panel Purchase", - "proformaInvoiceAmount": 50000.0, - "project": { "name": "Kohinoor Sportsville" }, - "supplier": { "name": "Fire Safety Solutions" }, - "status": { "displayName": "Draft" }, - "totalAmount": 0.0 - }, - { - "id": "8bbe811f-b0fd-4b36-8c0c-3df4dd0f8aee", - "title": "Fire Pump Purchase", - "proformaInvoiceAmount": 30000.0, - "project": { "name": "Kohinoor Sportsville" }, - "supplier": { "name": "Fire Safety Solutions" }, - "status": { "displayName": "Draft" }, - "totalAmount": 0.0 - }, - { - "id": "b485a0e8-1955-44a4-8062-e2004b4d4ada", - "title": "Pipe purchase at Lodha", - "proformaInvoiceAmount": 20000.0, - "project": { "name": "LODHA GIARDINO KHARADI FAPA" }, - "supplier": { "name": "Elite Tech Services" }, - "status": { "displayName": "Draft" }, - "totalAmount": 20000.0 - }, - { - "id": "67406cdc-2b16-4290-a333-adc4fe7c0f70", - "title": "Pipe purchase For ", - "proformaInvoiceAmount": 10000.0, - "project": { "name": "Raja Bahadur International Ltd." }, - "supplier": { "name": "Elite Tech Services" }, - "status": { "displayName": "Draft" }, - "totalAmount": 9800.0 - }, - { - "id": "4b8b60cb-65ac-4eff-8d35-dc3fa2ac37aa", - "title": "Cement material supply for Basement work", - "proformaInvoiceAmount": 10000.0, - "project": { "name": "ANP Ultimas Wakad" }, - "supplier": { "name": "Skyline Fire Safety Pvt Ltd" }, - "status": { "displayName": "Draft" }, - "totalAmount": 24000.0 - } - ] - }, - "errors": null, - "statusCode": 200, - "timestamp": "2025-12-04T09:50:43.6663252Z" -} -'''; - -/// ======================= -/// REDESIGNED PUBLIC DASHBOARD WIDGET -/// ======================= -/// Parent does NOT need to pass any data. -/// Optionally, parent can pass a JSON string if desired later. +import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; class CompactPurchaseInvoiceDashboard extends StatelessWidget { - final String? jsonString; - - const CompactPurchaseInvoiceDashboard({ - super.key, - this.jsonString, // if null, internal dummy JSON is used - }); + const CompactPurchaseInvoiceDashboard({super.key}); @override Widget build(BuildContext context) { - final PurchaseInvoiceDashboardData data = - _parsePurchaseInvoiceDashboardData( - jsonString ?? _purchaseInvoiceDummyData); + final DashboardController controller = Get.find(); - final metrics = data.metrics; + // Use Obx to reactively listen to data changes + return Obx(() { + final data = controller.purchaseInvoiceOverviewData.value; - const double mainPadding = 16.0; + // 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; - // Outer Container for a polished card effect return Container( - padding: const EdgeInsets.all(mainPadding), + padding: const EdgeInsets.all(spacing), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(5), // Slightly rounder corners + borderRadius: BorderRadius.circular(5), boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.08), // Softer, more subtle shadow + color: Colors.black.withOpacity(0.08), blurRadius: 15, offset: const Offset(0, 5), ), @@ -128,40 +69,29 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget { children: [ const _DashboardHeader(), const SizedBox(height: spacing), - - // 1. Total Value Card (Dominant Metric) _TotalValueCard( totalProformaAmount: metrics.totalProformaAmount, totalCount: metrics.totalCount, ), - const SizedBox(height: spacing), - - // 2. Key Metrics Row (Condensed, Broker-style display) _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), - - // 3. Status Breakdown (Donut Chart Style) 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), - - // 4. Top Projects Breakdown (Top 3) const _SectionTitle('Top Projects by Proforma Value'), const SizedBox(height: smallSpacing), _ProjectBreakdown( @@ -175,32 +105,6 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget { } } -/// ======================= -/// INTERNAL PARSING -/// ======================= - -PurchaseInvoiceDashboardData _parsePurchaseInvoiceDashboardData( - String jsonStr, -) { - final Map root = - json.decode(jsonStr) as Map; - final List rawInvoices = - (root['data']?['data'] as List?) ?? const []; - - final List invoices = rawInvoices - .whereType>() - .map(PurchaseInvoiceData.fromJson) - .toList(); - - final PurchaseInvoiceMetrics metrics = - PurchaseInvoiceMetricsCalculator().calculate(invoices); - - return PurchaseInvoiceDashboardData( - invoices: invoices, - metrics: metrics, - ); -} - /// Container object used internally class PurchaseInvoiceDashboardData { final List invoices; 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/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, + }; + } +} -- 2.43.0