addedapi for purchase invoice

This commit is contained in:
Vaibhav Surve 2025-12-05 17:24:57 +05:30
parent 8686d696f0
commit 48a96a703b
7 changed files with 841 additions and 268 deletions

View File

@ -9,6 +9,7 @@ import 'package:on_field_work/model/dashboard/monthly_expence_model.dart';
import 'package:on_field_work/model/expense/expense_type_model.dart'; import 'package:on_field_work/model/expense/expense_type_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// Dependencies // Dependencies
@ -68,9 +69,13 @@ class DashboardController extends GetxController {
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
// Collection // Collection
final isCollectionOverviewLoading = false.obs; final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null); final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants // Constants
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = { static const _rangeDaysMap = {
@ -211,6 +216,7 @@ class DashboardController extends GetxController {
fetchMonthlyExpenses(), fetchMonthlyExpenses(),
fetchMasterData(), fetchMasterData(),
fetchCollectionOverview(), fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(),
]); ]);
} }
@ -259,6 +265,19 @@ class DashboardController extends GetxController {
}); });
} }
Future<void> fetchPurchaseInvoiceOverview() async {
final projectId = projectController.selectedProjectId.value;
if (projectId.isEmpty) return;
await _executeApiCall(isPurchaseInvoiceLoading, () async {
final response = await ApiService.getPurchaseInvoiceOverview(
projectId: projectId,
);
purchaseInvoiceOverviewData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchPendingExpenses() async { Future<void> fetchPendingExpenses() async {
final id = projectController.selectedProjectId.value; final id = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (id.isEmpty) return;

View File

@ -38,6 +38,9 @@ class ApiEndpoints {
static const String getPendingExpenses = "/Dashboard/expense/pendings"; static const String getPendingExpenses = "/Dashboard/expense/pendings";
static const String getCollectionOverview = "/dashboard/collection-overview"; static const String getCollectionOverview = "/dashboard/collection-overview";
static const String getPurchaseInvoiceOverview =
"/dashboard/purchase-invoice-overview";
///// Projects Module API Endpoints ///// Projects Module API Endpoints
static const String createProject = "/project"; static const String createProject = "/project";

View File

@ -46,6 +46,7 @@ import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/model/infra_project/infra_project_list.dart'; import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart'; import 'package:on_field_work/model/infra_project/infra_project_details.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
class ApiService { class ApiService {
@ -317,6 +318,45 @@ class ApiService {
return null; return null;
} }
} }
/// ============================================
/// GET PURCHASE INVOICE OVERVIEW (Dashboard)
/// ============================================
static Future<PurchaseInvoiceOverviewResponse?> getPurchaseInvoiceOverview({
String? projectId,
}) async {
try {
final queryParams = <String, String>{};
if (projectId != null && projectId.isNotEmpty) {
queryParams['projectId'] = projectId;
}
final response = await _getRequest(
ApiEndpoints.getPurchaseInvoiceOverview,
queryParams: queryParams,
);
if (response == null) {
_log("getPurchaseInvoiceOverview: No response from server",
level: LogLevel.error);
return null;
}
final parsedJson = _parseResponseForAllData(
response,
label: "PurchaseInvoiceOverview",
);
if (parsedJson == null) return null;
return PurchaseInvoiceOverviewResponse.fromJson(parsedJson);
} catch (e, stack) {
_log("Exception in getPurchaseInvoiceOverview: $e\n$stack",
level: LogLevel.error);
return null;
}
}
/// ============================================ /// ============================================
/// GET COLLECTION OVERVIEW (Dashboard) /// GET COLLECTION OVERVIEW (Dashboard)
/// ============================================ /// ============================================

View File

@ -3,85 +3,83 @@ import 'package:get/get.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart'; import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/model/dashboard/collection_overview_model.dart'; import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
// ===============================================================
// MAIN WIDGET
// ===============================================================
class CollectionsHealthWidget extends StatelessWidget { class CollectionsHealthWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GetBuilder<DashboardController>( final DashboardController controller = Get.find<DashboardController>();
builder: (controller) {
final data = controller.collectionOverviewData.value;
final isLoading = controller.isCollectionOverviewLoading.value;
if (isLoading) { return Obx(() {
return const Center( final data = controller.collectionOverviewData.value;
child: Padding( final isLoading = controller.isCollectionOverviewLoading.value;
padding: EdgeInsets.all(32.0),
child: CircularProgressIndicator(),
),
);
}
if (data == null) { // Loading state
return Container( if (isLoading) {
decoration: _boxDecoration(), return Container(
padding: const EdgeInsets.all(16.0), decoration: _boxDecoration(), // Maintain the outer box decoration
child: Center( padding: const EdgeInsets.all(16.0),
child: MyText.bodyMedium( child: SkeletonLoaders.collectionHealthSkeleton(),
'No collection overview data available.', );
), }
),
);
}
final double totalDue = data.totalDueAmount;
final double totalCollected = data.totalCollectedAmount;
final double pendingPercentage = data.pendingPercentage / 100.0;
final double dsoDays = controller.calculatedDSO;
// No data
if (data == null) {
return Container( return Container(
decoration: _boxDecoration(), decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: MyText.bodyMedium('No collection overview data available.'),
children: <Widget>[
_buildHeader(),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 5,
child: _buildLeftChartSection(
totalDue: totalDue,
pendingPercentage: pendingPercentage,
totalCollected: totalCollected,
),
),
const SizedBox(width: 16),
Expanded(
flex: 4,
child: _buildRightMetricsSection(
data: data,
dsoDays: dsoDays,
),
),
],
),
const SizedBox(height: 20),
_buildAgingAnalysis(data: data),
],
), ),
); );
}, }
);
// Data available
final double totalDue = data.totalDueAmount;
final double totalCollected = data.totalCollectedAmount;
final double pendingPercentage = data.pendingPercentage / 100.0;
final double dsoDays = controller.calculatedDSO;
return Container(
decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
_buildHeader(),
const SizedBox(height: 20),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Expanded(
flex: 5,
child: _buildLeftChartSection(
totalDue: totalDue,
pendingPercentage: pendingPercentage,
totalCollected: totalCollected,
),
),
const SizedBox(width: 16),
Expanded(
flex: 4,
child: _buildRightMetricsSection(
data: data,
dsoDays: dsoDays,
),
),
],
),
const SizedBox(height: 20),
_buildAgingAnalysis(data: data),
],
),
);
});
} }
// =============================================================== // ==============================
// HEADER // HEADER
// =============================================================== // ==============================
Widget _buildHeader() { Widget _buildHeader() {
return Row( return Row(
children: [ children: [
@ -100,9 +98,9 @@ class CollectionsHealthWidget extends StatelessWidget {
); );
} }
// =============================================================== // ==============================
// LEFT SECTION (GAUGE + SUMMARY + TREND PLACEHOLDERS) // LEFT SECTION (GAUGE + SUMMARY)
// =============================================================== // ==============================
Widget _buildLeftChartSection({ Widget _buildLeftChartSection({
required double totalDue, required double totalDue,
required double pendingPercentage, required double pendingPercentage,
@ -115,13 +113,15 @@ class CollectionsHealthWidget extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Row(children: [ Row(
_GaugeChartPlaceholder( children: [
backgroundColor: Colors.white, _GaugeChartPlaceholder(
pendingPercentage: pendingPercentage, backgroundColor: Colors.white,
), pendingPercentage: pendingPercentage,
const SizedBox(width: 12), ),
]), const SizedBox(width: 12),
],
),
const SizedBox(height: 20), const SizedBox(height: 20),
Row( Row(
children: [ children: [
@ -151,15 +151,13 @@ class CollectionsHealthWidget extends StatelessWidget {
); );
} }
// =============================================================== // ==============================
// RIGHT SIDE METRICS // RIGHT METRICS SECTION
// =============================================================== // ==============================
Widget _buildRightMetricsSection({ Widget _buildRightMetricsSection({
required CollectionOverviewData data, required CollectionOverviewData data,
required double dsoDays, required double dsoDays,
}) { }) {
final double totalCollected = data.totalCollectedAmount;
final String topClientName = data.topClient?.name ?? 'N/A'; final String topClientName = data.topClient?.name ?? 'N/A';
final double topClientBalance = data.topClientBalance; final double topClientBalance = data.topClientBalance;
@ -175,7 +173,7 @@ class CollectionsHealthWidget extends StatelessWidget {
const SizedBox(height: 10), const SizedBox(height: 10),
_buildMetricCard( _buildMetricCard(
title: 'Total Collected (YTD)', title: 'Total Collected (YTD)',
value: '${totalCollected.toStringAsFixed(0)}', value: '${data.totalCollectedAmount.toStringAsFixed(0)}',
subValue: 'Collected', subValue: 'Collected',
valueColor: Colors.green, valueColor: Colors.green,
isDetailed: false, isDetailed: false,
@ -184,9 +182,6 @@ class CollectionsHealthWidget extends StatelessWidget {
); );
} }
// ===============================================================
// METRIC CARD UI
// ===============================================================
Widget _buildMetricCard({ Widget _buildMetricCard({
required String title, required String title,
required String value, required String value,
@ -221,35 +216,19 @@ class CollectionsHealthWidget extends StatelessWidget {
); );
} }
// =============================================================== // ==============================
// AGING ANALYSIS (DYNAMIC) // AGING ANALYSIS
// =============================================================== // ==============================
Widget _buildAgingAnalysis({required CollectionOverviewData data}) { Widget _buildAgingAnalysis({required CollectionOverviewData data}) {
final buckets = [ final buckets = [
AgingBucketData( AgingBucketData('0-30 Days', data.bucket0To30Amount, Colors.green,
'0-30 Days', data.bucket0To30Invoices),
data.bucket0To30Amount, AgingBucketData('30-60 Days', data.bucket30To60Amount, Colors.orange,
Colors.green, data.bucket30To60Invoices),
data.bucket0To30Invoices, AgingBucketData('60-90 Days', data.bucket60To90Amount,
), Colors.red.shade300, data.bucket60To90Invoices),
AgingBucketData( AgingBucketData('> 90 Days', data.bucket90PlusAmount, Colors.red,
'30-60 Days', data.bucket90PlusInvoices),
data.bucket30To60Amount,
Colors.orange,
data.bucket30To60Invoices,
),
AgingBucketData(
'60-90 Days',
data.bucket60To90Amount,
Colors.red.shade300,
data.bucket60To90Invoices,
),
AgingBucketData(
'> 90 Days',
data.bucket90PlusAmount,
Colors.red,
data.bucket90PlusInvoices,
),
]; ];
final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount); final double totalOutstanding = buckets.fold(0, (sum, b) => sum + b.amount);
@ -257,19 +236,13 @@ class CollectionsHealthWidget extends StatelessWidget {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium( MyText.bodyMedium('Outstanding Collections Aging Analysis',
'Outstanding Collections Aging Analysis', fontWeight: 700),
fontWeight: 700,
),
MyText.bodySmall( MyText.bodySmall(
'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}', 'Total Outstanding: ₹${totalOutstanding.toStringAsFixed(0)}',
color: Colors.black54, color: Colors.black54),
),
const SizedBox(height: 10), const SizedBox(height: 10),
_AgingStackedBar( _AgingStackedBar(buckets: buckets, totalOutstanding: totalOutstanding),
buckets: buckets,
totalOutstanding: totalOutstanding,
),
const SizedBox(height: 15), const SizedBox(height: 15),
Wrap( Wrap(
spacing: 12, spacing: 12,
@ -284,8 +257,7 @@ class CollectionsHealthWidget extends StatelessWidget {
} }
Widget _buildAgingLegendItem( Widget _buildAgingLegendItem(
String title, double amount, Color color, int count // Updated parameter String title, double amount, Color color, int count) {
) {
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -295,15 +267,11 @@ class CollectionsHealthWidget extends StatelessWidget {
decoration: BoxDecoration(color: color, shape: BoxShape.circle)), decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 6), const SizedBox(width: 6),
MyText.bodySmall( MyText.bodySmall(
'$title: ₹${amount.toStringAsFixed(0)} (${count} Invoices)' // Updated text '$title: ₹${amount.toStringAsFixed(0)} ($count Invoices)'),
),
], ],
); );
} }
// ===============================================================
// COMMON BOX DECORATION
// ===============================================================
BoxDecoration _boxDecoration() { BoxDecoration _boxDecoration() {
return BoxDecoration( return BoxDecoration(
color: Colors.white, color: Colors.white,

View File

@ -1,122 +1,63 @@
// lib/widgets/purchase_invoice_dashboard.dart
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/dashboard/dashboard_controller.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.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 { class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final String? jsonString; const CompactPurchaseInvoiceDashboard({super.key});
const CompactPurchaseInvoiceDashboard({
super.key,
this.jsonString, // if null, internal dummy JSON is used
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final PurchaseInvoiceDashboardData data = final DashboardController controller = Get.find();
_parsePurchaseInvoiceDashboardData(
jsonString ?? _purchaseInvoiceDummyData);
final metrics = data.metrics; // Use Obx to reactively listen to data changes
return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value;
const double mainPadding = 16.0; // Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
}
// Show empty state if no data
if (data == null || data.totalInvoices == 0) {
return Center(
child: MyText.bodySmall('No purchase invoices found.'),
);
}
// Convert API response to internal PurchaseInvoiceData list
final invoices = (data.projectBreakdown ?? [])
.map((project) => PurchaseInvoiceData(
id: project.id ?? '',
title: project.name ?? 'Unknown',
proformaInvoiceAmount: project.totalValue ?? 0.0,
supplierName: data.topSupplier?.name ?? 'N/A',
projectName: project.name ?? 'Unknown',
statusName: 'Unknown', // API might have status if needed
))
.toList();
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics);
});
}
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
const double spacing = 16.0; const double spacing = 16.0;
const double smallSpacing = 8.0; const double smallSpacing = 8.0;
// Outer Container for a polished card effect
return Container( return Container(
padding: const EdgeInsets.all(mainPadding), padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(5), // Slightly rounder corners borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.08), // Softer, more subtle shadow color: Colors.black.withOpacity(0.08),
blurRadius: 15, blurRadius: 15,
offset: const Offset(0, 5), offset: const Offset(0, 5),
), ),
@ -128,40 +69,29 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
children: [ children: [
const _DashboardHeader(), const _DashboardHeader(),
const SizedBox(height: spacing), const SizedBox(height: spacing),
// 1. Total Value Card (Dominant Metric)
_TotalValueCard( _TotalValueCard(
totalProformaAmount: metrics.totalProformaAmount, totalProformaAmount: metrics.totalProformaAmount,
totalCount: metrics.totalCount, totalCount: metrics.totalCount,
), ),
const SizedBox(height: spacing), const SizedBox(height: spacing),
// 2. Key Metrics Row (Condensed, Broker-style display)
_CondensedMetricsRow( _CondensedMetricsRow(
draftCount: metrics.draftCount, draftCount: metrics.draftCount,
avgInvoiceValue: metrics.avgInvoiceValue, avgInvoiceValue: metrics.avgInvoiceValue,
topSupplierName: metrics.topSupplierName, topSupplierName: metrics.topSupplierName,
spacing: smallSpacing, spacing: smallSpacing,
), ),
const SizedBox(height: spacing), const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5), const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing), const SizedBox(height: spacing),
// 3. Status Breakdown (Donut Chart Style)
const _SectionTitle('Status Breakdown by Value'), const _SectionTitle('Status Breakdown by Value'),
const SizedBox(height: smallSpacing), const SizedBox(height: smallSpacing),
_StatusDonutChart( _StatusDonutChart(
statusBuckets: metrics.statusBuckets, statusBuckets: metrics.statusBuckets,
totalAmount: metrics.totalProformaAmount, totalAmount: metrics.totalProformaAmount,
), ),
const SizedBox(height: spacing), const SizedBox(height: spacing),
const Divider(height: 1, thickness: 0.5), const Divider(height: 1, thickness: 0.5),
const SizedBox(height: spacing), const SizedBox(height: spacing),
// 4. Top Projects Breakdown (Top 3)
const _SectionTitle('Top Projects by Proforma Value'), const _SectionTitle('Top Projects by Proforma Value'),
const SizedBox(height: smallSpacing), const SizedBox(height: smallSpacing),
_ProjectBreakdown( _ProjectBreakdown(
@ -175,32 +105,6 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
} }
} }
/// =======================
/// 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 /// Container object used internally
class PurchaseInvoiceDashboardData { class PurchaseInvoiceDashboardData {
final List<PurchaseInvoiceData> invoices; final List<PurchaseInvoiceData> invoices;

View File

@ -1531,6 +1531,424 @@ class SkeletonLoaders {
), ),
); );
} }
// ====================================================================
// NEW SKELETON LOADER METHODS
// ====================================================================
/// Skeleton for the CollectionsHealthWidget
static Widget collectionHealthSkeleton() {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
padding: const EdgeInsets.all(16.0),
child: ShimmerEffect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Header Skeleton
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14, width: 180, color: Colors.grey.shade300),
MySpacing.height(4),
Container(
height: 10, width: 140, color: Colors.grey.shade300),
],
),
),
],
),
const SizedBox(height: 20),
// Main Content Row (Left Chart + Right Metrics)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Left Chart Section
Expanded(
flex: 5,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Gauge Chart Placeholder
Container(
width: 120,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.vertical(
top: Radius.circular(60), bottom: Radius.zero),
),
alignment: Alignment.bottomCenter,
child: Container(
height: 12, width: 60, color: Colors.grey.shade400),
),
const SizedBox(height: 20),
// Summary Text Placeholders
Container(
height: 16, width: 150, color: Colors.grey.shade300),
MySpacing.height(6),
Container(
height: 12, width: 200, color: Colors.grey.shade300),
MySpacing.height(4),
Container(
height: 12, width: 100, color: Colors.grey.shade300),
],
),
),
const SizedBox(width: 16),
// Right Metrics Section
Expanded(
flex: 4,
child: Column(
children: [
// Metric Card 1
_buildMetricCardSkeleton(),
const SizedBox(height: 10),
// Metric Card 2
_buildMetricCardSkeleton(),
],
),
),
],
),
const SizedBox(height: 20),
// Aging Analysis Section
Container(height: 14, width: 220, color: Colors.grey.shade300),
MySpacing.height(4),
Container(height: 10, width: 180, color: Colors.grey.shade300),
const SizedBox(height: 10),
// Aging Stacked Bar Placeholder
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
children: List.generate(
4,
(index) => Expanded(
flex: 1,
child: Container(
height: 16, color: Colors.grey.shade400),
)),
),
),
const SizedBox(height: 15),
// Aging Legend Placeholders
Wrap(
spacing: 12,
runSpacing: 8,
children: List.generate(
4,
(index) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle)),
const SizedBox(width: 6),
Container(
height: 12,
width: 120,
color: Colors.grey.shade300),
],
)),
),
],
),
),
);
}
/// Helper for Metric Card Skeleton
static Widget _buildMetricCardSkeleton() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.shade200, // Background color for the card
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Container(height: 10, width: 90, color: Colors.grey.shade300),
MySpacing.height(4),
Container(height: 12, width: 100, color: Colors.grey.shade300),
MySpacing.height(4),
Container(height: 14, width: 80, color: Colors.grey.shade300),
],
),
);
}
/// Skeleton for the CompactPurchaseInvoiceDashboard
static Widget purchaseInvoiceDashboardSkeleton() {
return Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: ShimmerEffect(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
// Header Skeleton
Row(mainAxisAlignment: MainAxisAlignment.start, children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 14, width: 200, color: Colors.grey.shade300),
MySpacing.height(4),
Container(
height: 10, width: 150, color: Colors.grey.shade300),
]))
]),
const SizedBox(height: 16),
// Total Value Card Skeleton
Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200, // Simulated light blue background
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
height: 12, width: 160, color: Colors.grey.shade300),
Icon(Icons.account_balance_wallet_outlined,
color: Colors.grey.shade300, size: 20),
],
),
MySpacing.height(8),
Container(
height: 16, width: 120, color: Colors.grey.shade300),
MySpacing.height(4),
Container(
height: 12, width: 180, color: Colors.grey.shade300),
],
),
),
const SizedBox(height: 16),
// Condensed Metrics Row Skeleton
Row(
children: List.generate(
3,
(index) => Expanded(
child: Padding(
padding: index == 1
? MySpacing.symmetric(horizontal: 8)
: EdgeInsets.zero,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade200, // Card background
borderRadius: BorderRadius.circular(5),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.circle,
color: Colors.grey.shade300, size: 16),
MySpacing.width(4),
Container(
height: 10,
width: 50,
color: Colors.grey.shade300),
],
),
MySpacing.height(6),
Container(
height: 14,
width: 80,
color: Colors.grey.shade300),
Container(
height: 10,
width: 60,
color: Colors.grey.shade300),
],
),
),
),
)),
),
const SizedBox(height: 16),
const Divider(
height: 1,
thickness: 0.5,
color: Colors.transparent), // Hidden divider for spacing
const SizedBox(height: 16),
// Status Breakdown Section Skeleton (Chart + Legend)
Container(height: 12, width: 180, color: Colors.grey.shade300),
const SizedBox(height: 8),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Donut Chart Placeholder
Container(
width: 120,
height: 120,
alignment: Alignment.center,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: Colors.grey.shade300, width: 6),
color: Colors.grey.shade200,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
height: 10, width: 60, color: Colors.grey.shade300),
MySpacing.height(4),
Container(
height: 14, width: 40, color: Colors.grey.shade300),
],
),
),
const SizedBox(width: 16),
// Legend/Details Placeholder
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Container(
width: 8,
height: 8,
margin:
const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle)),
Container(
height: 12,
width: 80,
color: Colors.grey.shade300),
],
),
Container(
height: 14,
width: 50,
color: Colors.grey.shade300),
],
),
)),
),
),
],
),
const SizedBox(height: 16),
const Divider(
height: 1,
thickness: 0.5,
color: Colors.transparent), // Hidden divider for spacing
const SizedBox(height: 16),
// Top Projects Section Skeleton
Container(height: 12, width: 200, color: Colors.grey.shade300),
const SizedBox(height: 8),
Column(
children: List.generate(
3,
(index) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 6,
height: 6,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.grey.shade300,
shape: BoxShape.circle)),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: 12,
width: double.infinity,
color: Colors.grey.shade300),
MySpacing.height(4),
ClipRRect(
borderRadius: BorderRadius.circular(5),
child: Container(
height: 4, color: Colors.grey.shade300),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
height: 14,
width: 70,
color: Colors.grey.shade300),
MySpacing.height(2),
Container(
height: 10,
width: 40,
color: Colors.grey.shade300),
],
),
],
),
)),
),
],
),
),
);
}
} }
/// A custom reusable Shimmer Effect widget. /// A custom reusable Shimmer Effect widget.

View File

@ -0,0 +1,221 @@
// ============================
// PurchaseInvoiceOverviewModel.dart
// ============================
class PurchaseInvoiceOverviewResponse {
final bool? success;
final String? message;
final PurchaseInvoiceOverviewData? data;
final dynamic errors;
final int? statusCode;
final DateTime? timestamp;
PurchaseInvoiceOverviewResponse({
this.success,
this.message,
this.data,
this.errors,
this.statusCode,
this.timestamp,
});
factory PurchaseInvoiceOverviewResponse.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewResponse(
success: json['success'] as bool?,
message: json['message'] as String?,
data: json['data'] != null
? PurchaseInvoiceOverviewData.fromJson(json['data'])
: null,
errors: json['errors'],
statusCode: json['statusCode'] as int?,
timestamp: json['timestamp'] != null
? DateTime.tryParse(json['timestamp'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data?.toJson(),
'errors': errors,
'statusCode': statusCode,
'timestamp': timestamp?.toIso8601String(),
};
}
}
class PurchaseInvoiceOverviewData {
final int? totalInvoices;
final double? totalValue;
final double? averageValue;
final List<StatusBreakdown>? statusBreakdown;
final List<ProjectBreakdown>? projectBreakdown;
final TopSupplier? topSupplier;
PurchaseInvoiceOverviewData({
this.totalInvoices,
this.totalValue,
this.averageValue,
this.statusBreakdown,
this.projectBreakdown,
this.topSupplier,
});
factory PurchaseInvoiceOverviewData.fromJson(Map<String, dynamic> json) {
return PurchaseInvoiceOverviewData(
totalInvoices: json['totalInvoices'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
averageValue: (json['averageValue'] != null)
? (json['averageValue'] as num).toDouble()
: null,
statusBreakdown: json['statusBreakdown'] != null
? (json['statusBreakdown'] as List)
.map((e) => StatusBreakdown.fromJson(e))
.toList()
: null,
projectBreakdown: json['projectBreakdown'] != null
? (json['projectBreakdown'] as List)
.map((e) => ProjectBreakdown.fromJson(e))
.toList()
: null,
topSupplier: json['topSupplier'] != null
? TopSupplier.fromJson(json['topSupplier'])
: null,
);
}
Map<String, dynamic> toJson() {
return {
'totalInvoices': totalInvoices,
'totalValue': totalValue,
'averageValue': averageValue,
'statusBreakdown': statusBreakdown?.map((e) => e.toJson()).toList(),
'projectBreakdown': projectBreakdown?.map((e) => e.toJson()).toList(),
'topSupplier': topSupplier?.toJson(),
};
}
}
class StatusBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
StatusBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory StatusBreakdown.fromJson(Map<String, dynamic> json) {
return StatusBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class ProjectBreakdown {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
ProjectBreakdown({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory ProjectBreakdown.fromJson(Map<String, dynamic> json) {
return ProjectBreakdown(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}
class TopSupplier {
final String? id;
final String? name;
final int? count;
final double? totalValue;
final double? percentage;
TopSupplier({
this.id,
this.name,
this.count,
this.totalValue,
this.percentage,
});
factory TopSupplier.fromJson(Map<String, dynamic> json) {
return TopSupplier(
id: json['id'] as String?,
name: json['name'] as String?,
count: json['count'] as int?,
totalValue: (json['totalValue'] != null)
? (json['totalValue'] as num).toDouble()
: null,
percentage: (json['percentage'] != null)
? (json['percentage'] as num).toDouble()
: null,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'count': count,
'totalValue': totalValue,
'percentage': percentage,
};
}
}