From 717f0c92afd77480fe71b6515aef86f426ce77b4 Mon Sep 17 00:00:00 2001 From: Vaibhav Surve Date: Thu, 4 Dec 2025 17:54:48 +0530 Subject: [PATCH] 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(), ),