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

View File

@ -6,6 +6,7 @@ import 'package:enaklo_pos/core/constants/variables.dart';
import 'package:enaklo_pos/core/network/dio_client.dart';
import 'package:enaklo_pos/data/datasources/auth_local_datasource.dart';
import 'package:enaklo_pos/data/models/response/payment_method_analytic_response_model.dart';
import 'package:enaklo_pos/data/models/response/product_analytic_response_model.dart';
import 'package:enaklo_pos/data/models/response/sales_analytic_response_model.dart';
import 'package:intl/intl.dart';
@ -79,4 +80,38 @@ class AnalyticRemoteDatasource {
return left('Unexpected error occurred');
}
}
Future<Either<String, ProductAnalyticResponseModel>> getProduct({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
final authData = await AuthLocalDataSource().getAuthData();
final headers = {
'Authorization': 'Bearer ${authData.token}',
'Accept': 'application/json',
};
try {
final response = await dio.get(
'${Variables.baseUrl}/api/v1/analytics/products',
queryParameters: {
'date_from': DateFormat('dd-MM-yyyy').format(dateFrom),
'date_to': DateFormat('dd-MM-yyyy').format(dateTo),
},
options: Options(headers: headers),
);
if (response.statusCode == 200) {
return right(ProductAnalyticResponseModel.fromMap(response.data));
} else {
return left('Terjadi Kesalahan, Coba lagi nanti.');
}
} on DioException catch (e) {
log('Dio error: ${e.message}');
return left(e.response?.data.toString() ?? e.message ?? 'Unknown error');
} catch (e) {
log('Unexpected error: $e');
return left('Unexpected error occurred');
}
}
}

View File

@ -0,0 +1,143 @@
class ProductAnalyticResponseModel {
final bool success;
final ProductAnalyticData data;
final dynamic errors;
ProductAnalyticResponseModel({
required this.success,
required this.data,
this.errors,
});
factory ProductAnalyticResponseModel.fromJson(Map<String, dynamic> json) =>
ProductAnalyticResponseModel.fromMap(json);
Map<String, dynamic> toJson() => toMap();
factory ProductAnalyticResponseModel.fromMap(Map<String, dynamic> map) {
return ProductAnalyticResponseModel(
success: map['success'] ?? false,
data: ProductAnalyticData.fromMap(map['data']),
errors: map['errors'],
);
}
Map<String, dynamic> toMap() {
return {
'success': success,
'data': data.toMap(),
'errors': errors,
};
}
}
class ProductAnalyticData {
final String organizationId;
final String outletId;
final DateTime dateFrom;
final DateTime dateTo;
final List<ProductAnalyticItem> data;
ProductAnalyticData({
required this.organizationId,
required this.outletId,
required this.dateFrom,
required this.dateTo,
required this.data,
});
factory ProductAnalyticData.fromMap(Map<String, dynamic> map) =>
ProductAnalyticData(
organizationId: map['organization_id'],
outletId: map['outlet_id'],
dateFrom: DateTime.parse(map['date_from']),
dateTo: DateTime.parse(map['date_to']),
data: List<ProductAnalyticItem>.from(
map['data'].map((x) => ProductAnalyticItem.fromMap(x)),
),
);
Map<String, dynamic> toMap() => {
'organization_id': organizationId,
'outlet_id': outletId,
'date_from': dateFrom.toIso8601String(),
'date_to': dateTo.toIso8601String(),
'data': data.map((x) => x.toMap()).toList(),
};
}
class ProductAnalyticItem {
final String productId;
final String productName;
final String categoryId;
final String categoryName;
final int quantitySold;
final int revenue;
final double averagePrice;
final int orderCount;
ProductAnalyticItem({
required this.productId,
required this.productName,
required this.categoryId,
required this.categoryName,
required this.quantitySold,
required this.revenue,
required this.averagePrice,
required this.orderCount,
});
factory ProductAnalyticItem.fromMap(Map<String, dynamic> map) =>
ProductAnalyticItem(
productId: map['product_id'],
productName: map['product_name'],
categoryId: map['category_id'],
categoryName: map['category_name'],
quantitySold: map['quantity_sold'],
revenue: map['revenue'],
averagePrice: (map['average_price'] as num).toDouble(),
orderCount: map['order_count'],
);
Map<String, dynamic> toMap() => {
'product_id': productId,
'product_name': productName,
'category_id': categoryId,
'category_name': categoryName,
'quantity_sold': quantitySold,
'revenue': revenue,
'average_price': averagePrice,
'order_count': orderCount,
};
}
class ProductInsights {
final List<ProductAnalyticItem> topProducts;
final ProductAnalyticItem? bestProduct;
final List<CategorySummary> categorySummary;
final int totalProducts;
final int totalRevenue;
final int totalQuantitySold;
ProductInsights({
required this.topProducts,
required this.bestProduct,
required this.categorySummary,
required this.totalProducts,
required this.totalRevenue,
required this.totalQuantitySold,
});
}
// Category summary class
class CategorySummary {
final String categoryName;
int productCount;
int totalRevenue;
CategorySummary({
required this.categoryName,
required this.productCount,
required this.totalRevenue,
});
}

View File

@ -193,7 +193,7 @@ class _MyAppState extends State<MyApp> {
create: (context) => SummaryBloc(OrderRemoteDatasource()),
),
BlocProvider(
create: (context) => ProductSalesBloc(OrderItemRemoteDatasource()),
create: (context) => ProductSalesBloc(AnalyticRemoteDatasource()),
),
BlocProvider(
create: (context) => ItemSalesReportBloc(AnalyticRemoteDatasource()),

View File

@ -1,6 +1,6 @@
import 'package:enaklo_pos/data/datasources/analytic_remote_datasource.dart';
import 'package:enaklo_pos/data/models/response/product_analytic_response_model.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:enaklo_pos/data/datasources/order_item_remote_datasource.dart';
import 'package:enaklo_pos/data/models/response/product_sales_response_model.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product_sales_event.dart';
@ -8,14 +8,16 @@ part 'product_sales_state.dart';
part 'product_sales_bloc.freezed.dart';
class ProductSalesBloc extends Bloc<ProductSalesEvent, ProductSalesState> {
final OrderItemRemoteDatasource datasource;
final AnalyticRemoteDatasource datasource;
ProductSalesBloc(
this.datasource,
) : super(const _Initial()) {
on<_GetProductSales>((event, emit) async {
emit(const _Loading());
final result = await datasource.getProductSalesByRangeDate(
event.startDate, event.endDate);
final result = await datasource.getProduct(
dateFrom: event.startDate,
dateTo: event.endDate,
);
result.fold((l) => emit(_Error(l)), (r) => emit(_Success(r.data!)));
});
}

View File

@ -19,19 +19,20 @@ mixin _$ProductSalesEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() started,
required TResult Function(String startDate, String endDate) getProductSales,
required TResult Function(DateTime startDate, DateTime endDate)
getProductSales,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? started,
TResult? Function(String startDate, String endDate)? getProductSales,
TResult? Function(DateTime startDate, DateTime endDate)? getProductSales,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? started,
TResult Function(String startDate, String endDate)? getProductSales,
TResult Function(DateTime startDate, DateTime endDate)? getProductSales,
required TResult orElse(),
}) =>
throw _privateConstructorUsedError;
@ -119,7 +120,8 @@ class _$StartedImpl implements _Started {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() started,
required TResult Function(String startDate, String endDate) getProductSales,
required TResult Function(DateTime startDate, DateTime endDate)
getProductSales,
}) {
return started();
}
@ -128,7 +130,7 @@ class _$StartedImpl implements _Started {
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? started,
TResult? Function(String startDate, String endDate)? getProductSales,
TResult? Function(DateTime startDate, DateTime endDate)? getProductSales,
}) {
return started?.call();
}
@ -137,7 +139,7 @@ class _$StartedImpl implements _Started {
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? started,
TResult Function(String startDate, String endDate)? getProductSales,
TResult Function(DateTime startDate, DateTime endDate)? getProductSales,
required TResult orElse(),
}) {
if (started != null) {
@ -188,7 +190,7 @@ abstract class _$$GetProductSalesImplCopyWith<$Res> {
$Res Function(_$GetProductSalesImpl) then) =
__$$GetProductSalesImplCopyWithImpl<$Res>;
@useResult
$Res call({String startDate, String endDate});
$Res call({DateTime startDate, DateTime endDate});
}
/// @nodoc
@ -211,11 +213,11 @@ class __$$GetProductSalesImplCopyWithImpl<$Res>
null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as String,
as DateTime,
null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as String,
as DateTime,
));
}
}
@ -226,9 +228,9 @@ class _$GetProductSalesImpl implements _GetProductSales {
const _$GetProductSalesImpl(this.startDate, this.endDate);
@override
final String startDate;
final DateTime startDate;
@override
final String endDate;
final DateTime endDate;
@override
String toString() {
@ -261,7 +263,8 @@ class _$GetProductSalesImpl implements _GetProductSales {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() started,
required TResult Function(String startDate, String endDate) getProductSales,
required TResult Function(DateTime startDate, DateTime endDate)
getProductSales,
}) {
return getProductSales(startDate, endDate);
}
@ -270,7 +273,7 @@ class _$GetProductSalesImpl implements _GetProductSales {
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? started,
TResult? Function(String startDate, String endDate)? getProductSales,
TResult? Function(DateTime startDate, DateTime endDate)? getProductSales,
}) {
return getProductSales?.call(startDate, endDate);
}
@ -279,7 +282,7 @@ class _$GetProductSalesImpl implements _GetProductSales {
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? started,
TResult Function(String startDate, String endDate)? getProductSales,
TResult Function(DateTime startDate, DateTime endDate)? getProductSales,
required TResult orElse(),
}) {
if (getProductSales != null) {
@ -321,11 +324,11 @@ class _$GetProductSalesImpl implements _GetProductSales {
}
abstract class _GetProductSales implements ProductSalesEvent {
const factory _GetProductSales(final String startDate, final String endDate) =
_$GetProductSalesImpl;
const factory _GetProductSales(
final DateTime startDate, final DateTime endDate) = _$GetProductSalesImpl;
String get startDate;
String get endDate;
DateTime get startDate;
DateTime get endDate;
/// Create a copy of ProductSalesEvent
/// with the given fields replaced by the non-null parameter values.
@ -340,7 +343,7 @@ mixin _$ProductSalesState {
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(List<ProductSales> productSales) success,
required TResult Function(ProductAnalyticData product) success,
required TResult Function(String message) error,
}) =>
throw _privateConstructorUsedError;
@ -348,7 +351,7 @@ mixin _$ProductSalesState {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(List<ProductSales> productSales)? success,
TResult? Function(ProductAnalyticData product)? success,
TResult? Function(String message)? error,
}) =>
throw _privateConstructorUsedError;
@ -356,7 +359,7 @@ mixin _$ProductSalesState {
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(List<ProductSales> productSales)? success,
TResult Function(ProductAnalyticData product)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) =>
@ -452,7 +455,7 @@ class _$InitialImpl implements _Initial {
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(List<ProductSales> productSales) success,
required TResult Function(ProductAnalyticData product) success,
required TResult Function(String message) error,
}) {
return initial();
@ -463,7 +466,7 @@ class _$InitialImpl implements _Initial {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(List<ProductSales> productSales)? success,
TResult? Function(ProductAnalyticData product)? success,
TResult? Function(String message)? error,
}) {
return initial?.call();
@ -474,7 +477,7 @@ class _$InitialImpl implements _Initial {
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(List<ProductSales> productSales)? success,
TResult Function(ProductAnalyticData product)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) {
@ -569,7 +572,7 @@ class _$LoadingImpl implements _Loading {
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(List<ProductSales> productSales) success,
required TResult Function(ProductAnalyticData product) success,
required TResult Function(String message) error,
}) {
return loading();
@ -580,7 +583,7 @@ class _$LoadingImpl implements _Loading {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(List<ProductSales> productSales)? success,
TResult? Function(ProductAnalyticData product)? success,
TResult? Function(String message)? error,
}) {
return loading?.call();
@ -591,7 +594,7 @@ class _$LoadingImpl implements _Loading {
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(List<ProductSales> productSales)? success,
TResult Function(ProductAnalyticData product)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) {
@ -649,7 +652,7 @@ abstract class _$$SuccessImplCopyWith<$Res> {
_$SuccessImpl value, $Res Function(_$SuccessImpl) then) =
__$$SuccessImplCopyWithImpl<$Res>;
@useResult
$Res call({List<ProductSales> productSales});
$Res call({ProductAnalyticData product});
}
/// @nodoc
@ -665,13 +668,13 @@ class __$$SuccessImplCopyWithImpl<$Res>
@pragma('vm:prefer-inline')
@override
$Res call({
Object? productSales = null,
Object? product = null,
}) {
return _then(_$SuccessImpl(
null == productSales
? _value._productSales
: productSales // ignore: cast_nullable_to_non_nullable
as List<ProductSales>,
null == product
? _value.product
: product // ignore: cast_nullable_to_non_nullable
as ProductAnalyticData,
));
}
}
@ -679,20 +682,14 @@ class __$$SuccessImplCopyWithImpl<$Res>
/// @nodoc
class _$SuccessImpl implements _Success {
const _$SuccessImpl(final List<ProductSales> productSales)
: _productSales = productSales;
const _$SuccessImpl(this.product);
final List<ProductSales> _productSales;
@override
List<ProductSales> get productSales {
if (_productSales is EqualUnmodifiableListView) return _productSales;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_productSales);
}
final ProductAnalyticData product;
@override
String toString() {
return 'ProductSalesState.success(productSales: $productSales)';
return 'ProductSalesState.success(product: $product)';
}
@override
@ -700,13 +697,11 @@ class _$SuccessImpl implements _Success {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SuccessImpl &&
const DeepCollectionEquality()
.equals(other._productSales, _productSales));
(identical(other.product, product) || other.product == product));
}
@override
int get hashCode => Object.hash(
runtimeType, const DeepCollectionEquality().hash(_productSales));
int get hashCode => Object.hash(runtimeType, product);
/// Create a copy of ProductSalesState
/// with the given fields replaced by the non-null parameter values.
@ -721,10 +716,10 @@ class _$SuccessImpl implements _Success {
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(List<ProductSales> productSales) success,
required TResult Function(ProductAnalyticData product) success,
required TResult Function(String message) error,
}) {
return success(productSales);
return success(product);
}
@override
@ -732,10 +727,10 @@ class _$SuccessImpl implements _Success {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(List<ProductSales> productSales)? success,
TResult? Function(ProductAnalyticData product)? success,
TResult? Function(String message)? error,
}) {
return success?.call(productSales);
return success?.call(product);
}
@override
@ -743,12 +738,12 @@ class _$SuccessImpl implements _Success {
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(List<ProductSales> productSales)? success,
TResult Function(ProductAnalyticData product)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) {
if (success != null) {
return success(productSales);
return success(product);
}
return orElse();
}
@ -792,9 +787,9 @@ class _$SuccessImpl implements _Success {
}
abstract class _Success implements ProductSalesState {
const factory _Success(final List<ProductSales> productSales) = _$SuccessImpl;
const factory _Success(final ProductAnalyticData product) = _$SuccessImpl;
List<ProductSales> get productSales;
ProductAnalyticData get product;
/// Create a copy of ProductSalesState
/// with the given fields replaced by the non-null parameter values.
@ -873,7 +868,7 @@ class _$ErrorImpl implements _Error {
TResult when<TResult extends Object?>({
required TResult Function() initial,
required TResult Function() loading,
required TResult Function(List<ProductSales> productSales) success,
required TResult Function(ProductAnalyticData product) success,
required TResult Function(String message) error,
}) {
return error(message);
@ -884,7 +879,7 @@ class _$ErrorImpl implements _Error {
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? initial,
TResult? Function()? loading,
TResult? Function(List<ProductSales> productSales)? success,
TResult? Function(ProductAnalyticData product)? success,
TResult? Function(String message)? error,
}) {
return error?.call(message);
@ -895,7 +890,7 @@ class _$ErrorImpl implements _Error {
TResult maybeWhen<TResult extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(List<ProductSales> productSales)? success,
TResult Function(ProductAnalyticData product)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) {

View File

@ -4,7 +4,7 @@ part of 'product_sales_bloc.dart';
class ProductSalesEvent with _$ProductSalesEvent {
const factory ProductSalesEvent.started() = _Started;
const factory ProductSalesEvent.getProductSales(
String startDate,
String endDate,
DateTime startDate,
DateTime endDate,
) = _GetProductSales;
}

View File

@ -6,7 +6,7 @@ class ProductSalesState with _$ProductSalesState {
const factory ProductSalesState.loading() = _Loading;
const factory ProductSalesState.success(List<ProductSales> productSales) =
const factory ProductSalesState.success(ProductAnalyticData product) =
_Success;
const factory ProductSalesState.error(String message) = _Error;

View File

@ -14,7 +14,7 @@ import 'package:enaklo_pos/presentation/report/blocs/summary/summary_bloc.dart';
import 'package:enaklo_pos/presentation/report/blocs/transaction_report/transaction_report_bloc.dart';
import 'package:enaklo_pos/presentation/report/widgets/item_sales_report_widget.dart';
import 'package:enaklo_pos/presentation/report/widgets/payment_method_report_widget.dart';
import 'package:enaklo_pos/presentation/report/widgets/product_sales_chart_widget.dart';
import 'package:enaklo_pos/presentation/report/widgets/product_analytic_widget.dart';
import 'package:enaklo_pos/presentation/report/widgets/report_menu.dart';
import 'package:enaklo_pos/presentation/report/widgets/report_title.dart';
import 'package:flutter/material.dart';
@ -139,19 +139,19 @@ class _ReportPageState extends State<ReportPage> {
isActive: selectedMenu == 1,
),
ReportMenu(
label: 'Chart Penjualan Produk',
label: 'Laporan Penjualan Produk',
subtitle:
'Grafik visual penjualan produk untuk analisa performa penjualan.',
'Laporan penjualan berdasarkan masing-masing produk.',
icon: Icons.bar_chart_outlined,
onPressed: () {
selectedMenu = 2;
title = 'Chart Penjualan Produk';
title = 'Laporan Penjualan Produk';
setState(() {});
context.read<ProductSalesBloc>().add(
ProductSalesEvent.getProductSales(
DateFormatter.formatDateTime(
fromDate),
DateFormatter.formatDateTime(toDate)),
fromDate,
toDate,
),
);
},
isActive: selectedMenu == 2,
@ -262,12 +262,12 @@ class _ReportPageState extends State<ReportPage> {
error: (message) {
return Text(message);
},
success: (productSales) {
return ProductSalesChartWidgets(
success: (products) {
return ProductAnalyticsWidget(
title: title,
searchDateFormatted:
searchDateFormatted,
productSales: productSales,
productData: products,
);
},
);

View File

@ -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)}';
}
}
}

View File

@ -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---
),
],
),
),
),
)
],
);
}
}