diff --git a/lib/data/datasources/analytic_remote_datasource.dart b/lib/data/datasources/analytic_remote_datasource.dart index 113dbbf..82b9815 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/sales_analytic_response_model.dart'; import 'package:intl/intl.dart'; class AnalyticRemoteDatasource { @@ -34,7 +35,41 @@ class AnalyticRemoteDatasource { if (response.statusCode == 200) { return right(PaymentMethodAnalyticResponseModel.fromMap(response.data)); } else { - return left(response.data.toString()); + 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'); + } + } + + Future> getSales({ + 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/sales', + 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(SalesAnalyticResponseModel.fromMap(response.data)); + } else { + return left('Terjadi Kesalahan, Coba lagi nanti.'); } } on DioException catch (e) { log('Dio error: ${e.message}'); diff --git a/lib/data/models/response/sales_analytic_response_model.dart b/lib/data/models/response/sales_analytic_response_model.dart new file mode 100644 index 0000000..9176304 --- /dev/null +++ b/lib/data/models/response/sales_analytic_response_model.dart @@ -0,0 +1,195 @@ +class SalesAnalyticResponseModel { + final bool success; + final SalesAnalyticData data; + final dynamic errors; + + SalesAnalyticResponseModel({ + required this.success, + required this.data, + this.errors, + }); + + factory SalesAnalyticResponseModel.fromJson(Map json) => + SalesAnalyticResponseModel.fromMap(json); + + Map toJson() => toMap(); + + factory SalesAnalyticResponseModel.fromMap(Map map) { + return SalesAnalyticResponseModel( + success: map['success'], + data: SalesAnalyticData.fromMap(map['data']), + errors: map['errors'], + ); + } + + Map toMap() { + return { + 'success': success, + 'data': data.toMap(), + 'errors': errors, + }; + } +} + +class SalesAnalyticData { + final String organizationId; + final String outletId; + final DateTime dateFrom; + final DateTime dateTo; + final String groupBy; + final SalesSummary summary; + final List data; + + SalesAnalyticData({ + required this.organizationId, + required this.outletId, + required this.dateFrom, + required this.dateTo, + required this.groupBy, + required this.summary, + required this.data, + }); + + factory SalesAnalyticData.fromJson(Map json) => + SalesAnalyticData.fromMap(json); + + Map toJson() => toMap(); + + factory SalesAnalyticData.fromMap(Map map) { + return SalesAnalyticData( + organizationId: map['organization_id'], + outletId: map['outlet_id'], + dateFrom: DateTime.parse(map['date_from']), + dateTo: DateTime.parse(map['date_to']), + groupBy: map['group_by'], + summary: SalesSummary.fromMap(map['summary']), + data: List.from( + map['data']?.map((x) => SalesAnalyticItem.fromMap(x)) ?? [], + ), + ); + } + + Map toMap() { + return { + 'organization_id': organizationId, + 'outlet_id': outletId, + 'date_from': dateFrom.toIso8601String(), + 'date_to': dateTo.toIso8601String(), + 'group_by': groupBy, + 'summary': summary.toMap(), + 'data': data.map((x) => x.toMap()).toList(), + }; + } +} + +class SalesSummary { + final int totalSales; + final int totalOrders; + final int totalItems; + final double averageOrderValue; + final int totalTax; + final int totalDiscount; + final int netSales; + + SalesSummary({ + required this.totalSales, + required this.totalOrders, + required this.totalItems, + required this.averageOrderValue, + required this.totalTax, + required this.totalDiscount, + required this.netSales, + }); + + factory SalesSummary.fromJson(Map json) => + SalesSummary.fromMap(json); + + Map toJson() => toMap(); + + factory SalesSummary.fromMap(Map map) { + return SalesSummary( + totalSales: map['total_sales'], + totalOrders: map['total_orders'], + totalItems: map['total_items'], + averageOrderValue: (map['average_order_value'] as num).toDouble(), + totalTax: map['total_tax'], + totalDiscount: map['total_discount'], + netSales: map['net_sales'], + ); + } + + Map toMap() { + return { + 'total_sales': totalSales, + 'total_orders': totalOrders, + 'total_items': totalItems, + 'average_order_value': averageOrderValue, + 'total_tax': totalTax, + 'total_discount': totalDiscount, + 'net_sales': netSales, + }; + } +} + +class SalesAnalyticItem { + final DateTime date; + final int sales; + final int orders; + final int items; + final int tax; + final int discount; + final int netSales; + + SalesAnalyticItem({ + required this.date, + required this.sales, + required this.orders, + required this.items, + required this.tax, + required this.discount, + required this.netSales, + }); + + factory SalesAnalyticItem.fromJson(Map json) => + SalesAnalyticItem.fromMap(json); + + Map toJson() => toMap(); + + factory SalesAnalyticItem.fromMap(Map map) { + return SalesAnalyticItem( + date: DateTime.parse(map['date']), + sales: map['sales'], + orders: map['orders'], + items: map['items'], + tax: map['tax'], + discount: map['discount'], + netSales: map['net_sales'], + ); + } + + Map toMap() { + return { + 'date': date.toIso8601String(), + 'sales': sales, + 'orders': orders, + 'items': items, + 'tax': tax, + 'discount': discount, + 'net_sales': netSales, + }; + } +} + +class SalesInsights { + final List originalData; + final List sortedDailyData; + final SalesAnalyticItem? highestRevenueDay; + final SalesSummary summary; + + SalesInsights({ + required this.originalData, + required this.sortedDailyData, + required this.highestRevenueDay, + required this.summary, + }); +} diff --git a/lib/main.dart b/lib/main.dart index ad3bf3c..1db6de7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -196,7 +196,7 @@ class _MyAppState extends State { create: (context) => ProductSalesBloc(OrderItemRemoteDatasource()), ), BlocProvider( - create: (context) => ItemSalesReportBloc(OrderItemRemoteDatasource()), + create: (context) => ItemSalesReportBloc(AnalyticRemoteDatasource()), ), BlocProvider( create: (context) => diff --git a/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.dart b/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.dart index c5b3eba..2365ecc 100644 --- a/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.dart +++ b/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.dart @@ -1,6 +1,6 @@ import 'package:bloc/bloc.dart'; -import 'package:enaklo_pos/data/models/response/item_sales_response_model.dart'; -import 'package:enaklo_pos/data/datasources/order_item_remote_datasource.dart'; +import 'package:enaklo_pos/data/datasources/analytic_remote_datasource.dart'; +import 'package:enaklo_pos/data/models/response/sales_analytic_response_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'item_sales_report_event.dart'; @@ -9,12 +9,14 @@ part 'item_sales_report_bloc.freezed.dart'; class ItemSalesReportBloc extends Bloc { - final OrderItemRemoteDatasource datasource; + final AnalyticRemoteDatasource datasource; ItemSalesReportBloc(this.datasource) : super(const _Initial()) { on<_GetItemSales>((event, emit) async { emit(const _Loading()); - final result = await datasource.getItemSalesByRangeDate( - event.startDate, event.endDate); + final result = await datasource.getSales( + dateFrom: event.startDate, + dateTo: event.endDate, + ); result.fold((l) => emit(_Error(l)), (r) => emit(_Loaded(r.data!))); }); } diff --git a/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.freezed.dart b/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.freezed.dart index c7012a7..ce47bf4 100644 --- a/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.freezed.dart +++ b/lib/presentation/report/blocs/item_sales_report/item_sales_report_bloc.freezed.dart @@ -19,19 +19,20 @@ mixin _$ItemSalesReportEvent { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getItemSales, + required TResult Function(DateTime startDate, DateTime endDate) + getItemSales, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getItemSales, + TResult? Function(DateTime startDate, DateTime endDate)? getItemSales, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getItemSales, + TResult Function(DateTime startDate, DateTime endDate)? getItemSales, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -120,7 +121,8 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getItemSales, + required TResult Function(DateTime startDate, DateTime endDate) + getItemSales, }) { return started(); } @@ -129,7 +131,7 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getItemSales, + TResult? Function(DateTime startDate, DateTime endDate)? getItemSales, }) { return started?.call(); } @@ -138,7 +140,7 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getItemSales, + TResult Function(DateTime startDate, DateTime endDate)? getItemSales, required TResult orElse(), }) { if (started != null) { @@ -189,7 +191,7 @@ abstract class _$$GetItemSalesImplCopyWith<$Res> { _$GetItemSalesImpl value, $Res Function(_$GetItemSalesImpl) then) = __$$GetItemSalesImplCopyWithImpl<$Res>; @useResult - $Res call({String startDate, String endDate}); + $Res call({DateTime startDate, DateTime endDate}); } /// @nodoc @@ -212,11 +214,11 @@ class __$$GetItemSalesImplCopyWithImpl<$Res> startDate: null == startDate ? _value.startDate : startDate // ignore: cast_nullable_to_non_nullable - as String, + as DateTime, endDate: null == endDate ? _value.endDate : endDate // ignore: cast_nullable_to_non_nullable - as String, + as DateTime, )); } } @@ -227,9 +229,9 @@ class _$GetItemSalesImpl implements _GetItemSales { const _$GetItemSalesImpl({required this.startDate, required this.endDate}); @override - final String startDate; + final DateTime startDate; @override - final String endDate; + final DateTime endDate; @override String toString() { @@ -261,7 +263,8 @@ class _$GetItemSalesImpl implements _GetItemSales { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getItemSales, + required TResult Function(DateTime startDate, DateTime endDate) + getItemSales, }) { return getItemSales(startDate, endDate); } @@ -270,7 +273,7 @@ class _$GetItemSalesImpl implements _GetItemSales { @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getItemSales, + TResult? Function(DateTime startDate, DateTime endDate)? getItemSales, }) { return getItemSales?.call(startDate, endDate); } @@ -279,7 +282,7 @@ class _$GetItemSalesImpl implements _GetItemSales { @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getItemSales, + TResult Function(DateTime startDate, DateTime endDate)? getItemSales, required TResult orElse(), }) { if (getItemSales != null) { @@ -322,11 +325,11 @@ class _$GetItemSalesImpl implements _GetItemSales { abstract class _GetItemSales implements ItemSalesReportEvent { const factory _GetItemSales( - {required final String startDate, - required final String endDate}) = _$GetItemSalesImpl; + {required final DateTime startDate, + required final DateTime endDate}) = _$GetItemSalesImpl; - String get startDate; - String get endDate; + DateTime get startDate; + DateTime get endDate; /// Create a copy of ItemSalesReportEvent /// with the given fields replaced by the non-null parameter values. @@ -341,7 +344,7 @@ mixin _$ItemSalesReportState { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List itemSales) loaded, + required TResult Function(SalesAnalyticData itemSales) loaded, required TResult Function(String message) error, }) => throw _privateConstructorUsedError; @@ -349,7 +352,7 @@ mixin _$ItemSalesReportState { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List itemSales)? loaded, + TResult? Function(SalesAnalyticData itemSales)? loaded, TResult? Function(String message)? error, }) => throw _privateConstructorUsedError; @@ -357,7 +360,7 @@ mixin _$ItemSalesReportState { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List itemSales)? loaded, + TResult Function(SalesAnalyticData itemSales)? loaded, TResult Function(String message)? error, required TResult orElse(), }) => @@ -454,7 +457,7 @@ class _$InitialImpl implements _Initial { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List itemSales) loaded, + required TResult Function(SalesAnalyticData itemSales) loaded, required TResult Function(String message) error, }) { return initial(); @@ -465,7 +468,7 @@ class _$InitialImpl implements _Initial { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List itemSales)? loaded, + TResult? Function(SalesAnalyticData itemSales)? loaded, TResult? Function(String message)? error, }) { return initial?.call(); @@ -476,7 +479,7 @@ class _$InitialImpl implements _Initial { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List itemSales)? loaded, + TResult Function(SalesAnalyticData itemSales)? loaded, TResult Function(String message)? error, required TResult orElse(), }) { @@ -571,7 +574,7 @@ class _$LoadingImpl implements _Loading { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List itemSales) loaded, + required TResult Function(SalesAnalyticData itemSales) loaded, required TResult Function(String message) error, }) { return loading(); @@ -582,7 +585,7 @@ class _$LoadingImpl implements _Loading { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List itemSales)? loaded, + TResult? Function(SalesAnalyticData itemSales)? loaded, TResult? Function(String message)? error, }) { return loading?.call(); @@ -593,7 +596,7 @@ class _$LoadingImpl implements _Loading { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List itemSales)? loaded, + TResult Function(SalesAnalyticData itemSales)? loaded, TResult Function(String message)? error, required TResult orElse(), }) { @@ -651,7 +654,7 @@ abstract class _$$LoadedImplCopyWith<$Res> { _$LoadedImpl value, $Res Function(_$LoadedImpl) then) = __$$LoadedImplCopyWithImpl<$Res>; @useResult - $Res call({List itemSales}); + $Res call({SalesAnalyticData itemSales}); } /// @nodoc @@ -671,9 +674,9 @@ class __$$LoadedImplCopyWithImpl<$Res> }) { return _then(_$LoadedImpl( null == itemSales - ? _value._itemSales + ? _value.itemSales : itemSales // ignore: cast_nullable_to_non_nullable - as List, + as SalesAnalyticData, )); } } @@ -681,15 +684,10 @@ class __$$LoadedImplCopyWithImpl<$Res> /// @nodoc class _$LoadedImpl implements _Loaded { - const _$LoadedImpl(final List itemSales) : _itemSales = itemSales; + const _$LoadedImpl(this.itemSales); - final List _itemSales; @override - List get itemSales { - if (_itemSales is EqualUnmodifiableListView) return _itemSales; - // ignore: implicit_dynamic_type - return EqualUnmodifiableListView(_itemSales); - } + final SalesAnalyticData itemSales; @override String toString() { @@ -701,13 +699,12 @@ class _$LoadedImpl implements _Loaded { return identical(this, other) || (other.runtimeType == runtimeType && other is _$LoadedImpl && - const DeepCollectionEquality() - .equals(other._itemSales, _itemSales)); + (identical(other.itemSales, itemSales) || + other.itemSales == itemSales)); } @override - int get hashCode => - Object.hash(runtimeType, const DeepCollectionEquality().hash(_itemSales)); + int get hashCode => Object.hash(runtimeType, itemSales); /// Create a copy of ItemSalesReportState /// with the given fields replaced by the non-null parameter values. @@ -722,7 +719,7 @@ class _$LoadedImpl implements _Loaded { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List itemSales) loaded, + required TResult Function(SalesAnalyticData itemSales) loaded, required TResult Function(String message) error, }) { return loaded(itemSales); @@ -733,7 +730,7 @@ class _$LoadedImpl implements _Loaded { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List itemSales)? loaded, + TResult? Function(SalesAnalyticData itemSales)? loaded, TResult? Function(String message)? error, }) { return loaded?.call(itemSales); @@ -744,7 +741,7 @@ class _$LoadedImpl implements _Loaded { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List itemSales)? loaded, + TResult Function(SalesAnalyticData itemSales)? loaded, TResult Function(String message)? error, required TResult orElse(), }) { @@ -793,9 +790,9 @@ class _$LoadedImpl implements _Loaded { } abstract class _Loaded implements ItemSalesReportState { - const factory _Loaded(final List itemSales) = _$LoadedImpl; + const factory _Loaded(final SalesAnalyticData itemSales) = _$LoadedImpl; - List get itemSales; + SalesAnalyticData get itemSales; /// Create a copy of ItemSalesReportState /// with the given fields replaced by the non-null parameter values. @@ -874,7 +871,7 @@ class _$ErrorImpl implements _Error { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(List itemSales) loaded, + required TResult Function(SalesAnalyticData itemSales) loaded, required TResult Function(String message) error, }) { return error(message); @@ -885,7 +882,7 @@ class _$ErrorImpl implements _Error { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(List itemSales)? loaded, + TResult? Function(SalesAnalyticData itemSales)? loaded, TResult? Function(String message)? error, }) { return error?.call(message); @@ -896,7 +893,7 @@ class _$ErrorImpl implements _Error { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(List itemSales)? loaded, + TResult Function(SalesAnalyticData itemSales)? loaded, TResult Function(String message)? error, required TResult orElse(), }) { diff --git a/lib/presentation/report/blocs/item_sales_report/item_sales_report_event.dart b/lib/presentation/report/blocs/item_sales_report/item_sales_report_event.dart index 1935cf0..86ff9c1 100644 --- a/lib/presentation/report/blocs/item_sales_report/item_sales_report_event.dart +++ b/lib/presentation/report/blocs/item_sales_report/item_sales_report_event.dart @@ -4,7 +4,7 @@ part of 'item_sales_report_bloc.dart'; class ItemSalesReportEvent with _$ItemSalesReportEvent { const factory ItemSalesReportEvent.started() = _Started; const factory ItemSalesReportEvent.getItemSales({ - required String startDate, - required String endDate, + required DateTime startDate, + required DateTime endDate, }) = _GetItemSales; } diff --git a/lib/presentation/report/blocs/item_sales_report/item_sales_report_state.dart b/lib/presentation/report/blocs/item_sales_report/item_sales_report_state.dart index c0b3de6..733fc58 100644 --- a/lib/presentation/report/blocs/item_sales_report/item_sales_report_state.dart +++ b/lib/presentation/report/blocs/item_sales_report/item_sales_report_state.dart @@ -4,7 +4,7 @@ part of 'item_sales_report_bloc.dart'; class ItemSalesReportState with _$ItemSalesReportState { const factory ItemSalesReportState.initial() = _Initial; const factory ItemSalesReportState.loading() = _Loading; - const factory ItemSalesReportState.loaded(List itemSales) = + const factory ItemSalesReportState.loaded(SalesAnalyticData itemSales) = _Loaded; const factory ItemSalesReportState.error(String message) = _Error; } diff --git a/lib/presentation/report/pages/report_page.dart b/lib/presentation/report/pages/report_page.dart index a1dd47f..c13de1f 100644 --- a/lib/presentation/report/pages/report_page.dart +++ b/lib/presentation/report/pages/report_page.dart @@ -1,5 +1,7 @@ import 'dart:developer'; +import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/presentation/sales/pages/sales_page.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:enaklo_pos/core/components/custom_date_picker.dart'; import 'package:enaklo_pos/core/constants/colors.dart'; @@ -29,7 +31,7 @@ class ReportPage extends StatefulWidget { } class _ReportPageState extends State { - int selectedMenu = 0; + int selectedMenu = 1; String title = 'Transaction Report'; DateTime fromDate = DateTime.now().subtract(const Duration(days: 30)); DateTime toDate = DateTime.now(); @@ -37,10 +39,9 @@ class _ReportPageState extends State { @override void initState() { super.initState(); - context.read().add( - TransactionReportEvent.getReport( - startDate: DateFormatter.formatDateTime(fromDate), - endDate: DateFormatter.formatDateTime(toDate)), + context.read().add( + ItemSalesReportEvent.getItemSales( + startDate: fromDate, endDate: toDate), ); } @@ -117,18 +118,7 @@ class _ReportPageState extends State { 'Menampilkan riwayat lengkap semua transaksi yang telah dilakukan.', icon: Icons.receipt_long_outlined, onPressed: () { - selectedMenu = 0; - title = 'Laporan Transaksi'; - setState(() {}); - //enddate is 1 month before the current date - context.read().add( - TransactionReportEvent.getReport( - startDate: - DateFormatter.formatDateTime( - fromDate), - endDate: DateFormatter.formatDateTime( - toDate)), - ); + context.push(SalesPage(status: 'completed')); }, isActive: selectedMenu == 0, ), @@ -143,11 +133,7 @@ class _ReportPageState extends State { setState(() {}); context.read().add( ItemSalesReportEvent.getItemSales( - startDate: - DateFormatter.formatDateTime( - fromDate), - endDate: DateFormatter.formatDateTime( - toDate)), + startDate: fromDate, endDate: toDate), ); }, isActive: selectedMenu == 1, @@ -254,7 +240,7 @@ class _ReportPageState extends State { }, loaded: (itemSales) { return ItemSalesReportWidget( - itemSales: itemSales, + sales: itemSales, title: title, searchDateFormatted: searchDateFormatted, diff --git a/lib/presentation/report/widgets/item_sales_report_widget.dart b/lib/presentation/report/widgets/item_sales_report_widget.dart index 312f803..b3e239d 100644 --- a/lib/presentation/report/widgets/item_sales_report_widget.dart +++ b/lib/presentation/report/widgets/item_sales_report_widget.dart @@ -1,24 +1,16 @@ -import 'dart:developer'; - -import 'package:enaklo_pos/core/components/spaces.dart'; -import 'package:enaklo_pos/core/constants/colors.dart'; import 'package:enaklo_pos/core/extensions/int_ext.dart'; -import 'package:enaklo_pos/core/utils/helper_pdf_service.dart'; -import 'package:enaklo_pos/presentation/report/widgets/report_page_title.dart'; +import 'package:enaklo_pos/data/models/response/sales_analytic_response_model.dart'; import 'package:flutter/material.dart'; -import 'package:enaklo_pos/core/utils/item_sales_invoice.dart'; -import 'package:enaklo_pos/core/utils/permession_handler.dart'; -import 'package:enaklo_pos/data/models/response/item_sales_response_model.dart'; -import 'package:horizontal_data_table/horizontal_data_table.dart'; +import 'package:intl/intl.dart'; class ItemSalesReportWidget extends StatelessWidget { final String title; final String searchDateFormatted; - final List itemSales; + final SalesAnalyticData sales; final List? headerWidgets; const ItemSalesReportWidget({ super.key, - required this.itemSales, + required this.sales, required this.title, required this.searchDateFormatted, required this.headerWidgets, @@ -26,123 +18,522 @@ class ItemSalesReportWidget extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - ReportPageTitle( - title: title, - searchDateFormatted: searchDateFormatted, - onExport: () async { - try { - final status = await PermessionHelper().checkPermission(); - if (status) { - final pdfFile = await ItemSalesInvoice.generate( - itemSales, searchDateFormatted); - log("pdfFile: $pdfFile"); - await HelperPdfService.openFile(pdfFile); - } else { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Storage permission is required to save PDF'), - backgroundColor: Colors.red, - ), - ); - } - } catch (e) { - log("Error generating PDF: $e"); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Failed to generate PDF: $e'), - backgroundColor: Colors.red, - ), - ); - } - }, + // Proses data untuk mendapatkan insights + final insights = _processSalesData(sales); + + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + border: Border( + left: BorderSide( + color: const Color(0xFFE5E7EB), + width: 1, + ), ), - const SpaceHeight(16.0), - Expanded( - child: Padding( - padding: const EdgeInsets.all(12), - child: ClipRRect( - borderRadius: BorderRadius.circular(12), - child: HorizontalDataTable( - leftHandSideColumnWidth: 80, - rightHandSideColumnWidth: 670, - isFixedHeader: true, - headerWidgets: headerWidgets, + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + _buildHeader(), - // isFixedFooter: true, - // footerWidgets: _getTitleWidget(), - leftSideItemBuilder: (context, index) { - return Container( - width: 80, - height: 52, - alignment: Alignment.centerLeft, - child: Center(child: Text(itemSales[index].id.toString())), - ); - }, - rightSideItemBuilder: (context, index) { - return Row( - children: [ - Container( - width: 100, - height: 52, - alignment: Alignment.centerLeft, - child: Center( - child: Text(itemSales[index].orderId.toString())), - ), - Container( - width: 200, - height: 52, - alignment: Alignment.centerLeft, - child: - Center(child: Text(itemSales[index].productName!)), - ), - Container( - width: 60, - height: 52, - alignment: Alignment.centerLeft, - child: Center( - child: Text(itemSales[index].quantity.toString())), - ), - Container( - width: 150, - height: 52, - padding: const EdgeInsets.fromLTRB(5, 0, 0, 0), - alignment: Alignment.centerLeft, - child: Center( - child: Text( - itemSales[index].price!.currencyFormatRp, - )), - ), - Container( - width: 160, - height: 52, - padding: const EdgeInsets.fromLTRB(5, 0, 0, 0), - alignment: Alignment.centerLeft, - child: Center( - child: Text( - (itemSales[index].price! * itemSales[index].quantity!) - .currencyFormatRp, - )), - ), - ], - ); - }, - itemCount: itemSales.length, - rowSeparatorWidget: const Divider( - color: Colors.black38, - height: 1.0, - thickness: 0.0, - ), - leftHandSideColBackgroundColor: AppColors.white, - rightHandSideColBackgroundColor: AppColors.white, + const SizedBox(height: 24), - itemExtent: 55, + // Metrics menggunakan data dari summary + _buildMetrics(), + + const SizedBox(height: 24), + + // Daily Performance Section + Text( + 'Daily Performance', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: const Color(0xFF111827), ), ), + + const SizedBox(height: 16), + + // Daily Performance List dengan data dinamis + ListView.builder( + itemCount: insights.sortedDailyData.length, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + final dayData = insights.sortedDailyData[index]; + return _buildDailyPerformanceItem( + date: dayData.formattedDate, + sales: dayData.formattedSales, + orders: dayData.orders, + items: dayData.items, + isHighest: dayData == insights.highestRevenueDay, + ); + }, + ), + + const SizedBox(height: 16), + + // Summary Footer dengan data dinamis + _buildSummaryFooter(insights.highestRevenueDay), + ], + ), + ), + ); + } + + // Method untuk memproses data dan mendapatkan insights + SalesInsights _processSalesData(SalesAnalyticData data) { + // Sort data by sales (descending) untuk ranking + List sortedData = List.from(data.data); + sortedData.sort((a, b) => b.sales.compareTo(a.sales)); + + // Find highest revenue day + SalesAnalyticItem? highestRevenueDay; + if (sortedData.isNotEmpty) { + highestRevenueDay = sortedData.first; + } + + return SalesInsights( + originalData: data.data, + sortedDailyData: sortedData, + highestRevenueDay: highestRevenueDay, + summary: data.summary, + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: const Color(0xFFE5E7EB), + width: 1, ), ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sales Analytics', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: const Color(0xFF111827), + ), + ), + const SizedBox(height: 4), + Text( + searchDateFormatted, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFF10B981), + borderRadius: BorderRadius.circular(24), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.trending_up, + size: 16, + color: Colors.white, + ), + const SizedBox(width: 6), + Text( + 'Growing', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildMetrics() { + final summary = sales.summary; + + return Column( + children: [ + Row( + children: [ + Expanded( + child: _buildMetricCard( + title: 'Total Sales', + value: summary.totalSales.currencyFormatRpV2, + subtitle: 'Net Sales', + color: const Color(0xFF3B82F6), + backgroundColor: const Color(0xFFEFF6FF), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _buildMetricCard( + title: 'Total Orders', + value: '${summary.totalOrders}', + subtitle: '${summary.totalItems} Items', + color: const Color(0xFF8B5CF6), + backgroundColor: const Color(0xFFF3E8FF), + ), + ), + ], + ), + const SizedBox(height: 16), + _buildFullWidthMetricCard( + title: 'Average Order Value', + value: summary.averageOrderValue.round().currencyFormatRpV2, + subtitle: 'Per transaction', + color: const Color(0xFFEF4444), + backgroundColor: const Color(0xFFFEF2F2), + ), ], ); } + + Widget _buildSummaryFooter(SalesAnalyticItem? highestDay) { + if (highestDay == null) { + return Container(); + } + + final dateFormat = DateFormat('dd MMM'); + final formattedDate = dateFormat.format(highestDay.date); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: const Color(0xFFE5E7EB), + width: 1, + ), + ), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: const Color(0xFF10B981), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Peak performance on $formattedDate with ${_formatCurrency(highestDay.sales)} revenue (${highestDay.orders} orders)', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: const Color(0xFF374151), + ), + ), + ), + ], + ), + ); + } + + // Helper method untuk format currency + String _formatCurrency(int amount) { + if (amount >= 1000000) { + return 'Rp ${(amount / 1000000).toStringAsFixed(1)}JT'; + } else if (amount >= 1000) { + return 'Rp ${(amount / 1000).toStringAsFixed(0)}RB'; + } else { + return 'Rp ${NumberFormat('#,###').format(amount)}'; + } + } + + Widget _buildMetricCard({ + required String title, + required String value, + required String subtitle, + required Color color, + required Color backgroundColor, + }) { + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.2), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF111827), + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ); + } + + Widget _buildFullWidthMetricCard({ + required String title, + required String value, + required String subtitle, + required Color color, + required Color backgroundColor, + }) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: color.withOpacity(0.2), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: const Color(0xFF111827), + ), + ), + ], + ), + ); + } + + Widget _buildDailyPerformanceItem({ + required String date, + required String sales, + required int orders, + required int items, + required bool isHighest, + }) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: isHighest ? const Color(0xFF10B981) : const Color(0xFFE5E7EB), + width: isHighest ? 2 : 1, + ), + ), + child: Row( + children: [ + Container( + width: 60, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + date, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: const Color(0xFF111827), + ), + ), + if (isHighest) + Container( + margin: const EdgeInsets.only(top: 4), + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFF10B981), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'TOP', + style: TextStyle( + fontSize: 8, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sales, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF111827), + ), + ), + Text( + 'Revenue', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + Expanded( + flex: 1, + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '$orders orders', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: const Color(0xFF374151), + ), + ), + Text( + '$items items', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w400, + color: const Color(0xFF6B7280), + ), + ), + ], + ), + ), + ], + ), + ); + } } + +extension SalesAnalyticItemExtension on SalesAnalyticItem { + String get formattedDate { + final dateFormat = DateFormat('dd MMM'); + return dateFormat.format(date); + } + + String get formattedSales { + if (sales >= 1000000) { + return 'Rp ${(sales / 1000000).toStringAsFixed(1)}JT'; + } else if (sales >= 1000) { + return 'Rp ${(sales / 1000).toStringAsFixed(0)}RB'; + } else { + return 'Rp ${NumberFormat('#,###').format(sales)}'; + } + } + + double get averageOrderValue { + return orders > 0 ? sales / orders : 0.0; + } +} + + // ReportPageTitle( + // title: title, + // searchDateFormatted: searchDateFormatted, + // onExport: () async { + // try { + // final status = await PermessionHelper().checkPermission(); + // if (status) { + // final pdfFile = await ItemSalesInvoice.generate( + // itemSales, searchDateFormatted); + // log("pdfFile: $pdfFile"); + // await HelperPdfService.openFile(pdfFile); + // } else { + // ScaffoldMessenger.of(context).showSnackBar( + // const SnackBar( + // content: + // Text('Storage permission is required to save PDF'), + // backgroundColor: Colors.red, + // ), + // ); + // } + // } catch (e) { + // log("Error generating PDF: $e"); + // ScaffoldMessenger.of(context).showSnackBar( + // SnackBar( + // content: Text('Failed to generate PDF: $e'), + // backgroundColor: Colors.red, + // ), + // ); + // } + // }, + // ), \ No newline at end of file