feat: profit loss

This commit is contained in:
efrilm 2025-08-17 22:36:46 +07:00
parent beb9ead4da
commit 50b06da627
21 changed files with 4548 additions and 424 deletions

View File

@ -0,0 +1,44 @@
import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../domain/analytic/repositories/i_analytic_repository.dart';
part 'profit_loss_loader_event.dart';
part 'profit_loss_loader_state.dart';
part 'profit_loss_loader_bloc.freezed.dart';
@injectable
class ProfitLossLoaderBloc
extends Bloc<ProfitLossLoaderEvent, ProfitLossLoaderState> {
final IAnalyticRepository _repository;
ProfitLossLoaderBloc(this._repository)
: super(ProfitLossLoaderState.initial()) {
on<ProfitLossLoaderEvent>(_onProfitLossLoaderEvent);
}
Future<void> _onProfitLossLoaderEvent(
ProfitLossLoaderEvent event,
Emitter<ProfitLossLoaderState> emit,
) {
return event.map(
fetched: (e) async {
emit(state.copyWith(isFetching: true, failureOptionProfitLoss: none()));
final result = await _repository.getProfitLoss(
dateFrom: DateTime.now().subtract(const Duration(days: 30)),
dateTo: DateTime.now(),
);
var data = result.fold(
(f) => state.copyWith(failureOptionProfitLoss: optionOf(f)),
(profitLoss) => state.copyWith(profitLoss: profitLoss),
);
emit(data.copyWith(isFetching: false));
},
);
}
}

View File

@ -0,0 +1,384 @@
// 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_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 _$ProfitLossLoaderEvent {
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function() fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function()? fetched,
}) => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? 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;
}
/// @nodoc
abstract class $ProfitLossLoaderEventCopyWith<$Res> {
factory $ProfitLossLoaderEventCopyWith(
ProfitLossLoaderEvent value,
$Res Function(ProfitLossLoaderEvent) then,
) = _$ProfitLossLoaderEventCopyWithImpl<$Res, ProfitLossLoaderEvent>;
}
/// @nodoc
class _$ProfitLossLoaderEventCopyWithImpl<
$Res,
$Val extends ProfitLossLoaderEvent
>
implements $ProfitLossLoaderEventCopyWith<$Res> {
_$ProfitLossLoaderEventCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ProfitLossLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
abstract class _$$FetchedImplCopyWith<$Res> {
factory _$$FetchedImplCopyWith(
_$FetchedImpl value,
$Res Function(_$FetchedImpl) then,
) = __$$FetchedImplCopyWithImpl<$Res>;
}
/// @nodoc
class __$$FetchedImplCopyWithImpl<$Res>
extends _$ProfitLossLoaderEventCopyWithImpl<$Res, _$FetchedImpl>
implements _$$FetchedImplCopyWith<$Res> {
__$$FetchedImplCopyWithImpl(
_$FetchedImpl _value,
$Res Function(_$FetchedImpl) _then,
) : super(_value, _then);
/// Create a copy of ProfitLossLoaderEvent
/// with the given fields replaced by the non-null parameter values.
}
/// @nodoc
class _$FetchedImpl implements _Fetched {
const _$FetchedImpl();
@override
String toString() {
return 'ProfitLossLoaderEvent.fetched()';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType && other is _$FetchedImpl);
}
@override
int get hashCode => runtimeType.hashCode;
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({required TResult Function() fetched}) {
return fetched();
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({TResult? Function()? fetched}) {
return fetched?.call();
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function()? fetched,
required TResult orElse(),
}) {
if (fetched != null) {
return fetched();
}
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 ProfitLossLoaderEvent {
const factory _Fetched() = _$FetchedImpl;
}
/// @nodoc
mixin _$ProfitLossLoaderState {
ProfitLossAnalytic get profitLoss => throw _privateConstructorUsedError;
Option<AnalyticFailure> get failureOptionProfitLoss =>
throw _privateConstructorUsedError;
bool get isFetching => throw _privateConstructorUsedError;
/// Create a copy of ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$ProfitLossLoaderStateCopyWith<ProfitLossLoaderState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $ProfitLossLoaderStateCopyWith<$Res> {
factory $ProfitLossLoaderStateCopyWith(
ProfitLossLoaderState value,
$Res Function(ProfitLossLoaderState) then,
) = _$ProfitLossLoaderStateCopyWithImpl<$Res, ProfitLossLoaderState>;
@useResult
$Res call({
ProfitLossAnalytic profitLoss,
Option<AnalyticFailure> failureOptionProfitLoss,
bool isFetching,
});
$ProfitLossAnalyticCopyWith<$Res> get profitLoss;
}
/// @nodoc
class _$ProfitLossLoaderStateCopyWithImpl<
$Res,
$Val extends ProfitLossLoaderState
>
implements $ProfitLossLoaderStateCopyWith<$Res> {
_$ProfitLossLoaderStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? profitLoss = null,
Object? failureOptionProfitLoss = null,
Object? isFetching = null,
}) {
return _then(
_value.copyWith(
profitLoss: null == profitLoss
? _value.profitLoss
: profitLoss // ignore: cast_nullable_to_non_nullable
as ProfitLossAnalytic,
failureOptionProfitLoss: null == failureOptionProfitLoss
? _value.failureOptionProfitLoss
: failureOptionProfitLoss // 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 ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$ProfitLossAnalyticCopyWith<$Res> get profitLoss {
return $ProfitLossAnalyticCopyWith<$Res>(_value.profitLoss, (value) {
return _then(_value.copyWith(profitLoss: value) as $Val);
});
}
}
/// @nodoc
abstract class _$$ProfitLossLoaderStateImplCopyWith<$Res>
implements $ProfitLossLoaderStateCopyWith<$Res> {
factory _$$ProfitLossLoaderStateImplCopyWith(
_$ProfitLossLoaderStateImpl value,
$Res Function(_$ProfitLossLoaderStateImpl) then,
) = __$$ProfitLossLoaderStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call({
ProfitLossAnalytic profitLoss,
Option<AnalyticFailure> failureOptionProfitLoss,
bool isFetching,
});
@override
$ProfitLossAnalyticCopyWith<$Res> get profitLoss;
}
/// @nodoc
class __$$ProfitLossLoaderStateImplCopyWithImpl<$Res>
extends
_$ProfitLossLoaderStateCopyWithImpl<$Res, _$ProfitLossLoaderStateImpl>
implements _$$ProfitLossLoaderStateImplCopyWith<$Res> {
__$$ProfitLossLoaderStateImplCopyWithImpl(
_$ProfitLossLoaderStateImpl _value,
$Res Function(_$ProfitLossLoaderStateImpl) _then,
) : super(_value, _then);
/// Create a copy of ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? profitLoss = null,
Object? failureOptionProfitLoss = null,
Object? isFetching = null,
}) {
return _then(
_$ProfitLossLoaderStateImpl(
profitLoss: null == profitLoss
? _value.profitLoss
: profitLoss // ignore: cast_nullable_to_non_nullable
as ProfitLossAnalytic,
failureOptionProfitLoss: null == failureOptionProfitLoss
? _value.failureOptionProfitLoss
: failureOptionProfitLoss // 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 _$ProfitLossLoaderStateImpl implements _ProfitLossLoaderState {
const _$ProfitLossLoaderStateImpl({
required this.profitLoss,
required this.failureOptionProfitLoss,
this.isFetching = false,
});
@override
final ProfitLossAnalytic profitLoss;
@override
final Option<AnalyticFailure> failureOptionProfitLoss;
@override
@JsonKey()
final bool isFetching;
@override
String toString() {
return 'ProfitLossLoaderState(profitLoss: $profitLoss, failureOptionProfitLoss: $failureOptionProfitLoss, isFetching: $isFetching)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$ProfitLossLoaderStateImpl &&
(identical(other.profitLoss, profitLoss) ||
other.profitLoss == profitLoss) &&
(identical(
other.failureOptionProfitLoss,
failureOptionProfitLoss,
) ||
other.failureOptionProfitLoss == failureOptionProfitLoss) &&
(identical(other.isFetching, isFetching) ||
other.isFetching == isFetching));
}
@override
int get hashCode =>
Object.hash(runtimeType, profitLoss, failureOptionProfitLoss, isFetching);
/// Create a copy of ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$ProfitLossLoaderStateImplCopyWith<_$ProfitLossLoaderStateImpl>
get copyWith =>
__$$ProfitLossLoaderStateImplCopyWithImpl<_$ProfitLossLoaderStateImpl>(
this,
_$identity,
);
}
abstract class _ProfitLossLoaderState implements ProfitLossLoaderState {
const factory _ProfitLossLoaderState({
required final ProfitLossAnalytic profitLoss,
required final Option<AnalyticFailure> failureOptionProfitLoss,
final bool isFetching,
}) = _$ProfitLossLoaderStateImpl;
@override
ProfitLossAnalytic get profitLoss;
@override
Option<AnalyticFailure> get failureOptionProfitLoss;
@override
bool get isFetching;
/// Create a copy of ProfitLossLoaderState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$ProfitLossLoaderStateImplCopyWith<_$ProfitLossLoaderStateImpl>
get copyWith => throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,6 @@
part of 'profit_loss_loader_bloc.dart';
@freezed
class ProfitLossLoaderEvent with _$ProfitLossLoaderEvent {
const factory ProfitLossLoaderEvent.fetched() = _Fetched;
}

View File

@ -0,0 +1,15 @@
part of 'profit_loss_loader_bloc.dart';
@freezed
class ProfitLossLoaderState with _$ProfitLossLoaderState {
const factory ProfitLossLoaderState({
required ProfitLossAnalytic profitLoss,
required Option<AnalyticFailure> failureOptionProfitLoss,
@Default(false) bool isFetching,
}) = _ProfitLossLoaderState;
factory ProfitLossLoaderState.initial() => ProfitLossLoaderState(
profitLoss: ProfitLossAnalytic.empty(),
failureOptionProfitLoss: none(),
);
}

View File

@ -5,6 +5,7 @@ class ApiPath {
// Analytic
static const String salesAnalytic = '/api/v1/analytics/sales';
static const String profitLossAnalytic = '/api/v1/analytics/profit-loss';
// Category
static const String category = '/api/v1/categories';

View File

@ -6,3 +6,4 @@ part 'analytic.freezed.dart';
part 'entities/sales_analytic_entity.dart';
part 'failures/analytic_failure.dart';
part 'entities/profit_loss_analytic_entity.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 ProfitLossSummary summary,
required List<ProfitLossDailyData> data,
required List<ProfitLossProductData> productData,
}) = _ProfitLossAnalytic;
factory ProfitLossAnalytic.empty() => ProfitLossAnalytic(
organizationId: '',
dateFrom: '',
dateTo: '',
groupBy: '',
summary: ProfitLossSummary.empty(),
data: [],
productData: [],
);
}
@freezed
class ProfitLossSummary with _$ProfitLossSummary {
const factory ProfitLossSummary({
required int totalRevenue,
required int totalCost,
required int grossProfit,
required double grossProfitMargin,
required int totalTax,
required int totalDiscount,
required int netProfit,
required double netProfitMargin,
required int totalOrders,
required double averageProfit,
required double profitabilityRatio,
}) = _ProfitLossSummary;
factory ProfitLossSummary.empty() => ProfitLossSummary(
totalRevenue: 0,
totalCost: 0,
grossProfit: 0,
grossProfitMargin: 0,
totalTax: 0,
totalDiscount: 0,
netProfit: 0,
netProfitMargin: 0,
totalOrders: 0,
averageProfit: 0,
profitabilityRatio: 0,
);
}
@freezed
class ProfitLossDailyData with _$ProfitLossDailyData {
const factory ProfitLossDailyData({
required String date,
required int revenue,
required int cost,
required int grossProfit,
required double grossProfitMargin,
required int tax,
required int discount,
required int netProfit,
required double netProfitMargin,
required int orders,
}) = _ProfitLossDailyData;
factory ProfitLossDailyData.empty() => ProfitLossDailyData(
date: '',
revenue: 0,
cost: 0,
grossProfit: 0,
grossProfitMargin: 0,
tax: 0,
discount: 0,
netProfit: 0,
netProfitMargin: 0,
orders: 0,
);
}
@freezed
class ProfitLossProductData with _$ProfitLossProductData {
const factory ProfitLossProductData({
required String productId,
required String productName,
required String categoryId,
required String categoryName,
required int quantitySold,
required int revenue,
required int cost,
required int grossProfit,
required double grossProfitMargin,
required int averagePrice,
required int averageCost,
required int profitPerUnit,
}) = _ProfitLossProductData;
factory ProfitLossProductData.empty() => ProfitLossProductData(
productId: '',
productName: '',
categoryId: '',
categoryName: '',
quantitySold: 0,
revenue: 0,
cost: 0,
grossProfit: 0,
grossProfitMargin: 0,
averagePrice: 0,
averageCost: 0,
profitPerUnit: 0,
);
}

View File

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

View File

@ -6,3 +6,4 @@ part 'analytic_dtos.freezed.dart';
part 'analytic_dtos.g.dart';
part 'dto/sales_analytic_dto.dart';
part 'dto/profit_loss_analytic_dto.dart';

File diff suppressed because it is too large Load Diff

View File

@ -87,3 +87,129 @@ Map<String, dynamic> _$$SalesAnalyticDataDtoImplToJson(
'discount': instance.discount,
'net_sales': instance.netSales,
};
_$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
: ProfitLossSummaryDto.fromJson(json['summary'] as Map<String, dynamic>),
data: (json['data'] as List<dynamic>?)
?.map((e) => ProfitLossDailyDataDto.fromJson(e as Map<String, dynamic>))
.toList(),
productData: (json['product_data'] as List<dynamic>?)
?.map((e) => ProfitLossProductDataDto.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,
};
_$ProfitLossSummaryDtoImpl _$$ProfitLossSummaryDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossSummaryDtoImpl(
totalRevenue: (json['total_revenue'] as num?)?.toInt(),
totalCost: (json['total_cost'] as num?)?.toInt(),
grossProfit: (json['gross_profit'] as num?)?.toInt(),
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?)?.toInt(),
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> _$$ProfitLossSummaryDtoImplToJson(
_$ProfitLossSummaryDtoImpl 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,
};
_$ProfitLossDailyDataDtoImpl _$$ProfitLossDailyDataDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossDailyDataDtoImpl(
date: json['date'] as String?,
revenue: (json['revenue'] as num?)?.toInt(),
cost: (json['cost'] as num?)?.toInt(),
grossProfit: (json['gross_profit'] as num?)?.toInt(),
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?)?.toInt(),
netProfitMargin: (json['net_profit_margin'] as num?)?.toDouble(),
orders: (json['orders'] as num?)?.toInt(),
);
Map<String, dynamic> _$$ProfitLossDailyDataDtoImplToJson(
_$ProfitLossDailyDataDtoImpl 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,
};
_$ProfitLossProductDataDtoImpl _$$ProfitLossProductDataDtoImplFromJson(
Map<String, dynamic> json,
) => _$ProfitLossProductDataDtoImpl(
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?)?.toInt(),
grossProfit: (json['gross_profit'] as num?)?.toInt(),
grossProfitMargin: (json['gross_profit_margin'] as num?)?.toDouble(),
averagePrice: (json['average_price'] as num?)?.toInt(),
averageCost: (json['average_cost'] as num?)?.toInt(),
profitPerUnit: (json['profit_per_unit'] as num?)?.toInt(),
);
Map<String, dynamic> _$$ProfitLossProductDataDtoImplToJson(
_$ProfitLossProductDataDtoImpl 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

@ -44,4 +44,31 @@ 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.profitLossAnalytic,
params: {
'date_from': dateFrom.toServerDate,
'date_to': dateTo.toServerDate,
},
headers: getAuthorizationHeader(),
);
if (response.data['data'] == null) {
return DC.error(AnalyticFailure.empty());
}
final dto = ProfitLossAnalyticDto.fromJson(response.data['data']);
return DC.data(dto);
} on ApiFailure catch (e, s) {
log('fetchProfitLossError', name: _logName, error: e, stackTrace: s);
return DC.error(AnalyticFailure.serverError(e));
}
}
}

View File

@ -0,0 +1,137 @@
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') ProfitLossSummaryDto? summary,
@JsonKey(name: 'data') List<ProfitLossDailyDataDto>? data,
@JsonKey(name: 'product_data') List<ProfitLossProductDataDto>? productData,
}) = _ProfitLossAnalyticDto;
factory ProfitLossAnalyticDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossAnalyticDtoFromJson(json);
ProfitLossAnalytic toDomain() => ProfitLossAnalytic(
organizationId: organizationId ?? '',
dateFrom: dateFrom ?? '',
dateTo: dateTo ?? '',
groupBy: groupBy ?? '',
summary: summary?.toDomain() ?? ProfitLossSummary.empty(),
data: (data ?? []).map((e) => e.toDomain()).toList(),
productData: (productData ?? []).map((e) => e.toDomain()).toList(),
);
}
@freezed
class ProfitLossSummaryDto with _$ProfitLossSummaryDto {
const ProfitLossSummaryDto._();
const factory ProfitLossSummaryDto({
@JsonKey(name: 'total_revenue') int? totalRevenue,
@JsonKey(name: 'total_cost') int? totalCost,
@JsonKey(name: 'gross_profit') int? grossProfit,
@JsonKey(name: 'gross_profit_margin') double? grossProfitMargin,
@JsonKey(name: 'total_tax') int? totalTax,
@JsonKey(name: 'total_discount') int? totalDiscount,
@JsonKey(name: 'net_profit') int? 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,
}) = _ProfitLossSummaryDto;
factory ProfitLossSummaryDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossSummaryDtoFromJson(json);
ProfitLossSummary toDomain() => ProfitLossSummary(
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 ProfitLossDailyDataDto with _$ProfitLossDailyDataDto {
const ProfitLossDailyDataDto._();
const factory ProfitLossDailyDataDto({
@JsonKey(name: 'date') String? date,
@JsonKey(name: 'revenue') int? revenue,
@JsonKey(name: 'cost') int? cost,
@JsonKey(name: 'gross_profit') int? grossProfit,
@JsonKey(name: 'gross_profit_margin') double? grossProfitMargin,
@JsonKey(name: 'tax') int? tax,
@JsonKey(name: 'discount') int? discount,
@JsonKey(name: 'net_profit') int? netProfit,
@JsonKey(name: 'net_profit_margin') double? netProfitMargin,
@JsonKey(name: 'orders') int? orders,
}) = _ProfitLossDailyDataDto;
factory ProfitLossDailyDataDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossDailyDataDtoFromJson(json);
ProfitLossDailyData toDomain() => ProfitLossDailyData(
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 ProfitLossProductDataDto with _$ProfitLossProductDataDto {
const ProfitLossProductDataDto._();
const factory ProfitLossProductDataDto({
@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') int? cost,
@JsonKey(name: 'gross_profit') int? grossProfit,
@JsonKey(name: 'gross_profit_margin') double? grossProfitMargin,
@JsonKey(name: 'average_price') int? averagePrice,
@JsonKey(name: 'average_cost') int? averageCost,
@JsonKey(name: 'profit_per_unit') int? profitPerUnit,
}) = _ProfitLossProductDataDto;
factory ProfitLossProductDataDto.fromJson(Map<String, dynamic> json) =>
_$ProfitLossProductDataDtoFromJson(json);
ProfitLossProductData toDomain() => ProfitLossProductData(
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

@ -37,4 +37,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 auth = result.data!.toDomain();
return right(auth);
} catch (e, s) {
log('getProfitLossError', name: _logName, error: e, stackTrace: s);
return left(const AnalyticFailure.unexpectedError());
}
}
}

View File

@ -20,6 +20,8 @@ import 'package:apskel_owner_flutter/application/language/language_bloc.dart'
as _i455;
import 'package:apskel_owner_flutter/application/product/product_loader/product_loader_bloc.dart'
as _i458;
import 'package:apskel_owner_flutter/application/profit_loss/profit_loss_loader/profit_loss_loader_bloc.dart'
as _i608;
import 'package:apskel_owner_flutter/application/sales/sales_loader/sales_loader_bloc.dart'
as _i882;
import 'package:apskel_owner_flutter/common/api/api_client.dart' as _i115;
@ -104,18 +106,18 @@ extension GetItInjectableX on _i174.GetIt {
() => _i115.ApiClient(gh<_i361.Dio>(), gh<_i6.Env>()),
);
gh.factory<_i6.Env>(() => _i6.ProdEnv(), registerFor: {_prod});
gh.factory<_i17.AuthRemoteDataProvider>(
() => _i17.AuthRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i866.AnalyticRemoteDataProvider>(
() => _i866.AnalyticRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i333.CategoryRemoteDataProvider>(
() => _i333.CategoryRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i17.AuthRemoteDataProvider>(
() => _i17.AuthRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i823.ProductRemoteDataProvider>(
() => _i823.ProductRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i866.AnalyticRemoteDataProvider>(
() => _i866.AnalyticRemoteDataProvider(gh<_i115.ApiClient>()),
);
gh.factory<_i477.IAnalyticRepository>(
() => _i393.AnalyticRepository(gh<_i866.AnalyticRemoteDataProvider>()),
);
@ -134,6 +136,9 @@ extension GetItInjectableX on _i174.GetIt {
gh.factory<_i458.ProductLoaderBloc>(
() => _i458.ProductLoaderBloc(gh<_i419.IProductRepository>()),
);
gh.factory<_i608.ProfitLossLoaderBloc>(
() => _i608.ProfitLossLoaderBloc(gh<_i477.IAnalyticRepository>()),
);
gh.factory<_i183.CategoryLoaderBloc>(
() => _i183.CategoryLoaderBloc(gh<_i1020.ICategoryRepository>()),
);

View File

@ -1,20 +1,33 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:line_icons/line_icons.dart';
import '../../../application/profit_loss/profit_loss_loader/profit_loss_loader_bloc.dart';
import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart';
import '../../../domain/analytic/analytic.dart';
import '../../../injection.dart';
import '../../components/appbar/appbar.dart';
import 'widgets/cash_flow.dart';
import 'widgets/category.dart';
import 'widgets/product.dart';
import 'widgets/profit_loss.dart';
import 'widgets/summary_card.dart';
@RoutePage()
class FinancePage extends StatefulWidget {
class FinancePage extends StatefulWidget implements AutoRouteWrapper {
const FinancePage({super.key});
@override
State<FinancePage> createState() => _FinancePageState();
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (_) =>
getIt<ProfitLossLoaderBloc>()..add(ProfitLossLoaderEvent.fetched()),
child: this,
);
}
class _FinancePageState extends State<FinancePage>
@ -90,69 +103,74 @@ class _FinancePageState extends State<FinancePage>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColor.background,
body: CustomScrollView(
slivers: [
// SliverAppBar with animated background
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
elevation: 0,
flexibleSpace: CustomAppBar(title: 'Keuangan'),
),
body: BlocBuilder<ProfitLossLoaderBloc, ProfitLossLoaderState>(
builder: (context, state) {
return CustomScrollView(
slivers: [
// SliverAppBar with animated background
SliverAppBar(
expandedHeight: 120,
floating: false,
pinned: true,
backgroundColor: AppColor.primary,
elevation: 0,
flexibleSpace: CustomAppBar(title: 'Keuangan'),
),
// Header dengan filter periode
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildPeriodSelector(),
),
),
// Header dengan filter periode
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: _buildPeriodSelector(),
),
),
// Summary Cards
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: _buildSummaryCards(),
),
),
// Summary Cards
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: _buildSummaryCards(state.profitLoss.summary),
),
),
// Cash Flow Analysis
SliverToBoxAdapter(
child: ScaleTransition(
scale: _scaleAnimation,
child: FinanceCashFlow(),
),
),
// Cash Flow Analysis
SliverToBoxAdapter(
child: ScaleTransition(
scale: _scaleAnimation,
child: FinanceCashFlow(dailyData: state.profitLoss.data),
),
),
// Profit Loss Detail
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: FinanceProfitLoss(),
),
),
// Profit Loss Detail
SliverToBoxAdapter(
child: FadeTransition(
opacity: _fadeAnimation,
child: FinanceProfitLoss(data: state.profitLoss.summary),
),
),
// Transaction Categories
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: FinanceCategory(),
),
),
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: FinanceCategory(),
),
),
// Monthly Comparison
SliverToBoxAdapter(
child: ScaleTransition(
scale: _scaleAnimation,
child: _buildMonthlyComparison(),
),
),
// Product Analysis Section
SliverToBoxAdapter(
child: SlideTransition(
position: _slideAnimation,
child: _buildProductAnalysis(state.profitLoss.productData),
),
),
// Bottom spacing
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
// Transaction Categories
// Bottom spacing
const SliverToBoxAdapter(child: SizedBox(height: 100)),
],
);
},
),
);
}
@ -210,7 +228,7 @@ class _FinancePageState extends State<FinancePage>
);
}
Widget _buildSummaryCards() {
Widget _buildSummaryCards(ProfitLossSummary summary) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
@ -220,10 +238,9 @@ class _FinancePageState extends State<FinancePage>
Expanded(
child: FinanceSummaryCard(
title: 'Total Pendapatan',
amount: 'Rp 25.840.000',
amount: summary.totalRevenue.currencyFormatRp,
icon: LineIcons.arrowUp,
color: AppColor.success,
change: '+12.5%',
isPositive: true,
),
),
@ -231,10 +248,9 @@ class _FinancePageState extends State<FinancePage>
Expanded(
child: FinanceSummaryCard(
title: 'Total Pengeluaran',
amount: 'Rp 18.320.000',
amount: summary.totalCost.currencyFormatRp,
icon: LineIcons.arrowDown,
color: AppColor.error,
change: '+8.2%',
isPositive: false,
),
),
@ -246,10 +262,9 @@ class _FinancePageState extends State<FinancePage>
Expanded(
child: FinanceSummaryCard(
title: 'Keuntungan Bersih',
amount: 'Rp 7.520.000',
amount: summary.netProfit.currencyFormatRp,
icon: LineIcons.lineChart,
color: AppColor.info,
change: '+15.3%',
isPositive: true,
),
),
@ -257,10 +272,9 @@ class _FinancePageState extends State<FinancePage>
Expanded(
child: FinanceSummaryCard(
title: 'Margin Profit',
amount: '29.1%',
amount: '${summary.profitabilityRatio.round()}%',
icon: LineIcons.percent,
color: AppColor.warning,
change: '+2.1%',
isPositive: true,
),
),
@ -271,7 +285,7 @@ class _FinancePageState extends State<FinancePage>
);
}
Widget _buildMonthlyComparison() {
Widget _buildProductAnalysis(List<ProfitLossProductData> products) {
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
@ -295,138 +309,41 @@ class _FinancePageState extends State<FinancePage>
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColor.warning.withOpacity(0.1),
color: AppColor.info.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
LineIcons.calendarCheck,
color: AppColor.warning,
LineIcons.shoppingBag,
color: AppColor.info,
size: 20,
),
),
const SizedBox(width: 12),
Text(
'Perbandingan Bulanan',
'Analisis Produk',
style: AppStyle.lg.copyWith(fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 20),
Row(
children: [
Expanded(
child: _buildComparisonCard(
'Bulan Ini',
'Rp 7.52M',
'+15.3%',
true,
AppColor.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: _buildComparisonCard(
'Bulan Lalu',
'Rp 6.53M',
'-2.1%',
false,
AppColor.textSecondary,
const Spacer(),
TextButton(
onPressed: () {},
child: Text(
'Lihat Semua',
style: AppStyle.sm.copyWith(color: AppColor.primary),
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.success.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.success.withOpacity(0.2)),
),
child: Row(
children: [
const Icon(
LineIcons.thumbsUp,
color: AppColor.success,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Performa Bagus!',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.success,
),
),
Text(
'Keuntungan meningkat 15.3% dari bulan lalu',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
],
),
),
],
),
);
}
Widget _buildComparisonCard(
String period,
String amount,
String change,
bool isPositive,
Color color,
) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
period,
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
const SizedBox(height: 8),
Text(
amount,
style: AppStyle.lg.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
isPositive ? LineIcons.arrowUp : LineIcons.arrowDown,
size: 14,
color: isPositive ? AppColor.success : AppColor.error,
),
const SizedBox(width: 4),
Text(
change,
style: AppStyle.xs.copyWith(
color: isPositive ? AppColor.success : AppColor.error,
fontWeight: FontWeight.w600,
),
),
],
// Product list
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.only(top: 12),
itemCount: products.length,
separatorBuilder: (context, index) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final product = products[index];
return ProfitLossProduct(product: product);
},
),
],
),

View File

@ -1,14 +1,23 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import 'package:intl/intl.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class FinanceCashFlow extends StatelessWidget {
const FinanceCashFlow({super.key});
final List<ProfitLossDailyData> dailyData;
const FinanceCashFlow({super.key, required this.dailyData});
@override
Widget build(BuildContext context) {
// Calculate totals from daily data
final totalCashIn = _calculateTotalCashIn();
final totalCashOut = _calculateTotalCashOut();
final netFlow = totalCashIn - totalCashOut;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(20),
@ -70,7 +79,7 @@ class FinanceCashFlow extends StatelessWidget {
Expanded(
child: _buildCashFlowIndicator(
'Cash In',
'Rp 28.5M',
_formatCurrency(totalCashIn),
LineIcons.arrowUp,
AppColor.success,
),
@ -79,7 +88,7 @@ class FinanceCashFlow extends StatelessWidget {
Expanded(
child: _buildCashFlowIndicator(
'Cash Out',
'Rp 21.2M',
_formatCurrency(totalCashOut),
LineIcons.arrowDown,
AppColor.error,
),
@ -88,7 +97,7 @@ class FinanceCashFlow extends StatelessWidget {
Expanded(
child: _buildCashFlowIndicator(
'Net Flow',
'Rp 7.3M',
_formatCurrency(netFlow),
LineIcons.equals,
AppColor.info,
),
@ -110,7 +119,7 @@ class FinanceCashFlow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Grafik Cash Flow 7 Hari Terakhir',
'Grafik Cash Flow ${dailyData.length} Hari Terakhir',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
fontWeight: FontWeight.w600,
@ -118,207 +127,9 @@ class FinanceCashFlow extends StatelessWidget {
),
const SizedBox(height: 16),
Expanded(
child: LineChart(
LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: 5000000, // 5M interval
getDrawingHorizontalLine: (value) {
return FlLine(
color: AppColor.borderLight,
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (double value, TitleMeta meta) {
const style = TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
);
Widget text;
switch (value.toInt()) {
case 0:
text = const Text('Sen', style: style);
break;
case 1:
text = const Text('Sel', style: style);
break;
case 2:
text = const Text('Rab', style: style);
break;
case 3:
text = const Text('Kam', style: style);
break;
case 4:
text = const Text('Jum', style: style);
break;
case 5:
text = const Text('Sab', style: style);
break;
case 6:
text = const Text('Min', style: style);
break;
default:
text = const Text('', style: style);
break;
}
return SideTitleWidget(meta: meta, child: text);
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: 10000000, // 10M interval
reservedSize: 42,
getTitlesWidget: (double value, TitleMeta meta) {
return Text(
'${(value / 1000000).toInt()}M',
style: const TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
),
textAlign: TextAlign.left,
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColor.borderLight),
),
minX: 0,
maxX: 6,
minY: -5000000,
maxY: 30000000,
lineBarsData: [
// Cash In Line
LineChartBarData(
spots: const [
FlSpot(0, 25000000), // Monday
FlSpot(1, 22000000), // Tuesday
FlSpot(2, 28000000), // Wednesday
FlSpot(3, 24000000), // Thursday
FlSpot(4, 30000000), // Friday
FlSpot(5, 18000000), // Saturday
FlSpot(6, 26000000), // Sunday
],
isCurved: true,
gradient: LinearGradient(
colors: [
AppColor.success.withOpacity(0.8),
AppColor.success,
],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.success,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppColor.success.withOpacity(0.1),
AppColor.success.withOpacity(0.0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// Cash Out Line
LineChartBarData(
spots: const [
FlSpot(0, 20000000), // Monday
FlSpot(1, 18000000), // Tuesday
FlSpot(2, 23000000), // Wednesday
FlSpot(3, 19000000), // Thursday
FlSpot(4, 25000000), // Friday
FlSpot(5, 15000000), // Saturday
FlSpot(6, 21000000), // Sunday
],
isCurved: true,
gradient: LinearGradient(
colors: [
AppColor.error.withOpacity(0.8),
AppColor.error,
],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.error,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
// Net Flow Line
LineChartBarData(
spots: const [
FlSpot(0, 5000000), // Monday
FlSpot(1, 4000000), // Tuesday
FlSpot(2, 5000000), // Wednesday
FlSpot(3, 5000000), // Thursday
FlSpot(4, 5000000), // Friday
FlSpot(5, 3000000), // Saturday
FlSpot(6, 5000000), // Sunday
],
isCurved: true,
gradient: LinearGradient(
colors: [
AppColor.info.withOpacity(0.8),
AppColor.info,
],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.info,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
],
),
),
child: dailyData.isEmpty
? _buildEmptyChart()
: LineChart(_buildLineChartData()),
),
const SizedBox(height: 12),
// Legend
@ -340,6 +151,273 @@ class FinanceCashFlow extends StatelessWidget {
);
}
LineChartData _buildLineChartData() {
final maxValue = _getMaxChartValue();
final minValue = _getMinChartValue();
return LineChartData(
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: (maxValue / 5).roundToDouble(),
getDrawingHorizontalLine: (value) {
return FlLine(color: AppColor.borderLight, strokeWidth: 1);
},
),
titlesData: FlTitlesData(
show: true,
rightTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
topTitles: AxisTitles(sideTitles: SideTitles(showTitles: false)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 30,
interval: 1,
getTitlesWidget: (double value, TitleMeta meta) {
final index = value.toInt();
if (index >= 0 && index < dailyData.length) {
final date = DateTime.parse(dailyData[index].date);
final dayName = _getDayName(date.weekday);
return SideTitleWidget(
meta: meta,
child: Text(
dayName,
style: const TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
),
),
);
}
return SideTitleWidget(meta: meta, child: Text(''));
},
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
interval: (maxValue / 3).roundToDouble(),
reservedSize: 42,
getTitlesWidget: (double value, TitleMeta meta) {
return Text(
_formatChartValue(value),
style: const TextStyle(
color: AppColor.textSecondary,
fontWeight: FontWeight.w500,
fontSize: 10,
),
textAlign: TextAlign.left,
);
},
),
),
),
borderData: FlBorderData(
show: true,
border: Border.all(color: AppColor.borderLight),
),
minX: 0,
maxX: (dailyData.length - 1).toDouble(),
minY: minValue,
maxY: maxValue,
lineBarsData: [
// Cash In Line (Revenue)
LineChartBarData(
spots: _buildCashInSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.success.withOpacity(0.8), AppColor.success],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.success,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
colors: [
AppColor.success.withOpacity(0.1),
AppColor.success.withOpacity(0.0),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
),
// Cash Out Line (Total Cost)
LineChartBarData(
spots: _buildCashOutSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.error.withOpacity(0.8), AppColor.error],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.error,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
// Net Flow Line (Net Profit)
LineChartBarData(
spots: _buildNetFlowSpots(),
isCurved: true,
gradient: LinearGradient(
colors: [AppColor.info.withOpacity(0.8), AppColor.info],
),
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
getDotPainter: (spot, percent, barData, index) {
return FlDotCirclePainter(
radius: 4,
color: AppColor.info,
strokeWidth: 2,
strokeColor: AppColor.white,
);
},
),
),
],
);
}
Widget _buildEmptyChart() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
LineIcons.lineChart,
size: 48,
color: AppColor.textSecondary.withOpacity(0.3),
),
const SizedBox(height: 12),
Text(
'Tidak ada data untuk ditampilkan',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
],
),
);
}
// Helper methods for calculating data
int _calculateTotalCashIn() {
return dailyData.fold(0, (sum, data) => sum + data.revenue);
}
int _calculateTotalCashOut() {
return dailyData.fold(
0,
(sum, data) => sum + data.cost + data.tax + data.discount,
);
}
double _getMaxChartValue() {
if (dailyData.isEmpty) return 30000000;
final maxRevenue = dailyData
.map((e) => e.revenue)
.reduce((a, b) => a > b ? a : b);
final maxCost = dailyData
.map((e) => e.cost + e.tax + e.discount)
.reduce((a, b) => a > b ? a : b);
final maxValue = maxRevenue > maxCost ? maxRevenue : maxCost;
return (maxValue * 1.2).toDouble(); // Add 20% padding
}
double _getMinChartValue() {
if (dailyData.isEmpty) return -5000000;
final minNetProfit = dailyData
.map((e) => e.netProfit)
.reduce((a, b) => a < b ? a : b);
return minNetProfit < 0 ? (minNetProfit * 1.2).toDouble() : 0;
}
List<FlSpot> _buildCashInSpots() {
return dailyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.revenue.toDouble());
}).toList();
}
List<FlSpot> _buildCashOutSpots() {
return dailyData.asMap().entries.map((entry) {
final totalCost =
entry.value.cost + entry.value.tax + entry.value.discount;
return FlSpot(entry.key.toDouble(), totalCost.toDouble());
}).toList();
}
List<FlSpot> _buildNetFlowSpots() {
return dailyData.asMap().entries.map((entry) {
return FlSpot(entry.key.toDouble(), entry.value.netProfit.toDouble());
}).toList();
}
String _getDayName(int weekday) {
switch (weekday) {
case 1:
return 'Sen';
case 2:
return 'Sel';
case 3:
return 'Rab';
case 4:
return 'Kam';
case 5:
return 'Jum';
case 6:
return 'Sab';
case 7:
return 'Min';
default:
return '';
}
}
String _formatChartValue(double value) {
if (value.abs() >= 1000000) {
return '${(value / 1000000).toStringAsFixed(0)}M';
} else if (value.abs() >= 1000) {
return '${(value / 1000).toStringAsFixed(0)}K';
} else {
return value.toStringAsFixed(0);
}
}
String _formatCurrency(int amount) {
if (amount.abs() >= 1000000000) {
return 'Rp ${(amount / 1000000000).toStringAsFixed(1)}B';
} else if (amount.abs() >= 1000000) {
return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M';
} else if (amount.abs() >= 1000) {
return 'Rp ${(amount / 1000).toStringAsFixed(1)}K';
} else {
return 'Rp ${NumberFormat('#,###', 'id_ID').format(amount)}';
}
}
Widget _buildChartLegend(String label, Color color) {
return Row(
mainAxisSize: MainAxisSize.min,

View File

@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class ProfitLossProduct extends StatelessWidget {
final ProfitLossProductData product;
const ProfitLossProduct({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.background,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border.withOpacity(0.5)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Product header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
product.productName,
style: AppStyle.md.copyWith(fontWeight: FontWeight.bold),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColor.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
product.categoryName,
style: AppStyle.xs.copyWith(color: AppColor.primary),
),
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${product.quantitySold} terjual',
style: AppStyle.sm.copyWith(color: AppColor.textSecondary),
),
Text(
'${product.grossProfitMargin.toStringAsFixed(1)}%',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: product.grossProfitMargin > 25
? AppColor.success
: product.grossProfitMargin > 15
? AppColor.warning
: AppColor.error,
),
),
],
),
],
),
const SizedBox(height: 16),
// Financial metrics
Row(
children: [
Expanded(
child: _buildMetricColumn(
'Pendapatan',
product.revenue.currencyFormatRp,
AppColor.success,
),
),
Expanded(
child: _buildMetricColumn(
'Biaya',
product.cost.currencyFormatRp,
AppColor.error,
),
),
Expanded(
child: _buildMetricColumn(
'Laba Kotor',
product.grossProfit.currencyFormatRp,
AppColor.info,
),
),
],
),
const SizedBox(height: 12),
// Average metrics
Row(
children: [
Expanded(
child: _buildMetricColumn(
'Harga Rata-rata',
product.averagePrice.currencyFormatRp,
AppColor.textSecondary,
),
),
Expanded(
child: _buildMetricColumn(
'Laba per Unit',
product.profitPerUnit.currencyFormatRp,
AppColor.primary,
),
),
const Expanded(child: SizedBox()),
],
),
],
),
);
}
Widget _buildMetricColumn(String label, String value, Color color) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: AppStyle.xs.copyWith(color: AppColor.textSecondary)),
const SizedBox(height: 2),
Text(
value,
style: AppStyle.sm.copyWith(
fontWeight: FontWeight.w600,
color: color,
),
),
],
);
}
}

View File

@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:line_icons/line_icons.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/analytic/analytic.dart';
class FinanceProfitLoss extends StatelessWidget {
const FinanceProfitLoss({super.key});
final ProfitLossSummary data;
const FinanceProfitLoss({super.key, required this.data});
@override
Widget build(BuildContext context) {
@ -49,52 +53,77 @@ class FinanceProfitLoss extends StatelessWidget {
),
const SizedBox(height: 20),
// Total Revenue (Penjualan Kotor)
_buildPLItem(
'Penjualan Kotor',
'Rp 25.840.000',
data.totalRevenue.currencyFormatRp,
AppColor.success,
true,
),
_buildPLItem('Diskon & Retur', '- Rp 560.000', AppColor.error, false),
// Discount (Diskon & Retur)
_buildPLItem(
'Diskon & Retur',
'- ${data.totalDiscount.currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Net Sales (Penjualan Bersih = Total Revenue - Discount)
_buildPLItem(
'Penjualan Bersih',
'Rp 25.280.000',
(data.totalRevenue - data.totalDiscount).currencyFormatRp,
AppColor.textPrimary,
true,
isHeader: true,
),
const SizedBox(height: 12),
// Cost of Goods Sold (HPP)
_buildPLItem(
'HPP (Harga Pokok Penjualan)',
'- Rp 15.120.000',
'- ${data.totalCost.currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Gross Profit (Laba Kotor)
_buildPLItem(
'Laba Kotor',
'Rp 10.160.000',
data.grossProfit.currencyFormatRp,
AppColor.success,
true,
isHeader: true,
showPercentage: true,
percentage: '${data.grossProfitMargin.toStringAsFixed(1)}%',
),
const SizedBox(height: 12),
// Operational Cost (Biaya Operasional) - calculated as difference
_buildPLItem(
'Biaya Operasional',
'- Rp 2.640.000',
'- ${_calculateOperationalCost().currencyFormatRp}',
AppColor.error,
false,
),
const Divider(height: 24),
// Net Profit (Laba Bersih)
_buildPLItem(
'Laba Bersih',
'Rp 7.520.000',
data.netProfit.currencyFormatRp,
AppColor.primary,
true,
isHeader: true,
showPercentage: true,
percentage: '29.8%',
percentage: '${data.netProfitMargin.round()}%',
),
],
),
@ -155,4 +184,9 @@ class FinanceProfitLoss extends StatelessWidget {
),
);
}
// Calculate operational cost as the difference between gross profit and net profit
int _calculateOperationalCost() {
return data.grossProfit - data.netProfit - data.totalTax;
}
}

View File

@ -9,7 +9,6 @@ class FinanceSummaryCard extends StatelessWidget {
required this.amount,
required this.icon,
required this.color,
required this.change,
required this.isPositive,
});
@ -17,7 +16,6 @@ class FinanceSummaryCard extends StatelessWidget {
final String amount;
final IconData icon;
final Color color;
final String change;
final bool isPositive;
@override
@ -50,22 +48,6 @@ class FinanceSummaryCard extends StatelessWidget {
),
child: Icon(icon, color: color, size: 20),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isPositive
? AppColor.success.withOpacity(0.1)
: AppColor.error.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
change,
style: AppStyle.xs.copyWith(
color: isPositive ? AppColor.success : AppColor.error,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 12),