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.
This commit is contained in:
parent
633a75fe92
commit
717f0c92af
@ -1,9 +1,9 @@
|
|||||||
class ApiEndpoints {
|
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://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.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://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";
|
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||||
|
|||||||
683
lib/helpers/widgets/dashbaord/collection_dashboard_card.dart
Normal file
683
lib/helpers/widgets/dashbaord/collection_dashboard_card.dart
Normal file
@ -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: <Widget>[
|
||||||
|
// 1. HEADER
|
||||||
|
_buildHeader(),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 2. MAIN CONTENT ROW (Layout)
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
// 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 <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
// Top: Gauge Chart and Due Amount
|
||||||
|
Row(
|
||||||
|
children: <Widget>[
|
||||||
|
// 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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
// Expected Collections Timeline (Bar Chart Placeholder)
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
// 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: <Widget>[
|
||||||
|
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: <Widget>[
|
||||||
|
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<AgingBucketData> 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: <Widget>[
|
||||||
|
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<AgingBucketData> 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<Widget> 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,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
857
lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart
Normal file
857
lib/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart
Normal file
@ -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<String, dynamic> root =
|
||||||
|
json.decode(jsonStr) as Map<String, dynamic>;
|
||||||
|
final List<dynamic> rawInvoices =
|
||||||
|
(root['data']?['data'] as List<dynamic>?) ?? const [];
|
||||||
|
|
||||||
|
final List<PurchaseInvoiceData> invoices = rawInvoices
|
||||||
|
.whereType<Map<String, dynamic>>()
|
||||||
|
.map(PurchaseInvoiceData.fromJson)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final PurchaseInvoiceMetrics metrics =
|
||||||
|
PurchaseInvoiceMetricsCalculator().calculate(invoices);
|
||||||
|
|
||||||
|
return PurchaseInvoiceDashboardData(
|
||||||
|
invoices: invoices,
|
||||||
|
metrics: metrics,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Container object used internally
|
||||||
|
class PurchaseInvoiceDashboardData {
|
||||||
|
final List<PurchaseInvoiceData> 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<String, dynamic> json) {
|
||||||
|
final supplier = json['supplier'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final project = json['project'] as Map<String, dynamic>? ?? const {};
|
||||||
|
final status = json['status'] as Map<String, dynamic>? ?? 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<StatusBucketData> statusBuckets;
|
||||||
|
final List<ProjectMetricData> 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<PurchaseInvoiceData> 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<String, double> supplierTotals = <String, double>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
supplierTotals.update(
|
||||||
|
invoice.supplierName,
|
||||||
|
(value) => value + invoice.proformaInvoiceAmount,
|
||||||
|
ifAbsent: () => invoice.proformaInvoiceAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final MapEntry<String, double>? 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<String, double> projectTotals = <String, double>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
projectTotals.update(
|
||||||
|
invoice.projectName,
|
||||||
|
(value) => value + invoice.proformaInvoiceAmount,
|
||||||
|
ifAbsent: () => invoice.proformaInvoiceAmount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<ProjectMetricData> projectBuckets = projectTotals.entries
|
||||||
|
.map((e) => ProjectMetricData(name: e.key, amount: e.value))
|
||||||
|
.toList()
|
||||||
|
..sort((a, b) => b.amount.compareTo(a.amount));
|
||||||
|
|
||||||
|
final Map<String, List<PurchaseInvoiceData>> statusGroups =
|
||||||
|
<String, List<PurchaseInvoiceData>>{};
|
||||||
|
for (final invoice in invoices) {
|
||||||
|
statusGroups.putIfAbsent(
|
||||||
|
invoice.statusName,
|
||||||
|
() => <PurchaseInvoiceData>[],
|
||||||
|
);
|
||||||
|
statusGroups[invoice.statusName]!.add(invoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
final List<StatusBucketData> 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<Color> colors = <Color>[
|
||||||
|
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<StatusBucketData> statusBuckets;
|
||||||
|
final double totalAmount;
|
||||||
|
|
||||||
|
const _StatusDonutChart({
|
||||||
|
required this.statusBuckets,
|
||||||
|
required this.totalAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final List<StatusBucketData> 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<ProjectMetricData> 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>(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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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/avatar.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.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/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/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_custom_skeleton.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
@ -532,7 +534,6 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Build
|
// Build
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -554,6 +555,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
_dashboardModules(),
|
_dashboardModules(),
|
||||||
MySpacing.height(20),
|
MySpacing.height(20),
|
||||||
_sectionTitle('Reports & Analytics'),
|
_sectionTitle('Reports & Analytics'),
|
||||||
|
CompactPurchaseInvoiceDashboard(),
|
||||||
|
MySpacing.height(20),
|
||||||
|
CollectionsHealthWidget(),
|
||||||
|
MySpacing.height(20),
|
||||||
_cardWrapper(
|
_cardWrapper(
|
||||||
child: ExpenseTypeReportChart(),
|
child: ExpenseTypeReportChart(),
|
||||||
),
|
),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user