report dashboard

This commit is contained in:
efrilm 2025-11-03 16:24:11 +07:00
parent 069c2296de
commit b4a9cf31fc
30 changed files with 5779 additions and 5 deletions

View File

@ -0,0 +1,53 @@
import 'package:bloc/bloc.dart';
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
part 'dashboard_analytic_loader_event.dart';
part 'dashboard_analytic_loader_state.dart';
part 'dashboard_analytic_loader_bloc.freezed.dart';
@injectable
class DashboardAnalyticLoaderBloc
extends Bloc<DashboardAnalyticLoaderEvent, DashboardAnalyticLoaderState> {
final IAnalyticRepository _analyticRepository;
DashboardAnalyticLoaderBloc(this._analyticRepository)
: super(DashboardAnalyticLoaderState.initial()) {
on<DashboardAnalyticLoaderEvent>(_onDashboardAnalyticLoader);
}
Future<void> _onDashboardAnalyticLoader(
DashboardAnalyticLoaderEvent event,
Emitter<DashboardAnalyticLoaderState> emit,
) {
return event.map(
fetched: (e) async {
emit(state.copyWith(isFetching: true, failureOption: none()));
final result = await _analyticRepository.getDashboard(
dateFrom: e.startDate,
dateTo: e.endDate,
);
await result.fold(
(failure) async {
emit(
state.copyWith(
isFetching: false,
failureOption: optionOf(failure),
),
);
},
(dashboard) async {
emit(
state.copyWith(isFetching: false, dashboardAnalytic: dashboard),
);
},
);
},
);
}
}

View File

@ -0,0 +1,480 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'dashboard_analytic_loader_bloc.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models',
);
/// @nodoc
mixin _$DashboardAnalyticLoaderEvent {
DateTime get startDate => throw _privateConstructorUsedError;
DateTime get endDate => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(DateTime startDate, DateTime endDate) fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime startDate, DateTime endDate)? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime startDate, DateTime endDate)? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fetched value) fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fetched value)? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) => throw _privateConstructorUsedError;
/// Create a copy of DashboardAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$DashboardAnalyticLoaderEventCopyWith<DashboardAnalyticLoaderEvent>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DashboardAnalyticLoaderEventCopyWith<$Res> {
factory $DashboardAnalyticLoaderEventCopyWith(
DashboardAnalyticLoaderEvent value,
$Res Function(DashboardAnalyticLoaderEvent) then,
) =
_$DashboardAnalyticLoaderEventCopyWithImpl<
$Res,
DashboardAnalyticLoaderEvent
>;
@useResult
$Res call({DateTime startDate, DateTime endDate});
}
/// @nodoc
class _$DashboardAnalyticLoaderEventCopyWithImpl<
$Res,
$Val extends DashboardAnalyticLoaderEvent
>
implements $DashboardAnalyticLoaderEventCopyWith<$Res> {
_$DashboardAnalyticLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of DashboardAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? startDate = null, Object? endDate = null}) {
return _then(
_value.copyWith(
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
)
as $Val,
);
}
}
/// @nodoc
abstract class _$$FetchedImplCopyWith<$Res>
implements $DashboardAnalyticLoaderEventCopyWith<$Res> {
factory _$$FetchedImplCopyWith(
_$FetchedImpl value,
$Res Function(_$FetchedImpl) then,
) = __$$FetchedImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({DateTime startDate, DateTime endDate});
}
/// @nodoc
class __$$FetchedImplCopyWithImpl<$Res>
extends _$DashboardAnalyticLoaderEventCopyWithImpl<$Res, _$FetchedImpl>
implements _$$FetchedImplCopyWith<$Res> {
__$$FetchedImplCopyWithImpl(
_$FetchedImpl _value,
$Res Function(_$FetchedImpl) _then,
) : super(_value, _then);
/// Create a copy of DashboardAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({Object? startDate = null, Object? endDate = null}) {
return _then(
_$FetchedImpl(
startDate: null == startDate
? _value.startDate
: startDate // ignore: cast_nullable_to_non_nullable
as DateTime,
endDate: null == endDate
? _value.endDate
: endDate // ignore: cast_nullable_to_non_nullable
as DateTime,
),
);
}
}
/// @nodoc
class _$FetchedImpl implements _Fetched {
const _$FetchedImpl({required this.startDate, required this.endDate});
@override
final DateTime startDate;
@override
final DateTime endDate;
@override
String toString() {
return 'DashboardAnalyticLoaderEvent.fetched(startDate: $startDate, endDate: $endDate)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$FetchedImpl &&
(identical(other.startDate, startDate) ||
other.startDate == startDate) &&
(identical(other.endDate, endDate) || other.endDate == endDate));
}
@override
int get hashCode => Object.hash(runtimeType, startDate, endDate);
/// Create a copy of DashboardAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$FetchedImplCopyWith<_$FetchedImpl> get copyWith =>
__$$FetchedImplCopyWithImpl<_$FetchedImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(DateTime startDate, DateTime endDate) fetched,
}) {
return fetched(startDate, endDate);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(DateTime startDate, DateTime endDate)? fetched,
}) {
return fetched?.call(startDate, endDate);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(DateTime startDate, DateTime endDate)? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched(startDate, endDate);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(_Fetched value) fetched,
}) {
return fetched(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(_Fetched value)? fetched,
}) {
return fetched?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(_Fetched value)? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched(this);
}
return orElse();
}
}
abstract class _Fetched implements DashboardAnalyticLoaderEvent {
const factory _Fetched({
required final DateTime startDate,
required final DateTime endDate,
}) = _$FetchedImpl;
@override
DateTime get startDate;
@override
DateTime get endDate;
/// Create a copy of DashboardAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$FetchedImplCopyWith<_$FetchedImpl> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
mixin _$DashboardAnalyticLoaderState {
DashboardAnalytic get dashboardAnalytic => throw _privateConstructorUsedError;
Option<AnalyticFailure> get failureOption =>
throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$DashboardAnalyticLoaderStateCopyWith<DashboardAnalyticLoaderState>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $DashboardAnalyticLoaderStateCopyWith<$Res> {
factory $DashboardAnalyticLoaderStateCopyWith(
DashboardAnalyticLoaderState value,
$Res Function(DashboardAnalyticLoaderState) then,
) =
_$DashboardAnalyticLoaderStateCopyWithImpl<
$Res,
DashboardAnalyticLoaderState
>;
@useResult
$Res call({
DashboardAnalytic dashboardAnalytic,
Option<AnalyticFailure> failureOption,
bool isFetching,
});
$DashboardAnalyticCopyWith<$Res> get dashboardAnalytic;
}
/// @nodoc
class _$DashboardAnalyticLoaderStateCopyWithImpl<
$Res,
$Val extends DashboardAnalyticLoaderState
>
implements $DashboardAnalyticLoaderStateCopyWith<$Res> {
_$DashboardAnalyticLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? dashboardAnalytic = null,
Object? failureOption = null,
Object? isFetching = null,
}) {
return _then(
_value.copyWith(
dashboardAnalytic: null == dashboardAnalytic
? _value.dashboardAnalytic
: dashboardAnalytic // ignore: cast_nullable_to_non_nullable
as DashboardAnalytic,
failureOption: null == failureOption
? _value.failureOption
: failureOption // ignore: cast_nullable_to_non_nullable
as Option<AnalyticFailure>,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
)
as $Val,
);
}
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$DashboardAnalyticCopyWith<$Res> get dashboardAnalytic {
return $DashboardAnalyticCopyWith<$Res>(_value.dashboardAnalytic, (value) {
return _then(_value.copyWith(dashboardAnalytic: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$DashboardAnalyticLoaderStateImplCopyWith<$Res>
implements $DashboardAnalyticLoaderStateCopyWith<$Res> {
factory _$$DashboardAnalyticLoaderStateImplCopyWith(
_$DashboardAnalyticLoaderStateImpl value,
$Res Function(_$DashboardAnalyticLoaderStateImpl) then,
) = __$$DashboardAnalyticLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
DashboardAnalytic dashboardAnalytic,
Option<AnalyticFailure> failureOption,
bool isFetching,
});
@override
$DashboardAnalyticCopyWith<$Res> get dashboardAnalytic;
}
/// @nodoc
class __$$DashboardAnalyticLoaderStateImplCopyWithImpl<$Res>
extends
_$DashboardAnalyticLoaderStateCopyWithImpl<
$Res,
_$DashboardAnalyticLoaderStateImpl
>
implements _$$DashboardAnalyticLoaderStateImplCopyWith<$Res> {
__$$DashboardAnalyticLoaderStateImplCopyWithImpl(
_$DashboardAnalyticLoaderStateImpl _value,
$Res Function(_$DashboardAnalyticLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? dashboardAnalytic = null,
Object? failureOption = null,
Object? isFetching = null,
}) {
return _then(
_$DashboardAnalyticLoaderStateImpl(
dashboardAnalytic: null == dashboardAnalytic
? _value.dashboardAnalytic
: dashboardAnalytic // ignore: cast_nullable_to_non_nullable
as DashboardAnalytic,
failureOption: null == failureOption
? _value.failureOption
: failureOption // ignore: cast_nullable_to_non_nullable
as Option<AnalyticFailure>,
isFetching: null == isFetching
? _value.isFetching
: isFetching // ignore: cast_nullable_to_non_nullable
as bool,
),
);
}
}
/// @nodoc
class _$DashboardAnalyticLoaderStateImpl
implements _DashboardAnalyticLoaderState {
_$DashboardAnalyticLoaderStateImpl({
required this.dashboardAnalytic,
required this.failureOption,
this.isFetching = false,
});
@override
final DashboardAnalytic dashboardAnalytic;
@override
final Option<AnalyticFailure> failureOption;
@override
@JsonKey()
final bool isFetching;
@override
String toString() {
return 'DashboardAnalyticLoaderState(dashboardAnalytic: $dashboardAnalytic, failureOption: $failureOption, isFetching: $isFetching)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$DashboardAnalyticLoaderStateImpl &&
(identical(other.dashboardAnalytic, dashboardAnalytic) ||
other.dashboardAnalytic == dashboardAnalytic) &&
(identical(other.failureOption, failureOption) ||
other.failureOption == failureOption) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching));
}
@override
int get hashCode =>
Object.hash(runtimeType, dashboardAnalytic, failureOption, isFetching);
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$DashboardAnalyticLoaderStateImplCopyWith<
_$DashboardAnalyticLoaderStateImpl
>
get copyWith =>
__$$DashboardAnalyticLoaderStateImplCopyWithImpl<
_$DashboardAnalyticLoaderStateImpl
>(this, _$identity);
}
abstract class _DashboardAnalyticLoaderState
implements DashboardAnalyticLoaderState {
factory _DashboardAnalyticLoaderState({
required final DashboardAnalytic dashboardAnalytic,
required final Option<AnalyticFailure> failureOption,
final bool isFetching,
}) = _$DashboardAnalyticLoaderStateImpl;
@override
DashboardAnalytic get dashboardAnalytic;
@override
Option<AnalyticFailure> get failureOption;
@override
bool get isFetching;
/// Create a copy of DashboardAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$DashboardAnalyticLoaderStateImplCopyWith<
_$DashboardAnalyticLoaderStateImpl
>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,9 @@
part of 'dashboard_analytic_loader_bloc.dart';
@freezed
class DashboardAnalyticLoaderEvent with _$DashboardAnalyticLoaderEvent {
const factory DashboardAnalyticLoaderEvent.fetched({
required DateTime startDate,
required DateTime endDate,
}) = _Fetched;
}

View File

@ -0,0 +1,16 @@
part of 'dashboard_analytic_loader_bloc.dart';
@freezed
class DashboardAnalyticLoaderState with _$DashboardAnalyticLoaderState {
factory DashboardAnalyticLoaderState({
required DashboardAnalytic dashboardAnalytic,
required Option<AnalyticFailure> failureOption,
@Default(false) bool isFetching,
}) = _DashboardAnalyticLoaderState;
factory DashboardAnalyticLoaderState.initial() =>
DashboardAnalyticLoaderState(
dashboardAnalytic: DashboardAnalytic.empty(),
failureOption: none(),
);
}

View File

@ -12,9 +12,9 @@ class ReportState with _$ReportState {
factory ReportState.initial() => ReportState(
title: 'Ringkasan Laporan Penjualan',
startDate: DateTime.now(),
startDate: DateTime.now().subtract(const Duration(days: 30)),
endDate: DateTime.now(),
rangeDateFormatted:
'${DateTime.now().toFormattedDate()} - ${DateTime.now().toFormattedDate()}',
'${DateTime.now().subtract(const Duration(days: 30)).toFormattedDate()} - ${DateTime.now().toFormattedDate()}',
);
}

View File

@ -62,4 +62,12 @@ extension DateTimeExt on DateTime {
return '$day $month $year, $hour:$minute:$second';
}
String toServerDate() {
return DateFormat('dd-MM-yyyy').format(this);
}
String toSimpleMonthDate() {
return DateFormat('dd MMM yyyy').format(this);
}
}

View File

@ -8,4 +8,5 @@ class ApiPath {
static const String paymentMethods = '/api/v1/payment-methods';
static const String orders = '/api/v1/orders';
static const String payments = '/api/v1/payments';
static const String analyticDashboard = '/api/v1/analytics/dashboard';
}

View File

@ -0,0 +1,10 @@
import 'package:dartz/dartz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../common/api/api_failure.dart';
part 'analytic.freezed.dart';
part 'entities/dashboard_entity.dart';
part 'failures/analytic_failure.dart';
part 'repositories/i_analytic_repository.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,118 @@
part of '../analytic.dart';
@freezed
class DashboardAnalytic with _$DashboardAnalytic {
const factory DashboardAnalytic({
required String organizationId,
required String outletId,
required String dateFrom,
required String dateTo,
required DashboardOverview overview,
required List<DashboardTopProduct> topProducts,
required List<DashboardPaymentMethod> paymentMethods,
required List<DashboardRecentSale> recentSales,
}) = _DashboardAnalytic;
factory DashboardAnalytic.empty() => DashboardAnalytic(
organizationId: '',
outletId: '',
dateFrom: '',
dateTo: '',
overview: DashboardOverview.empty(),
topProducts: const [],
paymentMethods: const [],
recentSales: const [],
);
}
@freezed
class DashboardOverview with _$DashboardOverview {
const factory DashboardOverview({
required int totalSales,
required int totalOrders,
required double averageOrderValue,
required int totalCustomers,
required int voidedOrders,
required int refundedOrders,
}) = _DashboardOverview;
factory DashboardOverview.empty() => const DashboardOverview(
totalSales: 0,
totalOrders: 0,
averageOrderValue: 0.0,
totalCustomers: 0,
voidedOrders: 0,
refundedOrders: 0,
);
}
@freezed
class DashboardTopProduct with _$DashboardTopProduct {
const factory DashboardTopProduct({
required String productId,
required String productName,
required String categoryId,
required String categoryName,
required int quantitySold,
required int revenue,
required double averagePrice,
required int orderCount,
}) = _DashboardTopProduct;
factory DashboardTopProduct.empty() => const DashboardTopProduct(
productId: '',
productName: '',
categoryId: '',
categoryName: '',
quantitySold: 0,
revenue: 0,
averagePrice: 0.0,
orderCount: 0,
);
}
@freezed
class DashboardPaymentMethod with _$DashboardPaymentMethod {
const factory DashboardPaymentMethod({
required String paymentMethodId,
required String paymentMethodName,
required String paymentMethodType,
required int totalAmount,
required int orderCount,
required int paymentCount,
required double percentage,
}) = _DashboardPaymentMethod;
factory DashboardPaymentMethod.empty() => const DashboardPaymentMethod(
paymentMethodId: '',
paymentMethodName: '',
paymentMethodType: '',
totalAmount: 0,
orderCount: 0,
paymentCount: 0,
percentage: 0.0,
);
}
@freezed
class DashboardRecentSale with _$DashboardRecentSale {
const factory DashboardRecentSale({
required String date,
required int sales,
required int orders,
required int items,
required int tax,
required int discount,
required int netSales,
}) = _DashboardRecentSale;
factory DashboardRecentSale.empty() => const DashboardRecentSale(
date: '',
sales: 0,
orders: 0,
items: 0,
tax: 0,
discount: 0,
netSales: 0,
);
}

View File

@ -0,0 +1,9 @@
part of '../analytic.dart';
@freezed
sealed class AnalyticFailure with _$AnalyticFailure {
const factory AnalyticFailure.serverError(ApiFailure failure) = _ServerError;
const factory AnalyticFailure.unexpectedError() = _UnexpectedError;
const factory AnalyticFailure.dynamicErrorMessage(String erroMessage) =
_DynamicErrorMessage;
}

View File

@ -0,0 +1,8 @@
part of '../analytic.dart';
abstract class IAnalyticRepository {
Future<Either<AnalyticFailure, DashboardAnalytic>> getDashboard({
required DateTime dateFrom,
required DateTime dateTo,
});
}

View File

@ -0,0 +1,8 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../domain/analytic/analytic.dart';
part 'analytic_dtos.freezed.dart';
part 'analytic_dtos.g.dart';
part 'dtos/dashboard_dto.dart';

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,139 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'analytic_dtos.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$DashboardAnalyticDtoImpl _$$DashboardAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardAnalyticDtoImpl(
organizationId: json['organization_id'] as String?,
outletId: json['outlet_id'] as String?,
dateFrom: json['date_from'] as String?,
dateTo: json['date_to'] as String?,
overview: json['overview'] == null
? null
: DashboardOverviewDto.fromJson(json['overview'] as Map<String, dynamic>),
topProducts: (json['top_products'] as List<dynamic>?)
?.map((e) => DashboardTopProductDto.fromJson(e as Map<String, dynamic>))
.toList(),
paymentMethods: (json['payment_methods'] as List<dynamic>?)
?.map(
(e) => DashboardPaymentMethodDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
recentSales: (json['recent_sales'] as List<dynamic>?)
?.map((e) => DashboardRecentSaleDto.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$$DashboardAnalyticDtoImplToJson(
_$DashboardAnalyticDtoImpl instance,
) => <String, dynamic>{
'organization_id': instance.organizationId,
'outlet_id': instance.outletId,
'date_from': instance.dateFrom,
'date_to': instance.dateTo,
'overview': instance.overview,
'top_products': instance.topProducts,
'payment_methods': instance.paymentMethods,
'recent_sales': instance.recentSales,
};
_$DashboardOverviewDtoImpl _$$DashboardOverviewDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardOverviewDtoImpl(
totalSales: (json['total_sales'] as num?)?.toInt(),
totalOrders: (json['total_orders'] as num?)?.toInt(),
averageOrderValue: (json['average_order_value'] as num?)?.toDouble(),
totalCustomers: (json['total_customers'] as num?)?.toInt(),
voidedOrders: (json['voided_orders'] as num?)?.toInt(),
refundedOrders: (json['refunded_orders'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardOverviewDtoImplToJson(
_$DashboardOverviewDtoImpl instance,
) => <String, dynamic>{
'total_sales': instance.totalSales,
'total_orders': instance.totalOrders,
'average_order_value': instance.averageOrderValue,
'total_customers': instance.totalCustomers,
'voided_orders': instance.voidedOrders,
'refunded_orders': instance.refundedOrders,
};
_$DashboardTopProductDtoImpl _$$DashboardTopProductDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardTopProductDtoImpl(
productId: json['product_id'] as String?,
productName: json['product_name'] as String?,
categoryId: json['category_id'] as String?,
categoryName: json['category_name'] as String?,
quantitySold: (json['quantity_sold'] as num?)?.toInt(),
revenue: (json['revenue'] as num?)?.toInt(),
averagePrice: (json['average_price'] as num?)?.toDouble(),
orderCount: (json['order_count'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardTopProductDtoImplToJson(
_$DashboardTopProductDtoImpl instance,
) => <String, dynamic>{
'product_id': instance.productId,
'product_name': instance.productName,
'category_id': instance.categoryId,
'category_name': instance.categoryName,
'quantity_sold': instance.quantitySold,
'revenue': instance.revenue,
'average_price': instance.averagePrice,
'order_count': instance.orderCount,
};
_$DashboardPaymentMethodDtoImpl _$$DashboardPaymentMethodDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardPaymentMethodDtoImpl(
paymentMethodId: json['payment_method_id'] as String?,
paymentMethodName: json['payment_method_name'] as String?,
paymentMethodType: json['payment_method_type'] as String?,
totalAmount: (json['total_amount'] as num?)?.toInt(),
orderCount: (json['order_count'] as num?)?.toInt(),
paymentCount: (json['payment_count'] as num?)?.toInt(),
percentage: (json['percentage'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$DashboardPaymentMethodDtoImplToJson(
_$DashboardPaymentMethodDtoImpl instance,
) => <String, dynamic>{
'payment_method_id': instance.paymentMethodId,
'payment_method_name': instance.paymentMethodName,
'payment_method_type': instance.paymentMethodType,
'total_amount': instance.totalAmount,
'order_count': instance.orderCount,
'payment_count': instance.paymentCount,
'percentage': instance.percentage,
};
_$DashboardRecentSaleDtoImpl _$$DashboardRecentSaleDtoImplFromJson(
Map<String, dynamic> json,
) => _$DashboardRecentSaleDtoImpl(
date: json['date'] as String?,
sales: (json['sales'] as num?)?.toInt(),
orders: (json['orders'] as num?)?.toInt(),
items: (json['items'] as num?)?.toInt(),
tax: (json['tax'] as num?)?.toInt(),
discount: (json['discount'] as num?)?.toInt(),
netSales: (json['net_sales'] as num?)?.toInt(),
);
Map<String, dynamic> _$$DashboardRecentSaleDtoImplToJson(
_$DashboardRecentSaleDtoImpl instance,
) => <String, dynamic>{
'date': instance.date,
'sales': instance.sales,
'orders': instance.orders,
'items': instance.items,
'tax': instance.tax,
'discount': instance.discount,
'net_sales': instance.netSales,
};

View File

@ -0,0 +1,50 @@
import 'dart:developer';
import 'package:data_channel/data_channel.dart';
import 'package:injectable/injectable.dart';
import '../../../common/api/api_client.dart';
import '../../../common/api/api_failure.dart';
import '../../../common/extension/extension.dart';
import '../../../common/function/app_function.dart';
import '../../../common/url/api_path.dart';
import '../../../domain/analytic/analytic.dart';
import '../analytic_dtos.dart';
@injectable
class AnalyticRemoteDataProvider {
final ApiClient _apiClient;
final _logName = 'AnalyticRemoteDataProvider';
AnalyticRemoteDataProvider(this._apiClient);
Future<DC<AnalyticFailure, DashboardAnalyticDto>> fetchDashboard({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final response = await _apiClient.get(
ApiPath.analyticDashboard,
params: {
'date_from': dateFrom.toServerDate(),
'date_to': dateTo.toServerDate(),
},
headers: getAuthorizationHeader(),
);
if (response.data['success'] == false) {
return DC.error(AnalyticFailure.unexpectedError());
}
final dashboard = DashboardAnalyticDto.fromJson(
response.data['data'] as Map<String, dynamic>,
);
return DC.data(dashboard);
} on ApiFailure catch (e, s) {
log('fetchDashboard', name: _logName, error: e, stackTrace: s);
return DC.error(AnalyticFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,149 @@
part of '../analytic_dtos.dart';
@freezed
class DashboardAnalyticDto with _$DashboardAnalyticDto {
const DashboardAnalyticDto._();
const factory DashboardAnalyticDto({
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "outlet_id") String? outletId,
@JsonKey(name: "date_from") String? dateFrom,
@JsonKey(name: "date_to") String? dateTo,
@JsonKey(name: "overview") DashboardOverviewDto? overview,
@JsonKey(name: "top_products") List<DashboardTopProductDto>? topProducts,
@JsonKey(name: "payment_methods")
List<DashboardPaymentMethodDto>? paymentMethods,
@JsonKey(name: "recent_sales") List<DashboardRecentSaleDto>? recentSales,
}) = _DashboardAnalyticDto;
factory DashboardAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$DashboardAnalyticDtoFromJson(json);
// Optional mapping ke domain
DashboardAnalytic toDomain() => DashboardAnalytic(
organizationId: organizationId ?? '',
outletId: outletId ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '',
overview: overview?.toDomain() ?? DashboardOverview.empty(),
topProducts: topProducts?.map((e) => e.toDomain()).toList() ?? [],
paymentMethods: paymentMethods?.map((e) => e.toDomain()).toList() ?? [],
recentSales: recentSales?.map((e) => e.toDomain()).toList() ?? [],
);
}
@freezed
class DashboardOverviewDto with _$DashboardOverviewDto {
const DashboardOverviewDto._();
const factory DashboardOverviewDto({
@JsonKey(name: "total_sales") int? totalSales,
@JsonKey(name: "total_orders") int? totalOrders,
@JsonKey(name: "average_order_value") double? averageOrderValue,
@JsonKey(name: "total_customers") int? totalCustomers,
@JsonKey(name: "voided_orders") int? voidedOrders,
@JsonKey(name: "refunded_orders") int? refundedOrders,
}) = _DashboardOverviewDto;
factory DashboardOverviewDto.fromJson(Map<String, dynamic> json) =>
_$DashboardOverviewDtoFromJson(json);
// Optional mapping ke domain
DashboardOverview toDomain() => DashboardOverview(
totalSales: totalSales ?? 0,
totalOrders: totalOrders ?? 0,
averageOrderValue: averageOrderValue ?? 0.0,
totalCustomers: totalCustomers ?? 0,
voidedOrders: voidedOrders ?? 0,
refundedOrders: refundedOrders ?? 0,
);
}
@freezed
class DashboardTopProductDto with _$DashboardTopProductDto {
const DashboardTopProductDto._();
const factory DashboardTopProductDto({
@JsonKey(name: "product_id") String? productId,
@JsonKey(name: "product_name") String? productName,
@JsonKey(name: "category_id") String? categoryId,
@JsonKey(name: "category_name") String? categoryName,
@JsonKey(name: "quantity_sold") int? quantitySold,
@JsonKey(name: "revenue") int? revenue,
@JsonKey(name: "average_price") double? averagePrice,
@JsonKey(name: "order_count") int? orderCount,
}) = _DashboardTopProductDto;
factory DashboardTopProductDto.fromJson(Map<String, dynamic> json) =>
_$DashboardTopProductDtoFromJson(json);
// Optional mapping ke domain
DashboardTopProduct toDomain() => DashboardTopProduct(
productId: productId ?? '',
productName: productName ?? '',
categoryId: categoryId ?? '',
categoryName: categoryName ?? '',
quantitySold: quantitySold ?? 0,
revenue: revenue ?? 0,
averagePrice: averagePrice ?? 0.0,
orderCount: orderCount ?? 0,
);
}
@freezed
class DashboardPaymentMethodDto with _$DashboardPaymentMethodDto {
const DashboardPaymentMethodDto._();
const factory DashboardPaymentMethodDto({
@JsonKey(name: "payment_method_id") String? paymentMethodId,
@JsonKey(name: "payment_method_name") String? paymentMethodName,
@JsonKey(name: "payment_method_type") String? paymentMethodType,
@JsonKey(name: "total_amount") int? totalAmount,
@JsonKey(name: "order_count") int? orderCount,
@JsonKey(name: "payment_count") int? paymentCount,
@JsonKey(name: "percentage") double? percentage,
}) = _DashboardPaymentMethodDto;
factory DashboardPaymentMethodDto.fromJson(Map<String, dynamic> json) =>
_$DashboardPaymentMethodDtoFromJson(json);
// Optional mapping ke domain
DashboardPaymentMethod toDomain() => DashboardPaymentMethod(
paymentMethodId: paymentMethodId ?? '',
paymentMethodName: paymentMethodName ?? '',
paymentMethodType: paymentMethodType ?? '',
totalAmount: totalAmount ?? 0,
orderCount: orderCount ?? 0,
paymentCount: paymentCount ?? 0,
percentage: percentage ?? 0.0,
);
}
@freezed
class DashboardRecentSaleDto with _$DashboardRecentSaleDto {
const DashboardRecentSaleDto._();
const factory DashboardRecentSaleDto({
@JsonKey(name: "date") String? date,
@JsonKey(name: "sales") int? sales,
@JsonKey(name: "orders") int? orders,
@JsonKey(name: "items") int? items,
@JsonKey(name: "tax") int? tax,
@JsonKey(name: "discount") int? discount,
@JsonKey(name: "net_sales") int? netSales,
}) = _DashboardRecentSaleDto;
factory DashboardRecentSaleDto.fromJson(Map<String, dynamic> json) =>
_$DashboardRecentSaleDtoFromJson(json);
// Optional mapping ke domain
DashboardRecentSale toDomain() => DashboardRecentSale(
date: date ?? '',
sales: sales ?? 0,
orders: orders ?? 0,
items: items ?? 0,
tax: tax ?? 0,
discount: discount ?? 0,
netSales: netSales ?? 0,
);
}

View File

@ -0,0 +1,40 @@
import 'dart:developer';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
import '../datasources/remote_data_provider.dart';
@Injectable(as: IAnalyticRepository)
class AnalyticRepository implements IAnalyticRepository {
final AnalyticRemoteDataProvider _dataProvider;
final _logName = 'AnalyticRepository';
AnalyticRepository(this._dataProvider);
@override
Future<Either<AnalyticFailure, DashboardAnalytic>> getDashboard({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final result = await _dataProvider.fetchDashboard(
dateFrom: dateFrom,
dateTo: dateTo,
);
if (result.hasError) {
return left(result.error!);
}
final dashboard = result.data!.toDomain();
return right(dashboard);
} catch (e) {
log('getDashboardError', name: _logName, error: e);
return left(const AnalyticFailure.unexpectedError());
}
}
}

View File

@ -9,6 +9,8 @@
// coverage:ignore-file
// ignore_for_file: no_leading_underscores_for_library_prefixes
import 'package:apskel_pos_flutter_v2/application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart'
as _i80;
import 'package:apskel_pos_flutter_v2/application/auth/auth_bloc.dart' as _i343;
import 'package:apskel_pos_flutter_v2/application/auth/login_form/login_form_bloc.dart'
as _i46;
@ -54,6 +56,7 @@ import 'package:apskel_pos_flutter_v2/common/di/di_shared_preferences.dart'
as _i135;
import 'package:apskel_pos_flutter_v2/common/network/network_client.dart'
as _i171;
import 'package:apskel_pos_flutter_v2/domain/analytic/analytic.dart' as _i346;
import 'package:apskel_pos_flutter_v2/domain/auth/auth.dart' as _i776;
import 'package:apskel_pos_flutter_v2/domain/category/category.dart' as _i502;
import 'package:apskel_pos_flutter_v2/domain/customer/customer.dart' as _i143;
@ -64,6 +67,10 @@ import 'package:apskel_pos_flutter_v2/domain/payment_method/payment_method.dart'
import 'package:apskel_pos_flutter_v2/domain/product/product.dart' as _i44;
import 'package:apskel_pos_flutter_v2/domain/table/table.dart' as _i983;
import 'package:apskel_pos_flutter_v2/env.dart' as _i923;
import 'package:apskel_pos_flutter_v2/infrastructure/analytic/datasources/remote_data_provider.dart'
as _i708;
import 'package:apskel_pos_flutter_v2/infrastructure/analytic/repositories/analytic_repository.dart'
as _i288;
import 'package:apskel_pos_flutter_v2/infrastructure/auth/datasources/local_data_provider.dart'
as _i204;
import 'package:apskel_pos_flutter_v2/infrastructure/auth/datasources/remote_data_provider.dart'
@ -182,6 +189,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i841.CustomerRemoteDataProvider>(
() => _i841.CustomerRemoteDataProvider(gh<_i457.ApiClient>()),
);
gh.factory<_i708.AnalyticRemoteDataProvider>(
() => _i708.AnalyticRemoteDataProvider(gh<_i457.ApiClient>()),
);
gh.factory<_i776.IAuthRepository>(
() => _i941.AuthRepository(
gh<_i370.AuthRemoteDataProvider>(),
@ -256,6 +266,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i683.CustomerLoaderBloc>(
() => _i683.CustomerLoaderBloc(gh<_i143.ICustomerRepository>()),
);
gh.factory<_i346.IAnalyticRepository>(
() => _i288.AnalyticRepository(gh<_i708.AnalyticRemoteDataProvider>()),
);
gh.factory<_i952.PaymentMethodLoaderBloc>(
() => _i952.PaymentMethodLoaderBloc(gh<_i297.IPaymentMethodRepository>()),
);
@ -277,6 +290,9 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i502.ICategoryRepository>(),
),
);
gh.factory<_i80.DashboardAnalyticLoaderBloc>(
() => _i80.DashboardAnalyticLoaderBloc(gh<_i346.IAnalyticRepository>()),
);
return this;
}
}

View File

@ -0,0 +1,43 @@
import 'package:flutter/widgets.dart';
import '../../../common/data/report_menu.dart';
import '../../../domain/analytic/analytic.dart';
import '../card/error_card.dart';
class AnalyticErrorStateWidget extends StatelessWidget {
final AnalyticFailure failure;
final ReportMenu menu;
final Function() onRefresh;
const AnalyticErrorStateWidget({
super.key,
required this.failure,
required this.menu,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
return failure.maybeMap(
orElse: () => ErrorCard(
title: menu.title,
message: 'Terjadi kesalahan, Silakan coba lagi!',
onTap: onRefresh,
),
dynamicErrorMessage: (value) => ErrorCard(
title: menu.title,
message: value.erroMessage,
onTap: onRefresh,
),
serverError: (value) => ErrorCard(
title: menu.title,
message: 'Server Error, Silakan coba lagi!',
onTap: onRefresh,
),
unexpectedError: (value) => ErrorCard(
title: menu.title,
message: 'Terjadi kesalahan, Silakan coba lagi!',
onTap: onRefresh,
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import '../../../../common/data/report_menu.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../spaces/space.dart';
class ReportHeader extends StatelessWidget {
final ReportMenu menu;
final DateTime endDate;
final DateTime startDate;
const ReportHeader({
super.key,
required this.menu,
required this.endDate,
required this.startDate,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
menu.title,
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
menu.subtitle,
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColor.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(
startDate.toSimpleMonthDate() == endDate.toSimpleMonthDate()
? startDate.toSimpleMonthDate()
: '${startDate.toSimpleMonthDate()} - ${endDate.toSimpleMonthDate()}',
style: AppStyle.sm.copyWith(
color: Colors.white,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
}

View File

@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../../../common/theme/theme.dart';
import '../../spaces/space.dart';
class ReportSummaryCard extends StatelessWidget {
final Color color;
final IconData icon;
final String title;
final String value;
const ReportSummaryCard({
super.key,
required this.color,
required this.icon,
required this.title,
required this.value,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.grey[200]!),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: color.withOpacity(0.12),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: color, size: 22),
),
SpaceWidth(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: AppStyle.xxl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
title,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
],
),
),
],
),
);
}
}

View File

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../../../application/report/report_bloc.dart';
import '../../../../../common/data/report_menu.dart';
import '../../../../../common/theme/theme.dart';
@ -11,6 +12,7 @@ import '../../../../components/page/page_title.dart';
import '../../../../components/picker/date_range_picker.dart';
import '../../../../components/spaces/space.dart';
import '../../../../router/app_router.gr.dart';
import 'sections/report_dashboard_section.dart';
import 'widgets/report_menu_card.dart';
@RoutePage()
@ -27,6 +29,7 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
return Column(
children: [
PageTitle(
isBack: false,
title: 'Report',
subtitle: state.rangeDateFormatted,
actionWidget: [
@ -51,6 +54,11 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
),
SpaceWidth(8),
AppIconButton(icons: Icons.download_outlined, onTap: () {}),
SpaceWidth(8),
AppIconButton(
icons: Icons.refresh_outlined,
onTap: () => onFetched(context, state),
),
],
),
Expanded(
@ -81,6 +89,8 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
title: reportMenus[index].title,
),
);
onFetched(context, state);
}
},
),
@ -94,7 +104,20 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
child: Material(
color: AppColor.background,
child: switch (state.selectedMenu) {
0 => Text(state.title),
0 =>
BlocBuilder<
DashboardAnalyticLoaderBloc,
DashboardAnalyticLoaderState
>(
builder: (context, dashboard) {
return ReportDashboardSection(
menu: reportMenus[state.selectedMenu],
state: dashboard,
startDate: state.startDate,
endDate: state.endDate,
);
},
),
1 => Text(state.title),
2 => Text(state.title),
3 => Text(state.title),
@ -117,7 +140,41 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
);
}
void onFetched(BuildContext context, ReportState state) {
switch (state.selectedMenu) {
case 0:
return context.read<DashboardAnalyticLoaderBloc>().add(
DashboardAnalyticLoaderEvent.fetched(
startDate: state.startDate,
endDate: state.endDate,
),
);
case 1:
case 2:
case 3:
case 4:
case 5:
case 6:
case 7:
default:
return;
}
}
@override
Widget wrappedRoute(BuildContext context) =>
BlocProvider(create: (context) => getIt<ReportBloc>(), child: this);
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(create: (context) => getIt<ReportBloc>()),
BlocProvider(
create: (context) => getIt<DashboardAnalyticLoaderBloc>()
..add(
DashboardAnalyticLoaderEvent.fetched(
startDate: DateTime.now().subtract(const Duration(days: 30)),
endDate: DateTime.now(),
),
),
),
],
child: this,
);
}

View File

@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../../../../common/data/report_menu.dart';
import '../../../../../../common/extension/extension.dart';
import '../../../../../../common/theme/theme.dart';
import '../../../../../components/error/analytic_error_state_widget.dart';
import '../../../../../components/loader/loader_with_text.dart';
import '../../../../../components/spaces/space.dart';
import '../../../../../components/widgets/report/report_header.dart';
import '../../../../../components/widgets/report/report_summary_card.dart';
import '../widgets/dashboard/report_dashboard_order.dart';
import '../widgets/dashboard/report_dashboard_product_chart.dart';
import '../widgets/dashboard/report_dashboard_sales_chart.dart';
import '../widgets/dashboard/report_dashboard_top_product.dart';
class ReportDashboardSection extends StatelessWidget {
final ReportMenu menu;
final DashboardAnalyticLoaderState state;
final DateTime startDate;
final DateTime endDate;
const ReportDashboardSection({
super.key,
required this.menu,
required this.state,
required this.startDate,
required this.endDate,
});
@override
Widget build(BuildContext context) {
if (state.isFetching) {
return const Center(child: LoaderWithText());
}
return state.failureOption.fold(
() => RefreshIndicator(
backgroundColor: AppColor.white,
color: AppColor.primary,
onRefresh: () {
context.read<DashboardAnalyticLoaderBloc>().add(
DashboardAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
);
return Future.value();
},
child: ListView(
padding: const EdgeInsets.all(16),
children: [
ReportHeader(menu: menu, startDate: startDate, endDate: endDate),
_buildSummary(),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: ReportDashboardSalesChart(
sales: List.of(state.dashboardAnalytic.recentSales)
..sort(
(a, b) => DateTime.parse(
a.date,
).compareTo(DateTime.parse(b.date)),
),
),
),
SpaceWidth(12),
Expanded(
flex: 1,
child: ReportDashboardProductChart(
data: state.dashboardAnalytic.topProducts,
),
),
],
),
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: ReportDashboardTopProduct(
data: state.dashboardAnalytic.topProducts,
),
),
SpaceWidth(12),
Expanded(
flex: 1,
child: ReportDashboardOrder(data: state.dashboardAnalytic),
),
],
),
),
],
),
),
(f) => AnalyticErrorStateWidget(
failure: f,
menu: menu,
onRefresh: () => context.read<DashboardAnalyticLoaderBloc>().add(
DashboardAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
),
),
);
}
Padding _buildSummary() {
final successfulOrders =
state.dashboardAnalytic.overview.totalOrders -
state.dashboardAnalytic.overview.voidedOrders -
state.dashboardAnalytic.overview.refundedOrders;
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.success,
icon: Icons.trending_up,
title: 'Total Penjualan',
value: state
.dashboardAnalytic
.overview
.totalSales
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.info,
icon: Icons.shopping_cart_outlined,
title: 'Total Pesanan',
value: state.dashboardAnalytic.overview.totalOrders
.toString(),
),
),
],
),
SpaceHeight(12),
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.warning,
icon: Icons.attach_money,
title: 'Rata-rata Pesanan',
value: state
.dashboardAnalytic
.overview
.averageOrderValue
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.primary,
icon: Icons.check_circle_outline,
title: 'Pesanan Sukses',
value: successfulOrders.toString(),
),
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,138 @@
import 'package:flutter/material.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportDashboardOrder extends StatelessWidget {
final DashboardAnalytic data;
const ReportDashboardOrder({super.key, required this.data});
@override
Widget build(BuildContext context) {
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),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Ringkasan Pesanan',
style: AppStyle.xl.copyWith(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(20),
_buildSummaryItem(
'Total Pesanan',
'${data.overview.totalOrders}',
Icons.shopping_cart,
AppColor.info,
),
_buildSummaryItem(
'Pesanan Sukses',
'$successfulOrders',
Icons.check_circle,
AppColor.success,
),
_buildSummaryItem(
'Pesanan Dibatalkan',
'${data.overview.voidedOrders}',
Icons.cancel,
AppColor.error,
),
_buildSummaryItem(
'Pesanan Refund',
'${data.overview.refundedOrders}',
Icons.refresh,
AppColor.warning,
),
const SpaceHeight(20),
// Payment Methods
if (data.paymentMethods.isNotEmpty)
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
'Metode Pembayaran',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.textPrimary,
),
),
const SpaceHeight(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: AppColor.primary,
size: 16,
),
const SpaceWidth(8),
Text(
method.paymentMethodName,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
],
),
),
),
],
),
),
],
),
);
}
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: AppStyle.md.copyWith(fontWeight: FontWeight.w600)),
],
),
);
}
}

