report profit loss

This commit is contained in:
efrilm 2025-11-03 21:28:36 +07:00
parent 5a7892aa99
commit 07932d688f
23 changed files with 5188 additions and 1 deletions

View File

@ -0,0 +1,52 @@
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 'profit_loss_analytic_loader_event.dart';
part 'profit_loss_analytic_loader_state.dart';
part 'profit_loss_analytic_loader_bloc.freezed.dart';
@injectable
class ProfitLossAnalyticLoaderBloc
extends Bloc<ProfitLossAnalyticLoaderEvent, ProfitLossAnalyticLoaderState> {
final IAnalyticRepository _analyticRepository;
ProfitLossAnalyticLoaderBloc(this._analyticRepository)
: super(ProfitLossAnalyticLoaderState.initial()) {
on<ProfitLossAnalyticLoaderEvent>(_onProfitLossAnalyticLoaderEvent);
}
Future<void> _onProfitLossAnalyticLoaderEvent(
ProfitLossAnalyticLoaderEvent event,
Emitter<ProfitLossAnalyticLoaderState> emit,
) {
return event.map(
fetched: (e) async {
emit(state.copyWith(isFetching: true, failureOption: none()));
final result = await _analyticRepository.getProfitLoss(
dateFrom: e.startDate,
dateTo: e.endDate,
);
await result.fold(
(failure) async {
emit(
state.copyWith(
isFetching: false,
failureOption: optionOf(failure),
),
);
},
(profitLoss) async {
emit(
state.copyWith(isFetching: false, profitLossAnalytic: profitLoss),
);
},
);
},
);
}
}

View File

@ -0,0 +1,483 @@
// 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 'profit_loss_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 _$ProfitLossAnalyticLoaderEvent {
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 ProfitLossAnalyticLoaderEvent
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ProfitLossAnalyticLoaderEventCopyWith<ProfitLossAnalyticLoaderEvent>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ProfitLossAnalyticLoaderEventCopyWith<$Res> {
factory $ProfitLossAnalyticLoaderEventCopyWith(
ProfitLossAnalyticLoaderEvent value,
$Res Function(ProfitLossAnalyticLoaderEvent) then,
) =
_$ProfitLossAnalyticLoaderEventCopyWithImpl<
$Res,
ProfitLossAnalyticLoaderEvent
>;
@useResult
$Res call({DateTime startDate, DateTime endDate});
}
/// @nodoc
class _$ProfitLossAnalyticLoaderEventCopyWithImpl<
$Res,
$Val extends ProfitLossAnalyticLoaderEvent
>
implements $ProfitLossAnalyticLoaderEventCopyWith<$Res> {
_$ProfitLossAnalyticLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ProfitLossAnalyticLoaderEvent
/// 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 $ProfitLossAnalyticLoaderEventCopyWith<$Res> {
factory _$$FetchedImplCopyWith(
_$FetchedImpl value,
$Res Function(_$FetchedImpl) then,
) = __$$FetchedImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({DateTime startDate, DateTime endDate});
}
/// @nodoc
class __$$FetchedImplCopyWithImpl<$Res>
extends _$ProfitLossAnalyticLoaderEventCopyWithImpl<$Res, _$FetchedImpl>
implements _$$FetchedImplCopyWith<$Res> {
__$$FetchedImplCopyWithImpl(
_$FetchedImpl _value,
$Res Function(_$FetchedImpl) _then,
) : super(_value, _then);
/// Create a copy of ProfitLossAnalyticLoaderEvent
/// 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 'ProfitLossAnalyticLoaderEvent.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 ProfitLossAnalyticLoaderEvent
/// 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 ProfitLossAnalyticLoaderEvent {
const factory _Fetched({
required final DateTime startDate,
required final DateTime endDate,
}) = _$FetchedImpl;
@override
DateTime get startDate;
@override
DateTime get endDate;
/// Create a copy of ProfitLossAnalyticLoaderEvent
/// 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 _$ProfitLossAnalyticLoaderState {
ProfitLossAnalytic get profitLossAnalytic =>
throw _privateConstructorUsedError;
Option<AnalyticFailure> get failureOption =>
throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
/// Create a copy of ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ProfitLossAnalyticLoaderStateCopyWith<ProfitLossAnalyticLoaderState>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ProfitLossAnalyticLoaderStateCopyWith<$Res> {
factory $ProfitLossAnalyticLoaderStateCopyWith(
ProfitLossAnalyticLoaderState value,
$Res Function(ProfitLossAnalyticLoaderState) then,
) =
_$ProfitLossAnalyticLoaderStateCopyWithImpl<
$Res,
ProfitLossAnalyticLoaderState
>;
@useResult
$Res call({
ProfitLossAnalytic profitLossAnalytic,
Option<AnalyticFailure> failureOption,
bool isFetching,
});
$ProfitLossAnalyticCopyWith<$Res> get profitLossAnalytic;
}
/// @nodoc
class _$ProfitLossAnalyticLoaderStateCopyWithImpl<
$Res,
$Val extends ProfitLossAnalyticLoaderState
>
implements $ProfitLossAnalyticLoaderStateCopyWith<$Res> {
_$ProfitLossAnalyticLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? profitLossAnalytic = null,
Object? failureOption = null,
Object? isFetching = null,
}) {
return _then(
_value.copyWith(
profitLossAnalytic: null == profitLossAnalytic
? _value.profitLossAnalytic
: profitLossAnalytic // ignore: cast_nullable_to_non_nullable
as ProfitLossAnalytic,
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 ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProfitLossAnalyticCopyWith<$Res> get profitLossAnalytic {
return $ProfitLossAnalyticCopyWith<$Res>(_value.profitLossAnalytic, (
value,
) {
return _then(_value.copyWith(profitLossAnalytic: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$ProfitLossAnalyticLoaderStateImplCopyWith<$Res>
implements $ProfitLossAnalyticLoaderStateCopyWith<$Res> {
factory _$$ProfitLossAnalyticLoaderStateImplCopyWith(
_$ProfitLossAnalyticLoaderStateImpl value,
$Res Function(_$ProfitLossAnalyticLoaderStateImpl) then,
) = __$$ProfitLossAnalyticLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
ProfitLossAnalytic profitLossAnalytic,
Option<AnalyticFailure> failureOption,
bool isFetching,
});
@override
$ProfitLossAnalyticCopyWith<$Res> get profitLossAnalytic;
}
/// @nodoc
class __$$ProfitLossAnalyticLoaderStateImplCopyWithImpl<$Res>
extends
_$ProfitLossAnalyticLoaderStateCopyWithImpl<
$Res,
_$ProfitLossAnalyticLoaderStateImpl
>
implements _$$ProfitLossAnalyticLoaderStateImplCopyWith<$Res> {
__$$ProfitLossAnalyticLoaderStateImplCopyWithImpl(
_$ProfitLossAnalyticLoaderStateImpl _value,
$Res Function(_$ProfitLossAnalyticLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? profitLossAnalytic = null,
Object? failureOption = null,
Object? isFetching = null,
}) {
return _then(
_$ProfitLossAnalyticLoaderStateImpl(
profitLossAnalytic: null == profitLossAnalytic
? _value.profitLossAnalytic
: profitLossAnalytic // ignore: cast_nullable_to_non_nullable
as ProfitLossAnalytic,
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 _$ProfitLossAnalyticLoaderStateImpl
implements _ProfitLossAnalyticLoaderState {
_$ProfitLossAnalyticLoaderStateImpl({
required this.profitLossAnalytic,
required this.failureOption,
this.isFetching = false,
});
@override
final ProfitLossAnalytic profitLossAnalytic;
@override
final Option<AnalyticFailure> failureOption;
@override
@JsonKey()
final bool isFetching;
@override
String toString() {
return 'ProfitLossAnalyticLoaderState(profitLossAnalytic: $profitLossAnalytic, failureOption: $failureOption, isFetching: $isFetching)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ProfitLossAnalyticLoaderStateImpl &&
(identical(other.profitLossAnalytic, profitLossAnalytic) ||
other.profitLossAnalytic == profitLossAnalytic) &&
(identical(other.failureOption, failureOption) ||
other.failureOption == failureOption) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching));
}
@override
int get hashCode =>
Object.hash(runtimeType, profitLossAnalytic, failureOption, isFetching);
/// Create a copy of ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ProfitLossAnalyticLoaderStateImplCopyWith<
_$ProfitLossAnalyticLoaderStateImpl
>
get copyWith =>
__$$ProfitLossAnalyticLoaderStateImplCopyWithImpl<
_$ProfitLossAnalyticLoaderStateImpl
>(this, _$identity);
}
abstract class _ProfitLossAnalyticLoaderState
implements ProfitLossAnalyticLoaderState {
factory _ProfitLossAnalyticLoaderState({
required final ProfitLossAnalytic profitLossAnalytic,
required final Option<AnalyticFailure> failureOption,
final bool isFetching,
}) = _$ProfitLossAnalyticLoaderStateImpl;
@override
ProfitLossAnalytic get profitLossAnalytic;
@override
Option<AnalyticFailure> get failureOption;
@override
bool get isFetching;
/// Create a copy of ProfitLossAnalyticLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ProfitLossAnalyticLoaderStateImplCopyWith<
_$ProfitLossAnalyticLoaderStateImpl
>
get copyWith => throw _privateConstructorUsedError;
}

View File

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

View File

@ -0,0 +1,16 @@
part of 'profit_loss_analytic_loader_bloc.dart';
@freezed
class ProfitLossAnalyticLoaderState with _$ProfitLossAnalyticLoaderState {
factory ProfitLossAnalyticLoaderState({
required ProfitLossAnalytic profitLossAnalytic,
required Option<AnalyticFailure> failureOption,
@Default(false) bool isFetching,
}) = _ProfitLossAnalyticLoaderState;
factory ProfitLossAnalyticLoaderState.initial() =>
ProfitLossAnalyticLoaderState(
profitLossAnalytic: ProfitLossAnalytic.empty(),
failureOption: none(),
);
}

View File

@ -44,3 +44,13 @@ Map<String, int> getChairDistribution(int capacity) {
return {'top': side, 'bottom': side, 'left': 1, 'right': 1};
}
}
double safeDouble(double value) {
if (value.isNaN || value.isInfinite) return 0.0;
return value;
}
int safeRound(double value) {
if (value.isNaN || value.isInfinite) return 0;
return value.round();
}

View File

@ -13,4 +13,5 @@ class ApiPath {
static const String analyticProducts = '/api/v1/analytics/products';
static const String analyticPaymentMethods =
'/api/v1/analytics/payment-methods';
static const String analyticProfitLoss = '/api/v1/analytics/profit-loss';
}

View File

@ -9,5 +9,6 @@ part 'entities/dashboard_entity.dart';
part 'entities/sales_entity.dart';
part 'entities/product_analytic_entity.dart';
part 'entities/payment_method_analytic_entity.dart';
part 'entities/profit_loss_analytic_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,117 @@
part of '../analytic.dart';
@freezed
class ProfitLossAnalytic with _$ProfitLossAnalytic {
const factory ProfitLossAnalytic({
required String organizationId,
required String dateFrom,
required String dateTo,
required String groupBy,
required ProfitLossAnalyticSummary summary,
required List<ProfitLossAnalyticItem> data,
required List<ProfitLossAnalyticProduct> productData,
}) = _ProfitLossAnalytic;
factory ProfitLossAnalytic.empty() => ProfitLossAnalytic(
organizationId: '',
dateFrom: '',
dateTo: '',
groupBy: 'day',
summary: ProfitLossAnalyticSummary.empty(),
data: const [],
productData: const [],
);
}
@freezed
class ProfitLossAnalyticSummary with _$ProfitLossAnalyticSummary {
const factory ProfitLossAnalyticSummary({
required int totalRevenue,
required num totalCost,
required num grossProfit,
required double grossProfitMargin,
required int totalTax,
required int totalDiscount,
required num netProfit,
required double netProfitMargin,
required int totalOrders,
required double averageProfit,
required double profitabilityRatio,
}) = _ProfitLossAnalyticSummary;
factory ProfitLossAnalyticSummary.empty() => const ProfitLossAnalyticSummary(
totalRevenue: 0,
totalCost: 0,
grossProfit: 0,
grossProfitMargin: 0.0,
totalTax: 0,
totalDiscount: 0,
netProfit: 0,
netProfitMargin: 0.0,
totalOrders: 0,
averageProfit: 0.0,
profitabilityRatio: 0.0,
);
}
@freezed
class ProfitLossAnalyticItem with _$ProfitLossAnalyticItem {
const factory ProfitLossAnalyticItem({
required String date,
required int revenue,
required num cost,
required num grossProfit,
required double grossProfitMargin,
required int tax,
required int discount,
required num netProfit,
required double netProfitMargin,
required int orders,
}) = _ProfitLossAnalyticItem;
factory ProfitLossAnalyticItem.empty() => const ProfitLossAnalyticItem(
date: '',
revenue: 0,
cost: 0,
grossProfit: 0,
grossProfitMargin: 0.0,
tax: 0,
discount: 0,
netProfit: 0,
netProfitMargin: 0.0,
orders: 0,
);
}
@freezed
class ProfitLossAnalyticProduct with _$ProfitLossAnalyticProduct {
const factory ProfitLossAnalyticProduct({
required String productId,
required String productName,
required String categoryId,
required String categoryName,
required int quantitySold,
required int revenue,
required num cost,
required num grossProfit,
required double grossProfitMargin,
required int averagePrice,
required num averageCost,
required num profitPerUnit,
}) = _ProfitLossAnalyticProduct;
factory ProfitLossAnalyticProduct.empty() => const ProfitLossAnalyticProduct(
productId: '',
productName: '',
categoryId: '',
categoryName: '',
quantitySold: 0,
revenue: 0,
cost: 0,
grossProfit: 0,
grossProfitMargin: 0.0,
averagePrice: 0,
averageCost: 0,
profitPerUnit: 0,
);
}

View File

@ -17,4 +17,8 @@ abstract class IAnalyticRepository {
required DateTime dateFrom,
required DateTime dateTo,
});
Future<Either<AnalyticFailure, ProfitLossAnalytic>> getProfitLoss({
required DateTime dateFrom,
required DateTime dateTo,
});
}

View File

@ -9,3 +9,4 @@ part 'dtos/dashboard_dto.dart';
part 'dtos/sales_dto.dart';
part 'dtos/product_analytic_dto.dart';
part 'dtos/payment_method_analytic_dto.dart';
part 'dtos/profit_loss_analytic_dto.dart';

File diff suppressed because it is too large Load Diff

View File

@ -347,3 +347,135 @@ Map<String, dynamic> _$$PaymentMethodAnalyticSummaryDtoImplToJson(
'total_payments': instance.totalPayments,
'average_order_value': instance.averageOrderValue,
};
_$ProfitLossAnalyticDtoImpl _$$ProfitLossAnalyticDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossAnalyticDtoImpl(
organizationId: json['organization_id'] as String?,
dateFrom: json['date_from'] as String?,
dateTo: json['date_to'] as String?,
groupBy: json['group_by'] as String?,
summary: json['summary'] == null
? null
: ProfitLossAnalyticSummaryDto.fromJson(
json['summary'] as Map<String, dynamic>,
),
data: (json['data'] as List<dynamic>?)
?.map(
(e) => ProfitLossAnalyticItemDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
productData: (json['product_data'] as List<dynamic>?)
?.map(
(e) => ProfitLossAnalyticProductDto.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
Map<String, dynamic> _$$ProfitLossAnalyticDtoImplToJson(
_$ProfitLossAnalyticDtoImpl instance,
) => <String, dynamic>{
'organization_id': instance.organizationId,
'date_from': instance.dateFrom,
'date_to': instance.dateTo,
'group_by': instance.groupBy,
'summary': instance.summary,
'data': instance.data,
'product_data': instance.productData,
};
_$ProfitLossAnalyticSummaryDtoImpl _$$ProfitLossAnalyticSummaryDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossAnalyticSummaryDtoImpl(
totalRevenue: (json['total_revenue'] as num?)?.toInt(),
totalCost: json['total_cost'] as num?,
grossProfit: json['gross_profit'] as num?,
grossProfitMargin: (json['gross_profit_margin'] as num?)?.toDouble(),
totalTax: (json['total_tax'] as num?)?.toInt(),
totalDiscount: (json['total_discount'] as num?)?.toInt(),
netProfit: json['net_profit'] as num?,
netProfitMargin: (json['net_profit_margin'] as num?)?.toDouble(),
totalOrders: (json['total_orders'] as num?)?.toInt(),
averageProfit: (json['average_profit'] as num?)?.toDouble(),
profitabilityRatio: (json['profitability_ratio'] as num?)?.toDouble(),
);
Map<String, dynamic> _$$ProfitLossAnalyticSummaryDtoImplToJson(
_$ProfitLossAnalyticSummaryDtoImpl instance,
) => <String, dynamic>{
'total_revenue': instance.totalRevenue,
'total_cost': instance.totalCost,
'gross_profit': instance.grossProfit,
'gross_profit_margin': instance.grossProfitMargin,
'total_tax': instance.totalTax,
'total_discount': instance.totalDiscount,
'net_profit': instance.netProfit,
'net_profit_margin': instance.netProfitMargin,
'total_orders': instance.totalOrders,
'average_profit': instance.averageProfit,
'profitability_ratio': instance.profitabilityRatio,
};
_$ProfitLossAnalyticItemDtoImpl _$$ProfitLossAnalyticItemDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossAnalyticItemDtoImpl(
date: json['date'] as String?,
revenue: (json['revenue'] as num?)?.toInt(),
cost: json['cost'] as num?,
grossProfit: json['gross_profit'] as num?,
grossProfitMargin: (json['gross_profit_margin'] as num?)?.toDouble(),
tax: (json['tax'] as num?)?.toInt(),
discount: (json['discount'] as num?)?.toInt(),
netProfit: json['net_profit'] as num?,
netProfitMargin: (json['net_profit_margin'] as num?)?.toDouble(),
orders: (json['orders'] as num?)?.toInt(),
);
Map<String, dynamic> _$$ProfitLossAnalyticItemDtoImplToJson(
_$ProfitLossAnalyticItemDtoImpl instance,
) => <String, dynamic>{
'date': instance.date,
'revenue': instance.revenue,
'cost': instance.cost,
'gross_profit': instance.grossProfit,
'gross_profit_margin': instance.grossProfitMargin,
'tax': instance.tax,
'discount': instance.discount,
'net_profit': instance.netProfit,
'net_profit_margin': instance.netProfitMargin,
'orders': instance.orders,
};
_$ProfitLossAnalyticProductDtoImpl _$$ProfitLossAnalyticProductDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossAnalyticProductDtoImpl(
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(),
cost: json['cost'] as num?,
grossProfit: json['gross_profit'] as num?,
grossProfitMargin: (json['gross_profit_margin'] as num?)?.toDouble(),
averagePrice: (json['average_price'] as num?)?.toInt(),
averageCost: json['average_cost'] as num?,
profitPerUnit: json['profit_per_unit'] as num?,
);
Map<String, dynamic> _$$ProfitLossAnalyticProductDtoImplToJson(
_$ProfitLossAnalyticProductDtoImpl 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,
'cost': instance.cost,
'gross_profit': instance.grossProfit,
'gross_profit_margin': instance.grossProfitMargin,
'average_price': instance.averagePrice,
'average_cost': instance.averageCost,
'profit_per_unit': instance.profitPerUnit,
};

View File

@ -134,4 +134,33 @@ class AnalyticRemoteDataProvider {
return DC.error(AnalyticFailure.serverError(e));
}
}
Future<DC<AnalyticFailure, ProfitLossAnalyticDto>> fetchProfitLoss({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final response = await _apiClient.get(
ApiPath.analyticProfitLoss,
params: {
'date_from': dateFrom.toServerDate(),
'date_to': dateTo.toServerDate(),
},
headers: getAuthorizationHeader(),
);
if (response.data['success'] == false) {
return DC.error(AnalyticFailure.unexpectedError());
}
final profitLoss = ProfitLossAnalyticDto.fromJson(
response.data['data'] as Map<String, dynamic>,
);
return DC.data(profitLoss);
} on ApiFailure catch (e, s) {
log('fetchProfitLoss', name: _logName, error: e, stackTrace: s);
return DC.error(AnalyticFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,142 @@
part of '../analytic_dtos.dart';
@freezed
class ProfitLossAnalyticDto with _$ProfitLossAnalyticDto {
const ProfitLossAnalyticDto._();
const factory ProfitLossAnalyticDto({
@JsonKey(name: "organization_id") String? organizationId,
@JsonKey(name: "date_from") String? dateFrom,
@JsonKey(name: "date_to") String? dateTo,
@JsonKey(name: "group_by") String? groupBy,
@JsonKey(name: "summary") ProfitLossAnalyticSummaryDto? summary,
@JsonKey(name: "data") List<ProfitLossAnalyticItemDto>? data,
@JsonKey(name: "product_data")
List<ProfitLossAnalyticProductDto>? productData,
}) = _ProfitLossAnalyticDto;
factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossAnalyticDtoFromJson(json);
// Optional: mapper ke domain entity
ProfitLossAnalytic toDomain() => ProfitLossAnalytic(
organizationId: organizationId ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '',
groupBy: groupBy ?? 'day',
summary: summary?.toDomain() ?? ProfitLossAnalyticSummary.empty(),
data: data?.map((e) => e.toDomain()).toList() ?? [],
productData: productData?.map((e) => e.toDomain()).toList() ?? [],
);
}
@freezed
class ProfitLossAnalyticSummaryDto with _$ProfitLossAnalyticSummaryDto {
const ProfitLossAnalyticSummaryDto._();
const factory ProfitLossAnalyticSummaryDto({
@JsonKey(name: "total_revenue") int? totalRevenue,
@JsonKey(name: "total_cost") num? totalCost,
@JsonKey(name: "gross_profit") num? grossProfit,
@JsonKey(name: "gross_profit_margin") double? grossProfitMargin,
@JsonKey(name: "total_tax") int? totalTax,
@JsonKey(name: "total_discount") int? totalDiscount,
@JsonKey(name: "net_profit") num? netProfit,
@JsonKey(name: "net_profit_margin") double? netProfitMargin,
@JsonKey(name: "total_orders") int? totalOrders,
@JsonKey(name: "average_profit") double? averageProfit,
@JsonKey(name: "profitability_ratio") double? profitabilityRatio,
}) = _ProfitLossAnalyticSummaryDto;
factory ProfitLossAnalyticSummaryDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossAnalyticSummaryDtoFromJson(json);
// Optional mapper ke domain
ProfitLossAnalyticSummary toDomain() => ProfitLossAnalyticSummary(
totalRevenue: totalRevenue ?? 0,
totalCost: totalCost ?? 0,
grossProfit: grossProfit ?? 0,
grossProfitMargin: grossProfitMargin ?? 0.0,
totalTax: totalTax ?? 0,
totalDiscount: totalDiscount ?? 0,
netProfit: netProfit ?? 0,
netProfitMargin: netProfitMargin ?? 0.0,
totalOrders: totalOrders ?? 0,
averageProfit: averageProfit ?? 0.0,
profitabilityRatio: profitabilityRatio ?? 0.0,
);
}
@freezed
class ProfitLossAnalyticItemDto with _$ProfitLossAnalyticItemDto {
const ProfitLossAnalyticItemDto._();
const factory ProfitLossAnalyticItemDto({
@JsonKey(name: "date") String? date,
@JsonKey(name: "revenue") int? revenue,
@JsonKey(name: "cost") num? cost,
@JsonKey(name: "gross_profit") num? grossProfit,
@JsonKey(name: "gross_profit_margin") double? grossProfitMargin,
@JsonKey(name: "tax") int? tax,
@JsonKey(name: "discount") int? discount,
@JsonKey(name: "net_profit") num? netProfit,
@JsonKey(name: "net_profit_margin") double? netProfitMargin,
@JsonKey(name: "orders") int? orders,
}) = _ProfitLossAnalyticItemDto;
factory ProfitLossAnalyticItemDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossAnalyticItemDtoFromJson(json);
// Optional mapper ke domain
ProfitLossAnalyticItem toDomain() => ProfitLossAnalyticItem(
date: date ?? '',
revenue: revenue ?? 0,
cost: cost ?? 0,
grossProfit: grossProfit ?? 0,
grossProfitMargin: grossProfitMargin ?? 0.0,
tax: tax ?? 0,
discount: discount ?? 0,
netProfit: netProfit ?? 0,
netProfitMargin: netProfitMargin ?? 0.0,
orders: orders ?? 0,
);
}
@freezed
class ProfitLossAnalyticProductDto with _$ProfitLossAnalyticProductDto {
const ProfitLossAnalyticProductDto._();
const factory ProfitLossAnalyticProductDto({
@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: "cost") num? cost,
@JsonKey(name: "gross_profit") num? grossProfit,
@JsonKey(name: "gross_profit_margin") double? grossProfitMargin,
@JsonKey(name: "average_price") int? averagePrice,
@JsonKey(name: "average_cost") num? averageCost,
@JsonKey(name: "profit_per_unit") num? profitPerUnit,
}) = _ProfitLossAnalyticProductDto;
factory ProfitLossAnalyticProductDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossAnalyticProductDtoFromJson(json);
// Optional mapper ke domain
ProfitLossAnalyticProduct toDomain() => ProfitLossAnalyticProduct(
productId: productId ?? '',
productName: productName ?? '',
categoryId: categoryId ?? '',
categoryName: categoryName ?? '',
quantitySold: quantitySold ?? 0,
revenue: revenue ?? 0,
cost: cost ?? 0,
grossProfit: grossProfit ?? 0,
grossProfitMargin: grossProfitMargin ?? 0.0,
averagePrice: averagePrice ?? 0,
averageCost: averageCost ?? 0,
profitPerUnit: profitPerUnit ?? 0,
);
}

View File

@ -109,4 +109,28 @@ class AnalyticRepository implements IAnalyticRepository {
return left(const AnalyticFailure.unexpectedError());
}
}
@override
Future<Either<AnalyticFailure, ProfitLossAnalytic>> getProfitLoss({
required DateTime dateFrom,
required DateTime dateTo,
}) async {
try {
final result = await _dataProvider.fetchProfitLoss(
dateFrom: dateFrom,
dateTo: dateTo,
);
if (result.hasError) {
return left(result.error!);
}
final profitLoss = result.data!.toDomain();
return right(profitLoss);
} catch (e) {
log('getProfitLossError', name: _logName, error: e);
return left(const AnalyticFailure.unexpectedError());
}
}
}

View File

@ -15,6 +15,8 @@ import 'package:apskel_pos_flutter_v2/application/analytic/payment_method_analyt
as _i733;
import 'package:apskel_pos_flutter_v2/application/analytic/product_analytic_loader/product_analytic_loader_bloc.dart'
as _i268;
import 'package:apskel_pos_flutter_v2/application/analytic/profit_loss_analytic_loader/profit_loss_analytic_loader_bloc.dart'
as _i741;
import 'package:apskel_pos_flutter_v2/application/analytic/sales_analytic_loader/sales_analytic_loader_bloc.dart'
as _i413;
import 'package:apskel_pos_flutter_v2/application/auth/auth_bloc.dart' as _i343;
@ -310,6 +312,9 @@ extension GetItInjectableX on _i174.GetIt {
gh<_i346.IAnalyticRepository>(),
),
);
gh.factory<_i741.ProfitLossAnalyticLoaderBloc>(
() => _i741.ProfitLossAnalyticLoaderBloc(gh<_i346.IAnalyticRepository>()),
);
return this;
}
}

View File

@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../application/analytic/dashboard_analytic_loader/dashboard_analytic_loader_bloc.dart';
import '../../../../../application/analytic/payment_method_analytic_loader/payment_method_analytic_loader_bloc.dart';
import '../../../../../application/analytic/product_analytic_loader/product_analytic_loader_bloc.dart';
import '../../../../../application/analytic/profit_loss_analytic_loader/profit_loss_analytic_loader_bloc.dart';
import '../../../../../application/analytic/sales_analytic_loader/sales_analytic_loader_bloc.dart';
import '../../../../../application/report/report_bloc.dart';
import '../../../../../common/data/report_menu.dart';
@ -18,6 +19,7 @@ import '../../../../router/app_router.gr.dart';
import 'sections/report_dashboard_section.dart';
import 'sections/report_payment_method_section.dart';
import 'sections/report_product_section.dart';
import 'sections/report_profit_loss_section.dart';
import 'sections/report_sales_section.dart';
import 'widgets/report_menu_card.dart';
@ -167,7 +169,20 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
);
},
),
5 => Text(state.title),
5 =>
BlocBuilder<
ProfitLossAnalyticLoaderBloc,
ProfitLossAnalyticLoaderState
>(
builder: (context, profitLoss) {
return ReportProfitLossSection(
menu: reportMenus[state.selectedMenu],
state: profitLoss,
startDate: state.startDate,
endDate: state.endDate,
);
},
),
6 => Text(state.title),
7 => Text(state.title),
_ => Container(),
@ -218,6 +233,12 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
);
case 5:
return context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: state.startDate,
endDate: state.endDate,
),
);
case 6:
case 7:
default:
@ -265,6 +286,15 @@ class ReportPage extends StatelessWidget implements AutoRouteWrapper {
),
),
),
BlocProvider(
create: (context) => getIt<ProfitLossAnalyticLoaderBloc>()
..add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: DateTime.now().subtract(const Duration(days: 30)),
endDate: DateTime.now(),
),
),
),
],
child: this,
);

View File

@ -0,0 +1,157 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../../application/analytic/profit_loss_analytic_loader/profit_loss_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/profit_loss/report_profit_loss_breakdown.dart';
import '../widgets/profit_loss/report_profit_loss_metric.dart';
import '../widgets/profit_loss/report_profit_loss_product_profitability.dart';
import '../widgets/profit_loss/report_profit_loss_trend_chart.dart';
class ReportProfitLossSection extends StatelessWidget {
final ReportMenu menu;
final ProfitLossAnalyticLoaderState state;
final DateTime startDate;
final DateTime endDate;
const ReportProfitLossSection({
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(
onRefresh: () {
context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
);
return Future.value();
},
child: ListView(
padding: EdgeInsets.all(16),
children: [
ReportHeader(menu: menu, endDate: endDate, startDate: startDate),
_buildSummary(),
ReportProfitLossTrendChart(data: state.profitLossAnalytic.data),
Padding(
padding: const EdgeInsets.only(top: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 3,
child: ReportProfitLossProductProfitability(
data: state.profitLossAnalytic.productData,
),
),
SpaceWidth(12),
Expanded(
flex: 2,
child: ReportProfitLossBreakdown(
data: state.profitLossAnalytic,
),
),
],
),
),
ReportProfitLossMetric(data: state.profitLossAnalytic),
],
),
),
(f) => AnalyticErrorStateWidget(
failure: f,
menu: menu,
onRefresh: () {
context.read<ProfitLossAnalyticLoaderBloc>().add(
ProfitLossAnalyticLoaderEvent.fetched(
startDate: startDate,
endDate: endDate,
),
);
},
),
);
}
Padding _buildSummary() {
return Padding(
padding: const EdgeInsets.only(top: 16),
child: Column(
children: [
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.success,
icon: Icons.attach_money,
title: 'Jumlah Pendapatan',
value: state
.profitLossAnalytic
.summary
.totalRevenue
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.info,
icon: Icons.receipt_outlined,
title: 'Jumlah Biaya',
value: state.profitLossAnalytic.summary.totalCost
.toDouble()
.currencyFormatRpV2,
),
),
],
),
SpaceHeight(12),
Row(
children: [
Expanded(
child: ReportSummaryCard(
color: AppColor.warning,
icon: Icons.trending_up_outlined,
title: 'Laba Kotor',
value: state.profitLossAnalytic.summary.grossProfit
.toDouble()
.currencyFormatRpV2,
),
),
SpaceWidth(12),
Expanded(
child: ReportSummaryCard(
color: AppColor.primary,
icon: Icons.account_balance_outlined,
title: 'Laba Bersih',
value: state.profitLossAnalytic.summary.netProfit
.toString()
.currencyFormatRpV2,
),
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,185 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossBreakdown extends StatelessWidget {
final ProfitLossAnalytic data;
const ReportProfitLossBreakdown({super.key, required this.data});
@override
Widget build(BuildContext context) {
final breakdownData = [
{
'label': 'Laba Kotor',
'value': data.summary.grossProfit,
'color': AppColor.success,
},
{
'label': 'Pajak',
'value': data.summary.totalTax,
'color': AppColor.warning,
},
{
'label': 'Diskon',
'value': data.summary.totalDiscount,
'color': AppColor.info,
},
{
'label': 'Laba Bersih',
'value': data.summary.netProfit,
'color': AppColor.primary,
},
];
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Rincian Keuntungan',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
'Komponen pembentuk profit',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
SpaceHeight(20),
// Grafik Donut
SizedBox(
height: 160,
child: PieChart(
PieChartData(
sectionsSpace: 3,
centerSpaceRadius: 50,
startDegreeOffset: -90,
sections: breakdownData.asMap().entries.map((entry) {
final item = entry.value;
final grossProfit = data.summary.grossProfit;
final value = item['value'] as num;
// Handle division by zero
final percentage = grossProfit > 0
? (value / grossProfit * 100)
: 0.0;
return PieChartSectionData(
color: item['color'] as Color,
value: value.toDouble(),
title: '${safeRound(percentage)}%',
radius: 40,
titleStyle: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
}).toList(),
),
),
),
SpaceHeight(16),
// Legenda
Column(
children: breakdownData.map((item) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: item['color'] as Color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
item['label'] as String,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w500,
),
),
),
Text(
(item['value'] as num).toString().currencyFormatRpV2,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
],
),
);
}).toList(),
),
SpaceHeight(16),
// Rasio Profitabilitas
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
AppColor.primary.withOpacity(0.1),
AppColor.secondary.withOpacity(0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: [
Text(
'Rasio Profitabilitas',
style: AppStyle.sm.copyWith(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColor.textSecondary,
),
),
SpaceHeight(8),
Text(
'${safeRound(data.summary.profitabilityRatio)}%',
style: AppStyle.h5.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
SpaceHeight(4),
Text(
'Tingkat Pengembalian Pendapatan',
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.textSecondary,
),
),
],
),
),
],
),
);
}
}

View File

@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossMetric extends StatelessWidget {
final ProfitLossAnalytic data;
const ReportProfitLossMetric({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Metrik Kinerja Terperinci',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(16),
Row(
children: [
Expanded(
child: _buildMetricCard(
'Nilai Rata-rata Pesanan',
_safeCalculateAverageOrder().currencyFormatRpV2,
'Per transaksi',
Icons.shopping_cart_outlined,
AppColor.info,
),
),
SpaceWidth(12),
Expanded(
child: _buildMetricCard(
'Keuntungan Rata-rata',
"${data.summary.averageProfit.round()}%",
'Per pesanan',
Icons.trending_up,
AppColor.success,
),
),
SpaceWidth(12),
Expanded(
child: _buildMetricCard(
'Rasio Biaya',
'${_safeCalculateCostRatio()}%',
'Dari total pendapatan',
Icons.pie_chart,
AppColor.error,
),
),
],
),
SpaceHeight(16),
// Tabel rincian harian
Container(
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[100]!),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.05),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
'Tanggal',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Pendapatan',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Biaya',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Laba Bersih',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
'Margin',
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
),
],
),
),
...data.data.map((item) {
final date = DateTime.parse(item.date);
final dateStr = DateFormat('dd MMM').format(date);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: Colors.grey[100]!),
),
),
child: Row(
children: [
Expanded(
flex: 2,
child: Text(
dateStr,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
),
Expanded(
child: Text(
item.revenue.currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.info,
),
),
),
Expanded(
child: Text(
item.cost.toString().currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w500,
color: AppColor.error,
),
),
),
Expanded(
child: Text(
item.netProfit.toString().currencyFormatRpV2,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.success,
),
),
),
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 3,
),
decoration: BoxDecoration(
color: _getMarginColor(
item.netProfitMargin,
).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'${safeRound(item.netProfitMargin)}%',
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontSize: 10,
fontWeight: FontWeight.bold,
color: _getMarginColor(item.netProfitMargin),
),
),
),
),
],
),
);
}),
],
),
),
],
),
);
}
Widget _buildMetricCard(
String title,
String value,
String subtitle,
IconData icon,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.06),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
children: [
Icon(icon, color: color, size: 24),
SpaceHeight(8),
Text(
value,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
SpaceHeight(4),
Text(
title,
textAlign: TextAlign.center,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: AppColor.textPrimary,
),
),
SpaceHeight(2),
Text(
subtitle,
textAlign: TextAlign.center,
style: AppStyle.xs.copyWith(
fontSize: 10,
color: AppColor.textSecondary,
),
),
],
),
);
}
int _safeCalculateAverageOrder() {
if (data.summary.totalOrders == 0) return 0;
final average = data.summary.totalRevenue / data.summary.totalOrders;
if (average.isNaN || average.isInfinite) return 0;
return average.round();
}
int _safeCalculateCostRatio() {
if (data.summary.totalRevenue == 0) return 0;
final ratio = (data.summary.totalCost / data.summary.totalRevenue) * 100;
if (ratio.isNaN || ratio.isInfinite) return 0;
return ratio.round();
}
Color _getMarginColor(double margin) {
final safeMargin = safeDouble(margin);
if (safeMargin >= 25) return AppColor.success;
if (safeMargin >= 15) return AppColor.warning;
return AppColor.error;
}
}

View File

@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import '../../../../../../../common/extension/extension.dart';
import '../../../../../../../common/function/app_function.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
import '../../../../../../components/spaces/space.dart';
class ReportProfitLossProductProfitability extends StatelessWidget {
final List<ProfitLossAnalyticProduct> data;
const ReportProfitLossProductProfitability({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Profitabilitas Produk',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
SpaceHeight(4),
Text(
'Analisis margin keuntungan per produk',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
SpaceHeight(12),
Column(
children: data.take(4).map((product) {
return _buildProductItem(product);
}).toList(),
),
],
),
);
}
Widget _buildProductItem(ProfitLossAnalyticProduct product) {
final profitMargin = safeDouble(product.grossProfitMargin);
final profitColor = profitMargin >= 35
? AppColor.success
: profitMargin >= 25
? AppColor.warning
: AppColor.error;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[100]!),
),
child: Column(
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
color: Color(0xFF0F172A),
),
),
SpaceHeight(2),
Text(
'${product.quantitySold} unit • ${product.categoryName}',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: profitColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(
'${safeRound(profitMargin)}%',
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.bold,
color: profitColor,
),
),
),
SpaceHeight(4),
Text(
product.grossProfit.toString().currencyFormatRpV2,
style: AppStyle.md.copyWith(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColor.primary,
),
),
],
),
],
),
SpaceHeight(12),
Row(
children: [
Expanded(
child: _buildProductMetric(
'Pendapatan',
product.revenue,
AppColor.info,
),
),
SpaceWidth(8),
Expanded(
child: _buildProductMetric(
'Biaya',
product.cost,
AppColor.error,
),
),
SpaceWidth(8),
Expanded(
child: _buildProductMetric(
'Laba/Unit',
product.profitPerUnit,
AppColor.success,
),
),
],
),
],
),
);
}
Widget _buildProductMetric(String label, num value, Color color) {
return Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withOpacity(0.08),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.grey[600],
),
),
SpaceHeight(2),
Text(
value.toString().currencyFormatRpV2,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.bold,
color: color,
),
),
],
),
);
}
}

View File

@ -0,0 +1,185 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../../../../../../common/theme/theme.dart';
import '../../../../../../../domain/analytic/analytic.dart';
class ReportProfitLossTrendChart extends StatelessWidget {
final List<ProfitLossAnalyticItem> data;
const ReportProfitLossTrendChart({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
margin: EdgeInsets.only(top: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Analisis Tren Keuntungan',
style: AppStyle.xl.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.textPrimary,
),
),
Row(
children: [
_buildLegendItem('Pendapatan', AppColor.info),
const SizedBox(width: 16),
_buildLegendItem('Biaya', AppColor.error),
const SizedBox(width: 16),
_buildLegendItem('Laba Bersih', AppColor.success),
],
),
],
),
const SizedBox(height: 20),
SizedBox(
height: 220,
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawHorizontalLine: true,
drawVerticalLine: false,
horizontalInterval: 100000,
getDrawingHorizontalLine: (value) {
return FlLine(color: Colors.grey[100]!, strokeWidth: 1);
},
),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 50,
getTitlesWidget: (value, meta) {
final kValue = (value / 1000);
if (kValue.isFinite) {
return Text(
'${kValue.toInt()}K',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10,
),
);
}
return const SizedBox();
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
final index = value.toInt();
if (index >= 0 && index < data.length) {
final date = DateTime.parse(data[index].date);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
'${date.day}/${date.month}',
style: AppStyle.xs.copyWith(
color: AppColor.textSecondary,
fontSize: 10,
),
),
);
}
return const SizedBox();
},
),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
lineBarsData: [
// Garis Pendapatan
LineChartBarData(
spots: data.asMap().entries.map((entry) {
final revenue = entry.value.revenue.toDouble();
return FlSpot(
entry.key.toDouble(),
revenue.isFinite ? revenue : 0,
);
}).toList(),
isCurved: true,
color: AppColor.info,
dotData: const FlDotData(show: false),
),
// Garis Biaya
LineChartBarData(
spots: data.asMap().entries.map((entry) {
final cost = entry.value.cost.toDouble();
return FlSpot(
entry.key.toDouble(),
cost.isFinite ? cost : 0,
);
}).toList(),
isCurved: true,
color: AppColor.error,
dotData: const FlDotData(show: false),
),
// Garis Laba Bersih
LineChartBarData(
spots: data.asMap().entries.map((entry) {
final netProfit = entry.value.netProfit.toDouble();
return FlSpot(
entry.key.toDouble(),
netProfit.isFinite ? netProfit : 0,
);
}).toList(),
isCurved: true,
color: AppColor.success,
dotData: const FlDotData(show: true),
belowBarData: BarAreaData(
show: true,
color: AppColor.success.withOpacity(0.1),
),
),
],
),
),
),
],
),
);
}
Widget _buildLegendItem(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 6),
Text(
label,
style: AppStyle.sm.copyWith(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColor.textPrimary,
),
),
],
);
}
}