feat: product analytic

This commit is contained in:
efrilm
2025-08-06 12:32:53 +07:00
parent 648a4f5eb4
commit 91335ad8db
10 changed files with 749 additions and 204 deletions
@@ -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---
),
],
),
),
),
)
],
);
}
}