feat: product analytic
This commit is contained in:
@@ -0,0 +1,498 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
|
||||
import 'package:enaklo_pos/data/models/response/product_analytic_response_model.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class ProductAnalyticsWidget extends StatelessWidget {
|
||||
final ProductAnalyticData productData;
|
||||
final String title;
|
||||
final String searchDateFormatted;
|
||||
|
||||
const ProductAnalyticsWidget(
|
||||
{super.key,
|
||||
required this.productData,
|
||||
required this.title,
|
||||
required this.searchDateFormatted});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Proses data untuk mendapatkan insights
|
||||
final insights = _processProductData(productData);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: Border(
|
||||
left: BorderSide(
|
||||
color: const Color(0xFFD1D5DB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header Section dengan Icon dan Stats
|
||||
_buildHeader(insights),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Category Summary Cards (Horizontal Scroll)
|
||||
SizedBox(
|
||||
height: 80,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: insights.categorySummary.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = insights.categorySummary[index];
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
right: index == insights.categorySummary.length - 1
|
||||
? 0
|
||||
: 12),
|
||||
child: _buildCategorySummaryCard(
|
||||
categoryName: category.categoryName,
|
||||
productCount: category.productCount,
|
||||
totalRevenue: category.totalRevenue,
|
||||
color: _getCategoryColor(category.categoryName),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Top Products Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'Produk Berkinerja Terbaik',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF3F4F6),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD1D5DB),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Berdasarkan Pendapatan',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Product List dengan data dinamis
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: insights.topProducts.length,
|
||||
itemBuilder: (context, index) {
|
||||
final product = insights.topProducts[index];
|
||||
return _buildProductItem(
|
||||
rank: index + 1,
|
||||
product: product,
|
||||
isTopPerformer: product == insights.bestProduct,
|
||||
categoryColor: _getCategoryColor(product.categoryName),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Bottom Summary dengan insights dinamis
|
||||
_buildBottomSummary(insights.bestProduct),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Method untuk memproses data dan mendapatkan insights
|
||||
ProductInsights _processProductData(ProductAnalyticData data) {
|
||||
// Sort products by revenue (descending) untuk ranking
|
||||
List<ProductAnalyticItem> sortedProducts = List.from(data.data);
|
||||
sortedProducts.sort((a, b) => b.revenue.compareTo(a.revenue));
|
||||
|
||||
// Best product adalah yang revenue tertinggi
|
||||
ProductAnalyticItem? bestProduct;
|
||||
if (sortedProducts.isNotEmpty) {
|
||||
bestProduct = sortedProducts.first;
|
||||
}
|
||||
|
||||
// Group by category untuk summary
|
||||
Map<String, CategorySummary> categoryMap = {};
|
||||
|
||||
for (var product in data.data) {
|
||||
if (categoryMap.containsKey(product.categoryName)) {
|
||||
categoryMap[product.categoryName]!.productCount++;
|
||||
categoryMap[product.categoryName]!.totalRevenue += product.revenue;
|
||||
} else {
|
||||
categoryMap[product.categoryName] = CategorySummary(
|
||||
categoryName: product.categoryName,
|
||||
productCount: 1,
|
||||
totalRevenue: product.revenue,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert map to list dan sort by revenue
|
||||
List<CategorySummary> categorySummary = categoryMap.values.toList();
|
||||
categorySummary.sort((a, b) => b.totalRevenue.compareTo(a.totalRevenue));
|
||||
|
||||
// Calculate total metrics
|
||||
int totalProducts = data.data.length;
|
||||
int totalRevenue = data.data.fold(0, (sum, item) => sum + item.revenue);
|
||||
int totalQuantitySold =
|
||||
data.data.fold(0, (sum, item) => sum + item.quantitySold);
|
||||
|
||||
return ProductInsights(
|
||||
topProducts: sortedProducts,
|
||||
bestProduct: bestProduct,
|
||||
categorySummary: categorySummary,
|
||||
totalProducts: totalProducts,
|
||||
totalRevenue: totalRevenue,
|
||||
totalQuantitySold: totalQuantitySold,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(ProductInsights insights) {
|
||||
return Row(
|
||||
children: [
|
||||
// Icon Container
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF3B82F6),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.inventory_2,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// Title and Period
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
searchDateFormatted,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Total Products Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF059669),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'${insights.totalProducts} Produk',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomSummary(ProductAnalyticItem? bestProduct) {
|
||||
if (bestProduct == null) return Container();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF3C7),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: const Color(0xFFD97706),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.star,
|
||||
color: const Color(0xFFD97706),
|
||||
size: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${bestProduct.productName} memimpin dengan ${bestProduct.quantitySold} unit terjual dan pendapatan ${_formatCurrency(bestProduct.revenue)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xff92400E),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method untuk category color
|
||||
Color _getCategoryColor(String categoryName) {
|
||||
switch (categoryName.toLowerCase()) {
|
||||
case 'minuman':
|
||||
return const Color(0xFF06B6D4);
|
||||
case 'makanan':
|
||||
return const Color(0xFFEF4444);
|
||||
case 'snack':
|
||||
return const Color(0xFF8B5CF6);
|
||||
default:
|
||||
return const Color(0xFF6B7280);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCategorySummaryCard({
|
||||
required String categoryName,
|
||||
required int productCount,
|
||||
required int totalRevenue,
|
||||
required Color color,
|
||||
}) {
|
||||
return Container(
|
||||
width: 140,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: color.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
categoryName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$productCount items',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_formatCurrency(totalRevenue),
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProductItem({
|
||||
required int rank,
|
||||
required ProductAnalyticItem product,
|
||||
required bool isTopPerformer,
|
||||
required Color categoryColor,
|
||||
}) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
isTopPerformer ? const Color(0xFFF0F9FF) : const Color(0xFFF9FAFB),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isTopPerformer
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFFE5E7EB),
|
||||
width: isTopPerformer ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
// Rank Badge
|
||||
Container(
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: isTopPerformer
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF6B7280),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'$rank',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Product Info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
product.productName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
if (isTopPerformer)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF10B981),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
'BEST',
|
||||
style: TextStyle(
|
||||
fontSize: 8,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatCurrency(product.revenue),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: const Color(0xFF111827),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
// Category Badge
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: categoryColor.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
product.categoryName,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: categoryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Stats
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${product.quantitySold} units • ${product.orderCount} orders • Avg ${_formatCurrency(product.averagePrice.round())}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: const Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Helper method untuk format currency
|
||||
String _formatCurrency(int amount) {
|
||||
if (amount >= 1000000) {
|
||||
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M';
|
||||
} else if (amount >= 1000) {
|
||||
return 'Rp ${(amount / 1000).toStringAsFixed(0)}K';
|
||||
} else {
|
||||
return 'Rp ${NumberFormat('#,###').format(amount)}';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
// ignore_for_file: public_member_api_docs, sort_constructors_first
|
||||
|
||||
import 'package:enaklo_pos/core/components/spaces.dart';
|
||||
import 'package:enaklo_pos/presentation/report/widgets/report_page_title.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pie_chart/pie_chart.dart';
|
||||
|
||||
import 'package:enaklo_pos/data/models/response/product_sales_response_model.dart';
|
||||
|
||||
class ProductSalesChartWidgets extends StatefulWidget {
|
||||
final String title;
|
||||
final String searchDateFormatted;
|
||||
final List<ProductSales> productSales;
|
||||
const ProductSalesChartWidgets({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.searchDateFormatted,
|
||||
required this.productSales,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ProductSalesChartWidgets> createState() =>
|
||||
_ProductSalesChartWidgetsState();
|
||||
}
|
||||
|
||||
class _ProductSalesChartWidgetsState extends State<ProductSalesChartWidgets> {
|
||||
Map<String, double> dataMap2 = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
loadData();
|
||||
super.initState();
|
||||
}
|
||||
|
||||
loadData() {
|
||||
for (var data in widget.productSales) {
|
||||
dataMap2[data.productName ?? 'Unknown'] =
|
||||
double.parse(data.totalQuantity!);
|
||||
}
|
||||
}
|
||||
|
||||
final colorList = <Color>[
|
||||
const Color(0xfffdcb6e),
|
||||
const Color(0xff0984e3),
|
||||
const Color(0xfffd79a8),
|
||||
const Color(0xffe17055),
|
||||
const Color(0xff6c5ce7),
|
||||
const Color(0xfff0932b),
|
||||
const Color(0xff6ab04c),
|
||||
const Color(0xfff8a5c2),
|
||||
const Color(0xffe84393),
|
||||
const Color(0xfffd79a8),
|
||||
const Color(0xffa29bfe),
|
||||
const Color(0xff00b894),
|
||||
const Color(0xffe17055),
|
||||
const Color(0xffd63031),
|
||||
const Color(0xffa29bfe),
|
||||
const Color(0xff6c5ce7),
|
||||
const Color(0xff00cec9),
|
||||
const Color(0xfffad390),
|
||||
const Color(0xff686de0),
|
||||
const Color(0xfffdcb6e),
|
||||
const Color(0xff0984e3),
|
||||
const Color(0xfffd79a8),
|
||||
const Color(0xffe17055),
|
||||
const Color(0xff6c5ce7),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
ReportPageTitle(
|
||||
title: widget.title,
|
||||
searchDateFormatted: widget.searchDateFormatted,
|
||||
onExport: () async {},
|
||||
isExport: false, // Set to false if export is not needed
|
||||
),
|
||||
const SpaceHeight(16.0),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
margin: const EdgeInsets.all(12.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
PieChart(
|
||||
dataMap: dataMap2,
|
||||
animationDuration: Duration(milliseconds: 800),
|
||||
chartLegendSpacing: 32,
|
||||
chartRadius: MediaQuery.of(context).size.width / 3.2,
|
||||
colorList: colorList,
|
||||
initialAngleInDegree: 0,
|
||||
chartType: ChartType.disc,
|
||||
ringStrokeWidth: 32,
|
||||
// centerText: "HYBRID",
|
||||
legendOptions: LegendOptions(
|
||||
showLegendsInRow: false,
|
||||
legendPosition: LegendPosition.right,
|
||||
showLegends: true,
|
||||
legendShape: BoxShape.circle,
|
||||
legendTextStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
chartValuesOptions: ChartValuesOptions(
|
||||
showChartValueBackground: true,
|
||||
showChartValues: true,
|
||||
showChartValuesInPercentage: false,
|
||||
showChartValuesOutside: false,
|
||||
decimalPlaces: 0,
|
||||
),
|
||||
// gradientList: ---To add gradient colors---
|
||||
// emptyColorGradient: ---Empty Color gradient---
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user