View File

@ -0,0 +1,94 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportDashboardProductChart extends StatelessWidget {
final List<DashboardTopProduct> data;
const ReportDashboardProductChart({super.key, required this.data});
@override
Widget build(BuildContext context) {
final colors = [
AppColor.primary,
AppColor.secondary,
AppColor.info,
AppColor.warning,
AppColor.success,
];
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distribusi Produk',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(12),
SizedBox(
height: 160,
child: PieChart(
PieChartData(
sectionsSpace: 2,
centerSpaceRadius: 40,
sections: data.asMap().entries.map((entry) {
return PieChartSectionData(
color: colors[entry.key % colors.length],
value: entry.value.quantitySold.toDouble(),
title: '${entry.value.quantitySold}',
radius: 40,
titleStyle: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}).toList(),
),
),
),
SpaceHeight(16),
Column(
children: data.take(3).map((product) {
final index = data.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: AppStyle.sm),
),
Text(
'${product.quantitySold}',
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w500),
),
],
),
);
}).toList(),
),
],
),
);
}
}

View File

@ -0,0 +1,170 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportDashboardSalesChart extends StatelessWidget {
final List<DashboardRecentSale> sales;
const ReportDashboardSalesChart({super.key, required this.sales});
@override
Widget build(BuildContext context) {
if (sales.isEmpty) {
return const Center(child: Text('Tidak ada data penjualan'));
}
// Sort agar tanggal berurutan
final sortedSales = List.of(
sales,
)..sort((a, b) => DateTime.parse(a.date).compareTo(DateTime.parse(b.date)));
// Convert ke FlSpot berdasarkan tanggal
final spots = sortedSales.map((sale) {
final date = DateTime.parse(sale.date);
return FlSpot(
date.millisecondsSinceEpoch.toDouble(),
sale.sales.toDouble(),
);
}).toList();
// Batas min & max untuk X axis
final minX = spots.first.x;
final maxX = spots.last.x;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tren Penjualan Harian',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(12),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
minX: minX,
maxX: maxX,
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
fitInsideHorizontally: true,
fitInsideVertically: true,
getTooltipColor: (touchedSpot) => AppColor.primary,
getTooltipItems: (touchedSpots) {
return touchedSpots.map((spot) {
final date = DateTime.fromMillisecondsSinceEpoch(
spot.x.toInt(),
);
final sale = spot.y.toInt();
return LineTooltipItem(
'${date.toFormattedDate()}\n ${sale.currencyFormatRpV2}',
AppStyle.xs.copyWith(
color: Colors.white,
fontWeight: FontWeight.bold,
),
);
}).toList();
},
),
),
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: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10,
),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval:
(maxX - minX) /
(sortedSales.length > 6 ? 6 : sortedSales.length),
getTitlesWidget: (value, meta) {
final date = DateTime.fromMillisecondsSinceEpoch(
value.toInt(),
);
final formatter = DateFormat('dd MMM');
// tampilkan hanya jika tanggal cocok dengan titik data sebenarnya
final match = sortedSales.any((s) {
final d = DateTime.parse(s.date);
return d.difference(date).inDays.abs() < 1;
});
if (!match) return const SizedBox();
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
formatter.format(date),
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10,
),
),
);
},
),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: AppColor.primary,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: AppColor.primary.withOpacity(0.1),
),
),
],
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportDashboardTopProduct extends StatelessWidget {
final List<DashboardTopProduct> data;
const ReportDashboardTopProduct({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Produk Terlaris',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(16),
Column(
children: data.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: AppColor.border),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
),
),
SpaceHeight(2),
Text(
product.categoryName,
style: AppStyle.sm.copyWith(
fontSize: 12,
color: AppColor.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${product.quantitySold} unit',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
),
),
const SpaceHeight(2),
Text(
product.revenue.currencyFormatRpV2,
style: AppStyle.sm.copyWith(
fontSize: 12,
color: AppColor.textSecondary,
),
),
],
),
],
),
);
}).toList(),
),
],
),
);
}
}

View File

@ -353,6 +353,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"
fake_async:
dependency: transitive
description:
@ -425,6 +433,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.1"
fl_chart:
dependency: "direct main"
description:
name: fl_chart
sha256: "7ca9a40f4eb85949190e54087be8b4d6ac09dc4c54238d782a34cf1f7c011de9"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter

View File

@ -38,6 +38,7 @@ dependencies:
shimmer: ^3.0.0
dropdown_search: ^5.0.6
syncfusion_flutter_datepicker: ^31.2.3
fl_chart: ^1.1.1
dev_dependencies:
flutter_test: