From bdb2c0ba521ed123f322058e73e891851a89142a Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 6 Aug 2025 13:05:58 +0700 Subject: [PATCH] feat: Dashboard Analytic --- .../analytic_remote_datasource.dart | 35 + .../dashboard_analytic_response_model.dart | 242 +++++++ lib/main.dart | 3 +- .../report/blocs/summary/summary_bloc.dart | 14 +- .../blocs/summary/summary_bloc.freezed.dart | 96 +-- .../report/blocs/summary/summary_event.dart | 2 +- .../report/blocs/summary/summary_state.dart | 2 +- .../report/pages/report_page.dart | 13 +- .../widgets/dashboard_analytic_widget.dart | 639 ++++++++++++++++++ pubspec.lock | 18 +- pubspec.yaml | 1 + 11 files changed, 998 insertions(+), 67 deletions(-) create mode 100644 lib/data/models/response/dashboard_analytic_response_model.dart create mode 100644 lib/presentation/report/widgets/dashboard_analytic_widget.dart diff --git a/lib/data/datasources/analytic_remote_datasource.dart b/lib/data/datasources/analytic_remote_datasource.dart index 1857f2f..f875f5b 100644 --- a/lib/data/datasources/analytic_remote_datasource.dart +++ b/lib/data/datasources/analytic_remote_datasource.dart @@ -5,6 +5,7 @@ import 'package:dio/dio.dart'; 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/dashboard_analytic_response_model.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'; @@ -114,4 +115,38 @@ class AnalyticRemoteDatasource { return left('Unexpected error occurred'); } } + + Future> getDashboard({ + 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/dashboard', + 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(DashboardAnalyticResponseModel.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/dashboard_analytic_response_model.dart b/lib/data/models/response/dashboard_analytic_response_model.dart new file mode 100644 index 0000000..b0b5ac5 --- /dev/null +++ b/lib/data/models/response/dashboard_analytic_response_model.dart @@ -0,0 +1,242 @@ +class DashboardAnalyticResponseModel { + final bool success; + final DashboardAnalyticData data; + final dynamic errors; + + DashboardAnalyticResponseModel({ + required this.success, + required this.data, + this.errors, + }); + + /// Khusus untuk JSON string + factory DashboardAnalyticResponseModel.fromJson(Map json) => + DashboardAnalyticResponseModel.fromMap(json); + + /// Untuk menerima Map biasa (bukan dari JSON string) + factory DashboardAnalyticResponseModel.fromMap(Map map) { + return DashboardAnalyticResponseModel( + success: map['success'] ?? false, + data: DashboardAnalyticData.fromMap(map['data'] ?? {}), + errors: map['errors'], + ); + } + + Map toJson() => toMap(); + + Map toMap() => { + 'success': success, + 'data': data.toMap(), + 'errors': errors, + }; +} + +class DashboardAnalyticData { + final String organizationId; + final String outletId; + final String dateFrom; + final String dateTo; + final DashboardOverview overview; + final List topProducts; + final List paymentMethods; + final List recentSales; + + DashboardAnalyticData({ + required this.organizationId, + required this.outletId, + required this.dateFrom, + required this.dateTo, + required this.overview, + required this.topProducts, + required this.paymentMethods, + required this.recentSales, + }); + + factory DashboardAnalyticData.fromMap(Map map) => + DashboardAnalyticData( + organizationId: map['organization_id'], + outletId: map['outlet_id'], + dateFrom: map['date_from'], + dateTo: map['date_to'], + overview: DashboardOverview.fromMap(map['overview']), + topProducts: List.from( + map['top_products']?.map((x) => TopProduct.fromMap(x))), + paymentMethods: List.from(map['payment_methods'] + ?.map((x) => PaymentMethodAnalytic.fromMap(x))), + recentSales: List.from( + map['recent_sales']?.map((x) => RecentSale.fromMap(x))), + ); + + Map toMap() => { + 'organization_id': organizationId, + 'outlet_id': outletId, + 'date_from': dateFrom, + 'date_to': dateTo, + 'overview': overview.toMap(), + 'top_products': topProducts.map((x) => x.toMap()).toList(), + 'payment_methods': paymentMethods.map((x) => x.toMap()).toList(), + 'recent_sales': recentSales.map((x) => x.toMap()).toList(), + }; +} + +class DashboardOverview { + final int totalSales; + final int totalOrders; + final double averageOrderValue; + final int totalCustomers; + final int voidedOrders; + final int refundedOrders; + + DashboardOverview({ + required this.totalSales, + required this.totalOrders, + required this.averageOrderValue, + required this.totalCustomers, + required this.voidedOrders, + required this.refundedOrders, + }); + + factory DashboardOverview.fromMap(Map map) => + DashboardOverview( + totalSales: map['total_sales'], + totalOrders: map['total_orders'], + averageOrderValue: map['average_order_value']?.toDouble() ?? 0.0, + totalCustomers: map['total_customers'], + voidedOrders: map['voided_orders'], + refundedOrders: map['refunded_orders'], + ); + + Map toMap() => { + 'total_sales': totalSales, + 'total_orders': totalOrders, + 'average_order_value': averageOrderValue, + 'total_customers': totalCustomers, + 'voided_orders': voidedOrders, + 'refunded_orders': refundedOrders, + }; +} + +class TopProduct { + final String productId; + final String productName; + final String categoryId; + final String categoryName; + final int quantitySold; + final int revenue; + final double averagePrice; + final int orderCount; + + TopProduct({ + 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 TopProduct.fromMap(Map map) => TopProduct( + 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']?.toDouble() ?? 0.0, + 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 PaymentMethodAnalytic { + final String paymentMethodId; + final String paymentMethodName; + final String paymentMethodType; + final int totalAmount; + final int orderCount; + final int paymentCount; + final double percentage; + + PaymentMethodAnalytic({ + required this.paymentMethodId, + required this.paymentMethodName, + required this.paymentMethodType, + required this.totalAmount, + required this.orderCount, + required this.paymentCount, + required this.percentage, + }); + + factory PaymentMethodAnalytic.fromMap(Map map) => + PaymentMethodAnalytic( + paymentMethodId: map['payment_method_id'], + paymentMethodName: map['payment_method_name'], + paymentMethodType: map['payment_method_type'], + totalAmount: map['total_amount'], + orderCount: map['order_count'], + paymentCount: map['payment_count'], + percentage: map['percentage']?.toDouble() ?? 0.0, + ); + + Map toMap() => { + 'payment_method_id': paymentMethodId, + 'payment_method_name': paymentMethodName, + 'payment_method_type': paymentMethodType, + 'total_amount': totalAmount, + 'order_count': orderCount, + 'payment_count': paymentCount, + 'percentage': percentage, + }; +} + +class RecentSale { + final String date; + final int sales; + final int orders; + final int items; + final int tax; + final int discount; + final int netSales; + + RecentSale({ + required this.date, + required this.sales, + required this.orders, + required this.items, + required this.tax, + required this.discount, + required this.netSales, + }); + + factory RecentSale.fromMap(Map map) => RecentSale( + date: map['date'], + sales: map['sales'], + orders: map['orders'], + items: map['items'], + tax: map['tax'], + discount: map['discount'], + netSales: map['net_sales'], + ); + + Map toMap() => { + 'date': date, + 'sales': sales, + 'orders': orders, + 'items': items, + 'tax': tax, + 'discount': discount, + 'net_sales': netSales, + }; +} diff --git a/lib/main.dart b/lib/main.dart index fde4e83..2573015 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -27,7 +27,6 @@ import 'package:enaklo_pos/data/datasources/midtrans_remote_datasource.dart'; import 'package:enaklo_pos/data/datasources/order_remote_datasource.dart'; import 'package:enaklo_pos/data/datasources/product_local_datasource.dart'; import 'package:enaklo_pos/data/datasources/product_remote_datasource.dart'; -import 'package:enaklo_pos/data/datasources/order_item_remote_datasource.dart'; import 'package:enaklo_pos/data/datasources/payment_methods_remote_datasource.dart'; import 'package:enaklo_pos/data/datasources/settings_local_datasource.dart'; import 'package:enaklo_pos/presentation/auth/bloc/logout/logout_bloc.dart'; @@ -190,7 +189,7 @@ class _MyAppState extends State { create: (context) => GetCategoriesBloc(CategoryRemoteDatasource()), ), BlocProvider( - create: (context) => SummaryBloc(OrderRemoteDatasource()), + create: (context) => SummaryBloc(AnalyticRemoteDatasource()), ), BlocProvider( create: (context) => ProductSalesBloc(AnalyticRemoteDatasource()), diff --git a/lib/presentation/report/blocs/summary/summary_bloc.dart b/lib/presentation/report/blocs/summary/summary_bloc.dart index f48e5c9..fc3fee8 100644 --- a/lib/presentation/report/blocs/summary/summary_bloc.dart +++ b/lib/presentation/report/blocs/summary/summary_bloc.dart @@ -1,6 +1,6 @@ +import 'package:enaklo_pos/data/datasources/analytic_remote_datasource.dart'; +import 'package:enaklo_pos/data/models/response/dashboard_analytic_response_model.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:enaklo_pos/data/datasources/order_remote_datasource.dart'; -import 'package:enaklo_pos/data/models/response/summary_response_model.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; part 'summary_event.dart'; @@ -8,15 +8,17 @@ part 'summary_state.dart'; part 'summary_bloc.freezed.dart'; class SummaryBloc extends Bloc { - final OrderRemoteDatasource datasource; + final AnalyticRemoteDatasource datasource; SummaryBloc( this.datasource, ) : super(const _Initial()) { on<_GetSummary>((event, emit) async { emit(const _Loading()); - final result = await datasource.getSummaryByRangeDate( - event.startDate, event.endDate); - result.fold((l) => emit(_Error(l)), (r) => emit(_Success(r.data!))); + final result = await datasource.getDashboard( + dateFrom: event.startDate, + dateTo: event.endDate, + ); + result.fold((l) => emit(_Error(l)), (r) => emit(_Success(r.data))); }); } } diff --git a/lib/presentation/report/blocs/summary/summary_bloc.freezed.dart b/lib/presentation/report/blocs/summary/summary_bloc.freezed.dart index 7bc2fe5..d6fefa0 100644 --- a/lib/presentation/report/blocs/summary/summary_bloc.freezed.dart +++ b/lib/presentation/report/blocs/summary/summary_bloc.freezed.dart @@ -19,19 +19,19 @@ mixin _$SummaryEvent { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getSummary, + required TResult Function(DateTime startDate, DateTime endDate) getSummary, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getSummary, + TResult? Function(DateTime startDate, DateTime endDate)? getSummary, }) => throw _privateConstructorUsedError; @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getSummary, + TResult Function(DateTime startDate, DateTime endDate)? getSummary, required TResult orElse(), }) => throw _privateConstructorUsedError; @@ -119,7 +119,7 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getSummary, + required TResult Function(DateTime startDate, DateTime endDate) getSummary, }) { return started(); } @@ -128,7 +128,7 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getSummary, + TResult? Function(DateTime startDate, DateTime endDate)? getSummary, }) { return started?.call(); } @@ -137,7 +137,7 @@ class _$StartedImpl implements _Started { @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getSummary, + TResult Function(DateTime startDate, DateTime endDate)? getSummary, required TResult orElse(), }) { if (started != null) { @@ -188,7 +188,7 @@ abstract class _$$GetSummaryImplCopyWith<$Res> { _$GetSummaryImpl value, $Res Function(_$GetSummaryImpl) then) = __$$GetSummaryImplCopyWithImpl<$Res>; @useResult - $Res call({String startDate, String endDate}); + $Res call({DateTime startDate, DateTime endDate}); } /// @nodoc @@ -211,11 +211,11 @@ class __$$GetSummaryImplCopyWithImpl<$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 +226,9 @@ class _$GetSummaryImpl implements _GetSummary { const _$GetSummaryImpl(this.startDate, this.endDate); @override - final String startDate; + final DateTime startDate; @override - final String endDate; + final DateTime endDate; @override String toString() { @@ -260,7 +260,7 @@ class _$GetSummaryImpl implements _GetSummary { @optionalTypeArgs TResult when({ required TResult Function() started, - required TResult Function(String startDate, String endDate) getSummary, + required TResult Function(DateTime startDate, DateTime endDate) getSummary, }) { return getSummary(startDate, endDate); } @@ -269,7 +269,7 @@ class _$GetSummaryImpl implements _GetSummary { @optionalTypeArgs TResult? whenOrNull({ TResult? Function()? started, - TResult? Function(String startDate, String endDate)? getSummary, + TResult? Function(DateTime startDate, DateTime endDate)? getSummary, }) { return getSummary?.call(startDate, endDate); } @@ -278,7 +278,7 @@ class _$GetSummaryImpl implements _GetSummary { @optionalTypeArgs TResult maybeWhen({ TResult Function()? started, - TResult Function(String startDate, String endDate)? getSummary, + TResult Function(DateTime startDate, DateTime endDate)? getSummary, required TResult orElse(), }) { if (getSummary != null) { @@ -320,11 +320,11 @@ class _$GetSummaryImpl implements _GetSummary { } abstract class _GetSummary implements SummaryEvent { - const factory _GetSummary(final String startDate, final String endDate) = + const factory _GetSummary(final DateTime startDate, final DateTime endDate) = _$GetSummaryImpl; - String get startDate; - String get endDate; + DateTime get startDate; + DateTime get endDate; /// Create a copy of SummaryEvent /// with the given fields replaced by the non-null parameter values. @@ -339,7 +339,7 @@ mixin _$SummaryState { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(SummaryModel summary) success, + required TResult Function(DashboardAnalyticData data) success, required TResult Function(String message) error, }) => throw _privateConstructorUsedError; @@ -347,7 +347,7 @@ mixin _$SummaryState { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(SummaryModel summary)? success, + TResult? Function(DashboardAnalyticData data)? success, TResult? Function(String message)? error, }) => throw _privateConstructorUsedError; @@ -355,7 +355,7 @@ mixin _$SummaryState { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(SummaryModel summary)? success, + TResult Function(DashboardAnalyticData data)? success, TResult Function(String message)? error, required TResult orElse(), }) => @@ -451,7 +451,7 @@ class _$InitialImpl implements _Initial { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(SummaryModel summary) success, + required TResult Function(DashboardAnalyticData data) success, required TResult Function(String message) error, }) { return initial(); @@ -462,7 +462,7 @@ class _$InitialImpl implements _Initial { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(SummaryModel summary)? success, + TResult? Function(DashboardAnalyticData data)? success, TResult? Function(String message)? error, }) { return initial?.call(); @@ -473,7 +473,7 @@ class _$InitialImpl implements _Initial { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(SummaryModel summary)? success, + TResult Function(DashboardAnalyticData data)? success, TResult Function(String message)? error, required TResult orElse(), }) { @@ -568,7 +568,7 @@ class _$LoadingImpl implements _Loading { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(SummaryModel summary) success, + required TResult Function(DashboardAnalyticData data) success, required TResult Function(String message) error, }) { return loading(); @@ -579,7 +579,7 @@ class _$LoadingImpl implements _Loading { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(SummaryModel summary)? success, + TResult? Function(DashboardAnalyticData data)? success, TResult? Function(String message)? error, }) { return loading?.call(); @@ -590,7 +590,7 @@ class _$LoadingImpl implements _Loading { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(SummaryModel summary)? success, + TResult Function(DashboardAnalyticData data)? success, TResult Function(String message)? error, required TResult orElse(), }) { @@ -648,7 +648,7 @@ abstract class _$$SuccessImplCopyWith<$Res> { _$SuccessImpl value, $Res Function(_$SuccessImpl) then) = __$$SuccessImplCopyWithImpl<$Res>; @useResult - $Res call({SummaryModel summary}); + $Res call({DashboardAnalyticData data}); } /// @nodoc @@ -664,13 +664,13 @@ class __$$SuccessImplCopyWithImpl<$Res> @pragma('vm:prefer-inline') @override $Res call({ - Object? summary = null, + Object? data = null, }) { return _then(_$SuccessImpl( - null == summary - ? _value.summary - : summary // ignore: cast_nullable_to_non_nullable - as SummaryModel, + null == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as DashboardAnalyticData, )); } } @@ -678,14 +678,14 @@ class __$$SuccessImplCopyWithImpl<$Res> /// @nodoc class _$SuccessImpl implements _Success { - const _$SuccessImpl(this.summary); + const _$SuccessImpl(this.data); @override - final SummaryModel summary; + final DashboardAnalyticData data; @override String toString() { - return 'SummaryState.success(summary: $summary)'; + return 'SummaryState.success(data: $data)'; } @override @@ -693,11 +693,11 @@ class _$SuccessImpl implements _Success { return identical(this, other) || (other.runtimeType == runtimeType && other is _$SuccessImpl && - (identical(other.summary, summary) || other.summary == summary)); + (identical(other.data, data) || other.data == data)); } @override - int get hashCode => Object.hash(runtimeType, summary); + int get hashCode => Object.hash(runtimeType, data); /// Create a copy of SummaryState /// with the given fields replaced by the non-null parameter values. @@ -712,10 +712,10 @@ class _$SuccessImpl implements _Success { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(SummaryModel summary) success, + required TResult Function(DashboardAnalyticData data) success, required TResult Function(String message) error, }) { - return success(summary); + return success(data); } @override @@ -723,10 +723,10 @@ class _$SuccessImpl implements _Success { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(SummaryModel summary)? success, + TResult? Function(DashboardAnalyticData data)? success, TResult? Function(String message)? error, }) { - return success?.call(summary); + return success?.call(data); } @override @@ -734,12 +734,12 @@ class _$SuccessImpl implements _Success { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(SummaryModel summary)? success, + TResult Function(DashboardAnalyticData data)? success, TResult Function(String message)? error, required TResult orElse(), }) { if (success != null) { - return success(summary); + return success(data); } return orElse(); } @@ -783,9 +783,9 @@ class _$SuccessImpl implements _Success { } abstract class _Success implements SummaryState { - const factory _Success(final SummaryModel summary) = _$SuccessImpl; + const factory _Success(final DashboardAnalyticData data) = _$SuccessImpl; - SummaryModel get summary; + DashboardAnalyticData get data; /// Create a copy of SummaryState /// with the given fields replaced by the non-null parameter values. @@ -864,7 +864,7 @@ class _$ErrorImpl implements _Error { TResult when({ required TResult Function() initial, required TResult Function() loading, - required TResult Function(SummaryModel summary) success, + required TResult Function(DashboardAnalyticData data) success, required TResult Function(String message) error, }) { return error(message); @@ -875,7 +875,7 @@ class _$ErrorImpl implements _Error { TResult? whenOrNull({ TResult? Function()? initial, TResult? Function()? loading, - TResult? Function(SummaryModel summary)? success, + TResult? Function(DashboardAnalyticData data)? success, TResult? Function(String message)? error, }) { return error?.call(message); @@ -886,7 +886,7 @@ class _$ErrorImpl implements _Error { TResult maybeWhen({ TResult Function()? initial, TResult Function()? loading, - TResult Function(SummaryModel summary)? success, + TResult Function(DashboardAnalyticData data)? success, TResult Function(String message)? error, required TResult orElse(), }) { diff --git a/lib/presentation/report/blocs/summary/summary_event.dart b/lib/presentation/report/blocs/summary/summary_event.dart index febbb48..e306a89 100644 --- a/lib/presentation/report/blocs/summary/summary_event.dart +++ b/lib/presentation/report/blocs/summary/summary_event.dart @@ -3,6 +3,6 @@ part of 'summary_bloc.dart'; @freezed class SummaryEvent with _$SummaryEvent { const factory SummaryEvent.started() = _Started; - const factory SummaryEvent.getSummary(String startDate, String endDate) = + const factory SummaryEvent.getSummary(DateTime startDate, DateTime endDate) = _GetSummary; } diff --git a/lib/presentation/report/blocs/summary/summary_state.dart b/lib/presentation/report/blocs/summary/summary_state.dart index 37dd6e8..e207d9e 100644 --- a/lib/presentation/report/blocs/summary/summary_state.dart +++ b/lib/presentation/report/blocs/summary/summary_state.dart @@ -4,6 +4,6 @@ part of 'summary_bloc.dart'; class SummaryState with _$SummaryState { const factory SummaryState.initial() = _Initial; const factory SummaryState.loading() = _Loading; - const factory SummaryState.success(SummaryModel summary) = _Success; + const factory SummaryState.success(DashboardAnalyticData data) = _Success; const factory SummaryState.error(String message) = _Error; } diff --git a/lib/presentation/report/pages/report_page.dart b/lib/presentation/report/pages/report_page.dart index 1eaf38a..b28ae7a 100644 --- a/lib/presentation/report/pages/report_page.dart +++ b/lib/presentation/report/pages/report_page.dart @@ -1,6 +1,7 @@ import 'dart:developer'; import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/presentation/report/widgets/dashboard_analytic_widget.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'; @@ -18,7 +19,6 @@ import 'package:enaklo_pos/presentation/report/widgets/product_analytic_widget.d 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'; -import 'package:enaklo_pos/presentation/report/widgets/summary_report_widget.dart'; import 'package:enaklo_pos/presentation/report/widgets/transaction_report_widget.dart'; import '../../../core/components/spaces.dart'; @@ -166,10 +166,7 @@ class _ReportPageState extends State { title = 'Ringkasan Laporan Penjualan'; setState(() {}); context.read().add( - SummaryEvent.getSummary( - DateFormatter.formatDateTime( - fromDate), - DateFormatter.formatDateTime(toDate)), + SummaryEvent.getSummary(fromDate, toDate), ); log("Date ${DateFormatter.formatDateTime(fromDate)}"); @@ -284,9 +281,9 @@ class _ReportPageState extends State { error: (message) { return Text(message); }, - success: (summary) { - return SummaryReportWidget( - summary: summary, + success: (data) { + return DashboardAnalyticWidget( + data: data, title: title, searchDateFormatted: searchDateFormatted, diff --git a/lib/presentation/report/widgets/dashboard_analytic_widget.dart b/lib/presentation/report/widgets/dashboard_analytic_widget.dart new file mode 100644 index 0000000..c583f70 --- /dev/null +++ b/lib/presentation/report/widgets/dashboard_analytic_widget.dart @@ -0,0 +1,639 @@ +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/data/models/response/dashboard_analytic_response_model.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +// App Colors +class AppColorDashboard { + static const secondary = Color(0xff7c3aed); + static const success = Color(0xff10b981); + static const warning = Color(0xfff59e0b); + static const danger = Color(0xffef4444); + static const info = Color(0xff3b82f6); +} + +class DashboardAnalyticWidget extends StatelessWidget { + final String title; + final String searchDateFormatted; + final DashboardAnalyticData data; + + const DashboardAnalyticWidget({ + super.key, + required this.data, + required this.title, + required this.searchDateFormatted, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFF8FAFC), + padding: const EdgeInsets.all(24.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 24), + _buildKPICards(), + const SizedBox(height: 24), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: _buildSalesChart()), + const SizedBox(width: 16), + Expanded(flex: 1, child: _buildProductChart()), + ], + ), + const SizedBox(height: 24), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 2, child: _buildTopProductsList()), + const SizedBox(width: 16), + Expanded(flex: 1, child: _buildOrderSummary()), + ], + ), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 28, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 4), + Text( + 'Analisis performa penjualan outlet', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: AppColors.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.calendar_today, color: Colors.white, size: 16), + const SizedBox(width: 8), + Text( + searchDateFormatted, + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildKPICards() { + final successfulOrders = data.overview.totalOrders - + data.overview.voidedOrders - + data.overview.refundedOrders; + + final kpiData = [ + { + 'title': 'Total Penjualan', + 'value': _formatCurrency(data.overview.totalSales), + 'icon': Icons.trending_up, + 'color': AppColorDashboard.success, + 'bgColor': AppColorDashboard.success.withOpacity(0.1), + }, + { + 'title': 'Total Pesanan', + 'value': '${data.overview.totalOrders}', + 'icon': Icons.shopping_cart, + 'color': AppColorDashboard.info, + 'bgColor': AppColorDashboard.info.withOpacity(0.1), + }, + { + 'title': 'Rata-rata Pesanan', + 'value': _formatCurrency(data.overview.averageOrderValue.toInt()), + 'icon': Icons.attach_money, + 'color': AppColorDashboard.warning, + 'bgColor': AppColorDashboard.warning.withOpacity(0.1), + }, + { + 'title': 'Pesanan Sukses', + 'value': '$successfulOrders', + 'icon': Icons.check_circle, + 'color': AppColors.primary, + 'bgColor': AppColors.primary.withOpacity(0.1), + }, + ]; + + return Row( + children: kpiData.map((kpi) { + return Expanded( + child: Container( + margin: const EdgeInsets.only(right: 16), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: kpi['bgColor'] as Color, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + kpi['icon'] as IconData, + color: kpi['color'] as Color, + size: 20, + ), + ), + Icon( + Icons.trending_up, + color: Colors.grey[400], + size: 16, + ), + ], + ), + const SizedBox(height: 16), + Text( + kpi['value'] as String, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 4), + Text( + kpi['title'] as String, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + Widget _buildSalesChart() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Tren Penjualan Harian', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 200, + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawHorizontalLine: true, + drawVerticalLine: false, + horizontalInterval: 200000, + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey[200]!, + strokeWidth: 1, + ); + }, + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 60, + getTitlesWidget: (value, meta) { + return Text( + '${(value / 1000).toInt()}K', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index >= 0 && index < data.recentSales.length) { + final date = + DateTime.parse(data.recentSales[index].date); + final formatter = DateFormat('dd MMM'); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + formatter.format(date), + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + ), + ), + ); + } + return const SizedBox(); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + LineChartBarData( + spots: data.recentSales.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), entry.value.sales.toDouble()); + }).toList(), + isCurved: true, + color: AppColors.primary, + // strokeWidth: 3, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + color: AppColors.primary.withOpacity(0.1), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildProductChart() { + final colors = [ + AppColors.primary, + AppColorDashboard.secondary, + AppColorDashboard.info, + AppColorDashboard.warning, + AppColorDashboard.success, + ]; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Distribusi Produk', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 20), + SizedBox( + height: 160, + child: PieChart( + PieChartData( + sectionsSpace: 2, + centerSpaceRadius: 40, + sections: data.topProducts.asMap().entries.map((entry) { + return PieChartSectionData( + color: colors[entry.key % colors.length], + value: entry.value.quantitySold.toDouble(), + title: '${entry.value.quantitySold}', + radius: 60, + titleStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ); + }).toList(), + ), + ), + ), + const SizedBox(height: 16), + Column( + children: data.topProducts.take(3).map((product) { + final index = data.topProducts.indexOf(product); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors[index % colors.length], + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + product.productName, + style: const TextStyle(fontSize: 12), + ), + ), + Text( + '${product.quantitySold}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildTopProductsList() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Produk Terlaris', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 16), + Column( + children: data.topProducts.map((product) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[200]!), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon( + Icons.local_cafe, + color: AppColors.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.productName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + product.categoryName, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${product.quantitySold} unit', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + _formatCurrency(product.revenue), + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ], + ), + ); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildOrderSummary() { + final successfulOrders = data.overview.totalOrders - + data.overview.voidedOrders - + data.overview.refundedOrders; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Ringkasan Pesanan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1F2937), + ), + ), + const SizedBox(height: 20), + _buildSummaryItem('Total Pesanan', '${data.overview.totalOrders}', + Icons.shopping_cart, AppColorDashboard.info), + _buildSummaryItem('Pesanan Sukses', '$successfulOrders', + Icons.check_circle, AppColorDashboard.success), + _buildSummaryItem( + 'Pesanan Dibatalkan', + '${data.overview.voidedOrders}', + Icons.cancel, + AppColorDashboard.danger), + _buildSummaryItem('Pesanan Refund', '${data.overview.refundedOrders}', + Icons.refresh, AppColorDashboard.warning), + const SizedBox(height: 20), + // Payment Methods + if (data.paymentMethods.isNotEmpty) + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + const Text( + 'Metode Pembayaran', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 8), + ...data.paymentMethods + .map((method) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + method.paymentMethodType == 'cash' + ? Icons.payments + : Icons.credit_card, + color: AppColors.primary, + size: 16, + ), + const SizedBox(width: 8), + Text( + method.paymentMethodName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + )) + .toList(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSummaryItem( + String title, String value, IconData icon, Color color) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: color, size: 16), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: const TextStyle(fontSize: 12), + ), + ), + Text( + value, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ); + } + + String _formatCurrency(int amount) { + final formatter = NumberFormat.currency( + locale: 'id_ID', + symbol: 'Rp ', + decimalDigits: 0, + ); + return formatter.format(amount); + } +} diff --git a/pubspec.lock b/pubspec.lock index 999be1d..aaf63b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -374,6 +374,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.0.6" + equatable: + dependency: transitive + description: + name: equatable + sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + url: "https://pub.dev" + source: hosted + version: "2.0.7" esc_pos_utils_plus: dependency: "direct main" description: @@ -446,6 +454,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + fl_chart: + dependency: "direct main" + description: + name: fl_chart + sha256: "577aeac8ca414c25333334d7c4bb246775234c0e44b38b10a82b559dd4d764e7" + url: "https://pub.dev" + source: hosted + version: "1.0.0" flutter: dependency: "direct main" description: flutter @@ -1548,4 +1564,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.0-0 <4.0.0" - flutter: ">=3.24.0" + flutter: ">=3.27.4" diff --git a/pubspec.yaml b/pubspec.yaml index d72b405..d8874e6 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -63,6 +63,7 @@ dependencies: awesome_dio_interceptor: ^1.3.0 another_flushbar: ^1.12.30 dropdown_search: ^5.0.6 + fl_chart: ^1.0.0 # imin_printer: ^0.6.10 dev_dependencies: