From 91335ad8db8b2ffbdbcf7939492ce647e2dbbe48 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 6 Aug 2025 12:32:53 +0700 Subject: [PATCH] feat: product analytic --- .../analytic_remote_datasource.dart | 35 ++ .../product_analytic_response_model.dart | 143 +++++ lib/main.dart | 2 +- .../product_sales/product_sales_bloc.dart | 12 +- .../product_sales_bloc.freezed.dart | 109 ++-- .../product_sales/product_sales_event.dart | 4 +- .../product_sales/product_sales_state.dart | 2 +- .../report/pages/report_page.dart | 20 +- .../widgets/product_analytic_widget.dart | 498 ++++++++++++++++++ .../widgets/product_sales_chart_widget.dart | 128 ----- 10 files changed, 749 insertions(+), 204 deletions(-) create mode 100644 lib/data/models/response/product_analytic_response_model.dart create mode 100644 lib/presentation/report/widgets/product_analytic_widget.dart delete mode 100644 lib/presentation/report/widgets/product_sales_chart_widget.dart diff --git a/lib/data/datasources/analytic_remote_datasource.dart b/lib/data/datasources/analytic_remote_datasource.dart index 82b9815..1857f2f 100644 --- a/lib/data/datasources/analytic_remote_datasource.dart +++ b/lib/data/datasources/analytic_remote_datasource.dart @@ -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> 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'); + } + } } diff --git a/lib/data/models/response/product_analytic_response_model.dart b/lib/data/models/response/product_analytic_response_model.dart new file mode 100644 index 0000000..2e8f6ba --- /dev/null +++ b/lib/data/models/response/product_analytic_response_model.dart @@ -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 json) => + ProductAnalyticResponseModel.fromMap(json); + + Map toJson() => toMap(); + + factory ProductAnalyticResponseModel.fromMap(Map map) { + return ProductAnalyticResponseModel( + success: map['success'] ?? false, + data: ProductAnalyticData.fromMap(map['data']), + errors: map['errors'], + ); + } + + Map 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 data; + + ProductAnalyticData({ + required this.organizationId, + required this.outletId, + required this.dateFrom, + required this.dateTo, + required this.data, + }); + + factory ProductAnalyticData.fromMap(Map map) => + ProductAnalyticData( + organizationId: map['organization_id'], + outletId: map['outlet_id'], + dateFrom: DateTime.parse(map['date_from']), + dateTo: DateTime.parse(map['date_to']), + data: List.from( + map['data'].map((x) => ProductAnalyticItem.fromMap(x)), + ), + ); + + Map 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 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 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 topProducts; + final ProductAnalyticItem? bestProduct; + final List 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, + }); +} diff --git a/lib/main.dart b/lib/main.dart index 1db6de7..fde4e83 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -193,7 +193,7 @@ class _MyAppState extends State { create: (context) => SummaryBloc(OrderRemoteDatasource()), ), BlocProvider( - create: (context) => ProductSalesBloc(OrderItemRemoteDatasource()), + create: (context) => ProductSalesBloc(AnalyticRemoteDatasource()), ), BlocProvider( create: (context) => ItemSalesReportBloc(AnalyticRemoteDatasource()), diff --git a/lib/presentation/report/blocs/product_sales/product_sales_bloc.dart b/lib/presentation/report/blocs/product_sales/product_sales_bloc.dart index 0aaf866..57f61dd 100644 --- a/lib/presentation/report/blocs/product_sales/product_sales_bloc.dart +++ b/lib/presentation/report/blocs/product_sales/product_sales_bloc.dart @@ -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 { - 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!))); }); } diff --git a/lib/presentation/report/blocs/product_sales/product_sales_bloc.freezed.dart b/lib/presentation/report/blocs/product_sales/product_sales_bloc.freezed.dart index c44d742..5ab5dc0 100644 --- a/lib/presentation/report/blocs/product_sales/product_sales_bloc.freezed.dart +++ b/lib/presentation/report/blocs/product_sales/product_sales_bloc.freezed.dart @@ -19,19 +19,20 @@ mixin _$ProductSalesEvent { @optionalTypeArgs TResult when({ 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? Function()? started, - TResult? Function(String startDate, String endDate)? getProductSales, + TResult? Function(DateTime startDate, DateTime endDate)? getProductSales, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ 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({ 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? 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 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({ 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? 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 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({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List 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? Function()? initial, TResult? Function()? loading, - TResult? Function(List productSales)? success, + TResult? Function(ProductAnalyticData product)? success, TResult? Function(String message)? error, }) => throw _privateConstructorUsedError; @@ -356,7 +359,7 @@ mixin _$ProductSalesState { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List 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({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List 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? Function()? initial, TResult? Function()? loading, - TResult? Function(List 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 Function()? initial, TResult Function()? loading, - TResult Function(List 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({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List 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? Function()? initial, TResult? Function()? loading, - TResult? Function(List 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 Function()? initial, TResult Function()? loading, - TResult Function(List 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}); + $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, + 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; + const _$SuccessImpl(this.product); - final List _productSales; @override - List 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({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List 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? Function()? initial, TResult? Function()? loading, - TResult? Function(List 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 Function()? initial, TResult Function()? loading, - TResult Function(List 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) = _$SuccessImpl; + const factory _Success(final ProductAnalyticData product) = _$SuccessImpl; - List 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({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List 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? Function()? initial, TResult? Function()? loading, - TResult? Function(List 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 Function()? initial, TResult Function()? loading, - TResult Function(List productSales)? success, + TResult Function(ProductAnalyticData product)? success, TResult Function(String message)? error, required TResult orElse(), }) { diff --git a/lib/presentation/report/blocs/product_sales/product_sales_event.dart b/lib/presentation/report/blocs/product_sales/product_sales_event.dart index 8ff24f2..811a8ca 100644 --- a/lib/presentation/report/blocs/product_sales/product_sales_event.dart +++ b/lib/presentation/report/blocs/product_sales/product_sales_event.dart @@ -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; } diff --git a/lib/presentation/report/blocs/product_sales/product_sales_state.dart b/lib/presentation/report/blocs/product_sales/product_sales_state.dart index ddf4a39..0c42f67 100644 --- a/lib/presentation/report/blocs/product_sales/product_sales_state.dart +++ b/lib/presentation/report/blocs/product_sales/product_sales_state.dart @@ -6,7 +6,7 @@ class ProductSalesState with _$ProductSalesState { const factory ProductSalesState.loading() = _Loading; - const factory ProductSalesState.success(List productSales) = + const factory ProductSalesState.success(ProductAnalyticData product) = _Success; const factory ProductSalesState.error(String message) = _Error; diff --git a/lib/presentation/report/pages/report_page.dart b/lib/presentation/report/pages/report_page.dart index 58c3065..1eaf38a 100644 --- a/lib/presentation/report/pages/report_page.dart +++ b/lib/presentation/report/pages/report_page.dart @@ -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 { 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().add( ProductSalesEvent.getProductSales( - DateFormatter.formatDateTime( - fromDate), - DateFormatter.formatDateTime(toDate)), + fromDate, + toDate, + ), ); }, isActive: selectedMenu == 2, @@ -262,12 +262,12 @@ class _ReportPageState extends State { error: (message) { return Text(message); }, - success: (productSales) { - return ProductSalesChartWidgets( + success: (products) { + return ProductAnalyticsWidget( title: title, searchDateFormatted: searchDateFormatted, - productSales: productSales, + productData: products, ); }, ); diff --git a/lib/presentation/report/widgets/product_analytic_widget.dart b/lib/presentation/report/widgets/product_analytic_widget.dart new file mode 100644 index 0000000..6b27b67 --- /dev/null +++ b/lib/presentation/report/widgets/product_analytic_widget.dart @@ -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 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 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 = 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)}'; + } + } +} diff --git a/lib/presentation/report/widgets/product_sales_chart_widget.dart b/lib/presentation/report/widgets/product_sales_chart_widget.dart deleted file mode 100644 index ca4c456..0000000 --- a/lib/presentation/report/widgets/product_sales_chart_widget.dart +++ /dev/null @@ -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; - const ProductSalesChartWidgets({ - super.key, - required this.title, - required this.searchDateFormatted, - required this.productSales, - }); - - @override - State createState() => - _ProductSalesChartWidgetsState(); -} - -class _ProductSalesChartWidgetsState extends State { - Map dataMap2 = {}; - - @override - void initState() { - loadData(); - super.initState(); - } - - loadData() { - for (var data in widget.productSales) { - dataMap2[data.productName ?? 'Unknown'] = - double.parse(data.totalQuantity!); - } - } - - final colorList = [ - 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--- - ), - ], - ), - ), - ), - ) - ], - ); - } -}