feat: Dashboard Analytic

This commit is contained in:
efrilm 2025-08-06 13:05:58 +07:00
parent 91335ad8db
commit bdb2c0ba52
11 changed files with 998 additions and 67 deletions

View File

@ -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<Either<String, DashboardAnalyticResponseModel>> 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');
}
}
}

View File

@ -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<String, dynamic> json) =>
DashboardAnalyticResponseModel.fromMap(json);
/// Untuk menerima Map biasa (bukan dari JSON string)
factory DashboardAnalyticResponseModel.fromMap(Map<String, dynamic> map) {
return DashboardAnalyticResponseModel(
success: map['success'] ?? false,
data: DashboardAnalyticData.fromMap(map['data'] ?? {}),
errors: map['errors'],
);
}
Map<String, dynamic> toJson() => toMap();
Map<String, dynamic> 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<TopProduct> topProducts;
final List<PaymentMethodAnalytic> paymentMethods;
final List<RecentSale> 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<String, dynamic> 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<TopProduct>.from(
map['top_products']?.map((x) => TopProduct.fromMap(x))),
paymentMethods: List<PaymentMethodAnalytic>.from(map['payment_methods']
?.map((x) => PaymentMethodAnalytic.fromMap(x))),
recentSales: List<RecentSale>.from(
map['recent_sales']?.map((x) => RecentSale.fromMap(x))),
);
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toMap() => {
'product_id': productId,
'product_name': productName,
'category_id': categoryId,
'category_name': categoryName,
'quantity_sold': quantitySold,
'revenue': revenue,
'average_price': averagePrice,
'order_count': orderCount,
};
}
class 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> toMap() => {
'date': date,
'sales': sales,
'orders': orders,
'items': items,
'tax': tax,
'discount': discount,
'net_sales': netSales,
};
}

View File

@ -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<MyApp> {
create: (context) => GetCategoriesBloc(CategoryRemoteDatasource()),
),
BlocProvider(
create: (context) => SummaryBloc(OrderRemoteDatasource()),
create: (context) => SummaryBloc(AnalyticRemoteDatasource()),
),
BlocProvider(
create: (context) => ProductSalesBloc(AnalyticRemoteDatasource()),

View File

@ -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<SummaryEvent, SummaryState> {
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)));
});
}
}

View File

@ -19,19 +19,19 @@ mixin _$SummaryEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
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 extends Object?>({
TResult? Function()? started,
TResult? Function(String startDate, String endDate)? getSummary,
TResult? Function(DateTime startDate, DateTime endDate)? getSummary,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
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<TResult extends Object?>({
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 extends Object?>({
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 extends Object?>({
TResult Function()? initial,
TResult Function()? loading,
TResult Function(SummaryModel summary)? success,
TResult Function(DashboardAnalyticData data)? success,
TResult Function(String message)? error,
required TResult orElse(),
}) {

View File

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

View File

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

View File

@ -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<ReportPage> {
title = 'Ringkasan Laporan Penjualan';
setState(() {});
context.read<SummaryBloc>().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<ReportPage> {
error: (message) {
return Text(message);
},
success: (summary) {
return SummaryReportWidget(
summary: summary,
success: (data) {
return DashboardAnalyticWidget(
data: data,
title: title,
searchDateFormatted:
searchDateFormatted,

View File

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

View File

@ -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"

View File

@ -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: