diff --git a/lib/data/datasources/analytic_remote_datasource.dart b/lib/data/datasources/analytic_remote_datasource.dart index f875f5b..b538034 100644 --- a/lib/data/datasources/analytic_remote_datasource.dart +++ b/lib/data/datasources/analytic_remote_datasource.dart @@ -8,6 +8,7 @@ import 'package:enaklo_pos/data/datasources/auth_local_datasource.dart'; import 'package:enaklo_pos/data/models/response/dashboard_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/payment_method_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/product_analytic_response_model.dart'; +import 'package:enaklo_pos/data/models/response/profit_loss_response_model.dart'; import 'package:enaklo_pos/data/models/response/sales_analytic_response_model.dart'; import 'package:intl/intl.dart'; @@ -149,4 +150,38 @@ class AnalyticRemoteDatasource { return left('Unexpected error occurred'); } } + + Future> getProfitLoss({ + required DateTime dateFrom, + required DateTime dateTo, + }) async { + final authData = await AuthLocalDataSource().getAuthData(); + final headers = { + 'Authorization': 'Bearer ${authData.token}', + 'Accept': 'application/json', + }; + + try { + final response = await dio.get( + '${Variables.baseUrl}/api/v1/analytics/profit-loss', + queryParameters: { + 'date_from': DateFormat('dd-MM-yyyy').format(dateFrom), + 'date_to': DateFormat('dd-MM-yyyy').format(dateTo), + }, + options: Options(headers: headers), + ); + + if (response.statusCode == 200) { + return right(ProfitLossResponseModel.fromMap(response.data)); + } else { + return left('Terjadi Kesalahan, Coba lagi nanti.'); + } + } on DioException catch (e) { + log('Dio error: ${e.message}'); + return left(e.response?.data.toString() ?? e.message ?? 'Unknown error'); + } catch (e) { + log('Unexpected error: $e'); + return left('Unexpected error occurred'); + } + } } diff --git a/lib/data/models/response/profit_loss_response_model.dart b/lib/data/models/response/profit_loss_response_model.dart new file mode 100644 index 0000000..0f6f152 --- /dev/null +++ b/lib/data/models/response/profit_loss_response_model.dart @@ -0,0 +1,273 @@ +class ProfitLossResponseModel { + final bool success; + final ProfitLossData data; + final dynamic errors; + + ProfitLossResponseModel({ + required this.success, + required this.data, + this.errors, + }); + + /// Dari JSON ke model (misal response dari API) + factory ProfitLossResponseModel.fromJson(Map json) { + return ProfitLossResponseModel( + success: json['success'], + data: ProfitLossData.fromMap(json['data']), + errors: json['errors'], + ); + } + + /// Dari model ke JSON (misal saat kirim ke API) + Map toJson() { + return { + 'success': success, + 'data': data.toMap(), + 'errors': errors, + }; + } + + /// Alias dari fromJson, kadang dibutuhkan untuk naming yang konsisten + factory ProfitLossResponseModel.fromMap(Map map) { + return ProfitLossResponseModel( + success: map['success'], + data: ProfitLossData.fromMap(map['data']), + errors: map['errors'], + ); + } + + /// Alias dari toJson + Map toMap() { + return { + 'success': success, + 'data': data.toMap(), + 'errors': errors, + }; + } +} + +class ProfitLossData { + final String organizationId; + final String dateFrom; + final String dateTo; + final String groupBy; + final ProfitLossSummary summary; + final List data; + final List productData; + + ProfitLossData({ + required this.organizationId, + required this.dateFrom, + required this.dateTo, + required this.groupBy, + required this.summary, + required this.data, + required this.productData, + }); + + factory ProfitLossData.fromMap(Map map) { + return ProfitLossData( + organizationId: map['organization_id'], + dateFrom: map['date_from'], + dateTo: map['date_to'], + groupBy: map['group_by'], + summary: ProfitLossSummary.fromMap(map['summary']), + data: List.from( + map['data'].map((x) => ProfitLossItem.fromMap(x))), + productData: List.from( + map['product_data'].map((x) => ProfitLossProduct.fromMap(x))), + ); + } + + Map toMap() { + return { + 'organization_id': organizationId, + 'date_from': dateFrom, + 'date_to': dateTo, + 'group_by': groupBy, + 'summary': summary.toMap(), + 'data': data.map((x) => x.toMap()).toList(), + 'product_data': productData.map((x) => x.toMap()).toList(), + }; + } +} + +class ProfitLossSummary { + final int totalRevenue; + final int totalCost; + final int grossProfit; + final double grossProfitMargin; + final int totalTax; + final int totalDiscount; + final int netProfit; + final double netProfitMargin; + final int totalOrders; + final int averageProfit; + final double profitabilityRatio; + + ProfitLossSummary({ + required this.totalRevenue, + required this.totalCost, + required this.grossProfit, + required this.grossProfitMargin, + required this.totalTax, + required this.totalDiscount, + required this.netProfit, + required this.netProfitMargin, + required this.totalOrders, + required this.averageProfit, + required this.profitabilityRatio, + }); + + factory ProfitLossSummary.fromMap(Map map) { + return ProfitLossSummary( + totalRevenue: map['total_revenue'], + totalCost: map['total_cost'], + grossProfit: map['gross_profit'], + grossProfitMargin: (map['gross_profit_margin'] as num).toDouble(), + totalTax: map['total_tax'], + totalDiscount: map['total_discount'], + netProfit: map['net_profit'], + netProfitMargin: (map['net_profit_margin'] as num).toDouble(), + totalOrders: map['total_orders'], + averageProfit: map['average_profit'], + profitabilityRatio: (map['profitability_ratio'] as num).toDouble(), + ); + } + + Map toMap() { + return { + 'total_revenue': totalRevenue, + 'total_cost': totalCost, + 'gross_profit': grossProfit, + 'gross_profit_margin': grossProfitMargin, + 'total_tax': totalTax, + 'total_discount': totalDiscount, + 'net_profit': netProfit, + 'net_profit_margin': netProfitMargin, + 'total_orders': totalOrders, + 'average_profit': averageProfit, + 'profitability_ratio': profitabilityRatio, + }; + } +} + +class ProfitLossItem { + final String date; + final int revenue; + final int cost; + final int grossProfit; + final double grossProfitMargin; + final int tax; + final int discount; + final int netProfit; + final double netProfitMargin; + final int orders; + + ProfitLossItem({ + required this.date, + required this.revenue, + required this.cost, + required this.grossProfit, + required this.grossProfitMargin, + required this.tax, + required this.discount, + required this.netProfit, + required this.netProfitMargin, + required this.orders, + }); + + factory ProfitLossItem.fromMap(Map map) { + return ProfitLossItem( + date: map['date'], + revenue: map['revenue'], + cost: map['cost'], + grossProfit: map['gross_profit'], + grossProfitMargin: (map['gross_profit_margin'] as num).toDouble(), + tax: map['tax'], + discount: map['discount'], + netProfit: map['net_profit'], + netProfitMargin: (map['net_profit_margin'] as num).toDouble(), + orders: map['orders'], + ); + } + + Map toMap() { + return { + 'date': date, + 'revenue': revenue, + 'cost': cost, + 'gross_profit': grossProfit, + 'gross_profit_margin': grossProfitMargin, + 'tax': tax, + 'discount': discount, + 'net_profit': netProfit, + 'net_profit_margin': netProfitMargin, + 'orders': orders, + }; + } +} + +class ProfitLossProduct { + final String productId; + final String productName; + final String categoryId; + final String categoryName; + final int quantitySold; + final int revenue; + final int cost; + final int grossProfit; + final double grossProfitMargin; + final int averagePrice; + final int averageCost; + final int profitPerUnit; + + ProfitLossProduct({ + required this.productId, + required this.productName, + required this.categoryId, + required this.categoryName, + required this.quantitySold, + required this.revenue, + required this.cost, + required this.grossProfit, + required this.grossProfitMargin, + required this.averagePrice, + required this.averageCost, + required this.profitPerUnit, + }); + + factory ProfitLossProduct.fromMap(Map map) { + return ProfitLossProduct( + productId: map['product_id'], + productName: map['product_name'], + categoryId: map['category_id'], + categoryName: map['category_name'], + quantitySold: map['quantity_sold'], + revenue: map['revenue'], + cost: map['cost'], + grossProfit: map['gross_profit'], + grossProfitMargin: (map['gross_profit_margin'] as num).toDouble(), + averagePrice: map['average_price'], + averageCost: map['average_cost'], + profitPerUnit: map['profit_per_unit'], + ); + } + + Map toMap() { + return { + 'product_id': productId, + 'product_name': productName, + 'category_id': categoryId, + 'category_name': categoryName, + 'quantity_sold': quantitySold, + 'revenue': revenue, + 'cost': cost, + 'gross_profit': grossProfit, + 'gross_profit_margin': grossProfitMargin, + 'average_price': averagePrice, + 'average_cost': averageCost, + 'profit_per_unit': profitPerUnit, + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index 2573015..d7b0743 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -14,6 +14,7 @@ import 'package:enaklo_pos/presentation/home/bloc/outlet_loader/outlet_loader_bl import 'package:enaklo_pos/presentation/home/bloc/product_loader/product_loader_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/user_update_outlet/user_update_outlet_bloc.dart'; import 'package:enaklo_pos/presentation/refund/bloc/refund_bloc.dart'; +import 'package:enaklo_pos/presentation/report/blocs/profit_loss/profit_loss_bloc.dart'; import 'package:enaklo_pos/presentation/sales/blocs/order_loader/order_loader_bloc.dart'; import 'package:enaklo_pos/presentation/sales/blocs/payment_form/payment_form_bloc.dart'; import 'package:enaklo_pos/presentation/setting/bloc/upload_file/upload_file_bloc.dart'; @@ -191,6 +192,9 @@ class _MyAppState extends State { BlocProvider( create: (context) => SummaryBloc(AnalyticRemoteDatasource()), ), + BlocProvider( + create: (context) => ProfitLossBloc(AnalyticRemoteDatasource()), + ), BlocProvider( create: (context) => ProductSalesBloc(AnalyticRemoteDatasource()), ), diff --git a/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.dart b/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.dart new file mode 100644 index 0000000..5b5c4c4 --- /dev/null +++ b/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.dart @@ -0,0 +1,22 @@ +import 'package:bloc/bloc.dart'; +import 'package:enaklo_pos/data/datasources/analytic_remote_datasource.dart'; +import 'package:enaklo_pos/data/models/response/profit_loss_response_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'profit_loss_event.dart'; +part 'profit_loss_state.dart'; +part 'profit_loss_bloc.freezed.dart'; + +class ProfitLossBloc extends Bloc { + final AnalyticRemoteDatasource datasource; + ProfitLossBloc(this.datasource) : super(ProfitLossState.initial()) { + on<_GetProfitLoss>((event, emit) async { + emit(const _Loading()); + final result = await datasource.getProfitLoss( + dateFrom: event.startDate, + dateTo: event.endDate, + ); + result.fold((l) => emit(_Error(l)), (r) => emit(_Success(r.data))); + }); + } +} diff --git a/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.freezed.dart b/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.freezed.dart new file mode 100644 index 0000000..6a1beeb --- /dev/null +++ b/lib/presentation/report/blocs/profit_loss/profit_loss_bloc.freezed.dart @@ -0,0 +1,864 @@ +// 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_bloc.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(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 _$ProfitLossEvent { + DateTime get startDate => throw _privateConstructorUsedError; + DateTime get endDate => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime startDate, DateTime endDate) + getProfitLoss, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime startDate, DateTime endDate)? getProfitLoss, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime startDate, DateTime endDate)? getProfitLoss, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_GetProfitLoss value) getProfitLoss, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_GetProfitLoss value)? getProfitLoss, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_GetProfitLoss value)? getProfitLoss, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + /// Create a copy of ProfitLossEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $ProfitLossEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProfitLossEventCopyWith<$Res> { + factory $ProfitLossEventCopyWith( + ProfitLossEvent value, $Res Function(ProfitLossEvent) then) = + _$ProfitLossEventCopyWithImpl<$Res, ProfitLossEvent>; + @useResult + $Res call({DateTime startDate, DateTime endDate}); +} + +/// @nodoc +class _$ProfitLossEventCopyWithImpl<$Res, $Val extends ProfitLossEvent> + implements $ProfitLossEventCopyWith<$Res> { + _$ProfitLossEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProfitLossEvent + /// 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 _$$GetProfitLossImplCopyWith<$Res> + implements $ProfitLossEventCopyWith<$Res> { + factory _$$GetProfitLossImplCopyWith( + _$GetProfitLossImpl value, $Res Function(_$GetProfitLossImpl) then) = + __$$GetProfitLossImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime startDate, DateTime endDate}); +} + +/// @nodoc +class __$$GetProfitLossImplCopyWithImpl<$Res> + extends _$ProfitLossEventCopyWithImpl<$Res, _$GetProfitLossImpl> + implements _$$GetProfitLossImplCopyWith<$Res> { + __$$GetProfitLossImplCopyWithImpl( + _$GetProfitLossImpl _value, $Res Function(_$GetProfitLossImpl) _then) + : super(_value, _then); + + /// Create a copy of ProfitLossEvent + /// 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(_$GetProfitLossImpl( + null == startDate + ? _value.startDate + : startDate // ignore: cast_nullable_to_non_nullable + as DateTime, + null == endDate + ? _value.endDate + : endDate // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc + +class _$GetProfitLossImpl implements _GetProfitLoss { + const _$GetProfitLossImpl(this.startDate, this.endDate); + + @override + final DateTime startDate; + @override + final DateTime endDate; + + @override + String toString() { + return 'ProfitLossEvent.getProfitLoss(startDate: $startDate, endDate: $endDate)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GetProfitLossImpl && + (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 ProfitLossEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GetProfitLossImplCopyWith<_$GetProfitLossImpl> get copyWith => + __$$GetProfitLossImplCopyWithImpl<_$GetProfitLossImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime startDate, DateTime endDate) + getProfitLoss, + }) { + return getProfitLoss(startDate, endDate); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime startDate, DateTime endDate)? getProfitLoss, + }) { + return getProfitLoss?.call(startDate, endDate); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime startDate, DateTime endDate)? getProfitLoss, + required TResult orElse(), + }) { + if (getProfitLoss != null) { + return getProfitLoss(startDate, endDate); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_GetProfitLoss value) getProfitLoss, + }) { + return getProfitLoss(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_GetProfitLoss value)? getProfitLoss, + }) { + return getProfitLoss?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_GetProfitLoss value)? getProfitLoss, + required TResult orElse(), + }) { + if (getProfitLoss != null) { + return getProfitLoss(this); + } + return orElse(); + } +} + +abstract class _GetProfitLoss implements ProfitLossEvent { + const factory _GetProfitLoss( + final DateTime startDate, final DateTime endDate) = _$GetProfitLossImpl; + + @override + DateTime get startDate; + @override + DateTime get endDate; + + /// Create a copy of ProfitLossEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GetProfitLossImplCopyWith<_$GetProfitLossImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$ProfitLossState { + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(ProfitLossData data) success, + required TResult Function(String message) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(ProfitLossData data)? success, + TResult? Function(String message)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(ProfitLossData data)? success, + TResult Function(String message)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Success value) success, + required TResult Function(_Error value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Success value)? success, + TResult? Function(_Error value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Success value)? success, + TResult Function(_Error value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $ProfitLossStateCopyWith<$Res> { + factory $ProfitLossStateCopyWith( + ProfitLossState value, $Res Function(ProfitLossState) then) = + _$ProfitLossStateCopyWithImpl<$Res, ProfitLossState>; +} + +/// @nodoc +class _$ProfitLossStateCopyWithImpl<$Res, $Val extends ProfitLossState> + implements $ProfitLossStateCopyWith<$Res> { + _$ProfitLossStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc +abstract class _$$InitialImplCopyWith<$Res> { + factory _$$InitialImplCopyWith( + _$InitialImpl value, $Res Function(_$InitialImpl) then) = + __$$InitialImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$InitialImplCopyWithImpl<$Res> + extends _$ProfitLossStateCopyWithImpl<$Res, _$InitialImpl> + implements _$$InitialImplCopyWith<$Res> { + __$$InitialImplCopyWithImpl( + _$InitialImpl _value, $Res Function(_$InitialImpl) _then) + : super(_value, _then); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$InitialImpl implements _Initial { + const _$InitialImpl(); + + @override + String toString() { + return 'ProfitLossState.initial()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$InitialImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(ProfitLossData data) success, + required TResult Function(String message) error, + }) { + return initial(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(ProfitLossData data)? success, + TResult? Function(String message)? error, + }) { + return initial?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(ProfitLossData data)? success, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (initial != null) { + return initial(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Success value) success, + required TResult Function(_Error value) error, + }) { + return initial(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Success value)? success, + TResult? Function(_Error value)? error, + }) { + return initial?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Success value)? success, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (initial != null) { + return initial(this); + } + return orElse(); + } +} + +abstract class _Initial implements ProfitLossState { + const factory _Initial() = _$InitialImpl; +} + +/// @nodoc +abstract class _$$LoadingImplCopyWith<$Res> { + factory _$$LoadingImplCopyWith( + _$LoadingImpl value, $Res Function(_$LoadingImpl) then) = + __$$LoadingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$LoadingImplCopyWithImpl<$Res> + extends _$ProfitLossStateCopyWithImpl<$Res, _$LoadingImpl> + implements _$$LoadingImplCopyWith<$Res> { + __$$LoadingImplCopyWithImpl( + _$LoadingImpl _value, $Res Function(_$LoadingImpl) _then) + : super(_value, _then); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$LoadingImpl implements _Loading { + const _$LoadingImpl(); + + @override + String toString() { + return 'ProfitLossState.loading()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$LoadingImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(ProfitLossData data) success, + required TResult Function(String message) error, + }) { + return loading(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(ProfitLossData data)? success, + TResult? Function(String message)? error, + }) { + return loading?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(ProfitLossData data)? success, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Success value) success, + required TResult Function(_Error value) error, + }) { + return loading(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Success value)? success, + TResult? Function(_Error value)? error, + }) { + return loading?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Success value)? success, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(this); + } + return orElse(); + } +} + +abstract class _Loading implements ProfitLossState { + const factory _Loading() = _$LoadingImpl; +} + +/// @nodoc +abstract class _$$SuccessImplCopyWith<$Res> { + factory _$$SuccessImplCopyWith( + _$SuccessImpl value, $Res Function(_$SuccessImpl) then) = + __$$SuccessImplCopyWithImpl<$Res>; + @useResult + $Res call({ProfitLossData data}); +} + +/// @nodoc +class __$$SuccessImplCopyWithImpl<$Res> + extends _$ProfitLossStateCopyWithImpl<$Res, _$SuccessImpl> + implements _$$SuccessImplCopyWith<$Res> { + __$$SuccessImplCopyWithImpl( + _$SuccessImpl _value, $Res Function(_$SuccessImpl) _then) + : super(_value, _then); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? data = null, + }) { + return _then(_$SuccessImpl( + null == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as ProfitLossData, + )); + } +} + +/// @nodoc + +class _$SuccessImpl implements _Success { + const _$SuccessImpl(this.data); + + @override + final ProfitLossData data; + + @override + String toString() { + return 'ProfitLossState.success(data: $data)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$SuccessImpl && + (identical(other.data, data) || other.data == data)); + } + + @override + int get hashCode => Object.hash(runtimeType, data); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$SuccessImplCopyWith<_$SuccessImpl> get copyWith => + __$$SuccessImplCopyWithImpl<_$SuccessImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(ProfitLossData data) success, + required TResult Function(String message) error, + }) { + return success(data); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(ProfitLossData data)? success, + TResult? Function(String message)? error, + }) { + return success?.call(data); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(ProfitLossData data)? success, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (success != null) { + return success(data); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Success value) success, + required TResult Function(_Error value) error, + }) { + return success(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Success value)? success, + TResult? Function(_Error value)? error, + }) { + return success?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Success value)? success, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (success != null) { + return success(this); + } + return orElse(); + } +} + +abstract class _Success implements ProfitLossState { + const factory _Success(final ProfitLossData data) = _$SuccessImpl; + + ProfitLossData get data; + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$SuccessImplCopyWith<_$SuccessImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ErrorImplCopyWith<$Res> { + factory _$$ErrorImplCopyWith( + _$ErrorImpl value, $Res Function(_$ErrorImpl) then) = + __$$ErrorImplCopyWithImpl<$Res>; + @useResult + $Res call({String message}); +} + +/// @nodoc +class __$$ErrorImplCopyWithImpl<$Res> + extends _$ProfitLossStateCopyWithImpl<$Res, _$ErrorImpl> + implements _$$ErrorImplCopyWith<$Res> { + __$$ErrorImplCopyWithImpl( + _$ErrorImpl _value, $Res Function(_$ErrorImpl) _then) + : super(_value, _then); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? message = null, + }) { + return _then(_$ErrorImpl( + null == message + ? _value.message + : message // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc + +class _$ErrorImpl implements _Error { + const _$ErrorImpl(this.message); + + @override + final String message; + + @override + String toString() { + return 'ProfitLossState.error(message: $message)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$ErrorImpl && + (identical(other.message, message) || other.message == message)); + } + + @override + int get hashCode => Object.hash(runtimeType, message); + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$ErrorImplCopyWith<_$ErrorImpl> get copyWith => + __$$ErrorImplCopyWithImpl<_$ErrorImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(ProfitLossData data) success, + required TResult Function(String message) error, + }) { + return error(message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(ProfitLossData data)? success, + TResult? Function(String message)? error, + }) { + return error?.call(message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(ProfitLossData data)? success, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(message); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Success value) success, + required TResult Function(_Error value) error, + }) { + return error(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Success value)? success, + TResult? Function(_Error value)? error, + }) { + return error?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Success value)? success, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class _Error implements ProfitLossState { + const factory _Error(final String message) = _$ErrorImpl; + + String get message; + + /// Create a copy of ProfitLossState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ErrorImplCopyWith<_$ErrorImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/presentation/report/blocs/profit_loss/profit_loss_event.dart b/lib/presentation/report/blocs/profit_loss/profit_loss_event.dart new file mode 100644 index 0000000..9a015b9 --- /dev/null +++ b/lib/presentation/report/blocs/profit_loss/profit_loss_event.dart @@ -0,0 +1,9 @@ +part of 'profit_loss_bloc.dart'; + +@freezed +class ProfitLossEvent with _$ProfitLossEvent { + const factory ProfitLossEvent.getProfitLoss( + DateTime startDate, + DateTime endDate, + ) = _GetProfitLoss; +} diff --git a/lib/presentation/report/blocs/profit_loss/profit_loss_state.dart b/lib/presentation/report/blocs/profit_loss/profit_loss_state.dart new file mode 100644 index 0000000..8388e6c --- /dev/null +++ b/lib/presentation/report/blocs/profit_loss/profit_loss_state.dart @@ -0,0 +1,9 @@ +part of 'profit_loss_bloc.dart'; + +@freezed +class ProfitLossState with _$ProfitLossState { + const factory ProfitLossState.initial() = _Initial; + const factory ProfitLossState.loading() = _Loading; + const factory ProfitLossState.success(ProfitLossData data) = _Success; + const factory ProfitLossState.error(String message) = _Error; +} diff --git a/lib/presentation/report/pages/report_page.dart b/lib/presentation/report/pages/report_page.dart index 63a8813..4f1778c 100644 --- a/lib/presentation/report/pages/report_page.dart +++ b/lib/presentation/report/pages/report_page.dart @@ -1,7 +1,9 @@ import 'dart:developer'; import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/presentation/report/blocs/profit_loss/profit_loss_bloc.dart'; import 'package:enaklo_pos/presentation/report/widgets/dashboard_analytic_widget.dart'; +import 'package:enaklo_pos/presentation/report/widgets/profit_loss_widget.dart'; import 'package:enaklo_pos/presentation/sales/pages/sales_page.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:enaklo_pos/core/components/custom_date_picker.dart'; @@ -191,6 +193,23 @@ class _ReportPageState extends State { }, isActive: selectedMenu == 4, ), + ReportMenu( + label: 'Laporan untung rugi', + subtitle: 'Laporan untung rugi penjualan.', + icon: Icons.trending_down, + onPressed: () { + selectedMenu = 5; + title = 'Laporan untung rugi'; + setState(() {}); + context.read().add( + ProfitLossEvent.getProfitLoss( + fromDate, + toDate, + ), + ); + }, + isActive: selectedMenu == 5, + ), ], ), ), @@ -318,7 +337,30 @@ class _ReportPageState extends State { ); }, ) - : const SizedBox.shrink()), + : selectedMenu == 5 + ? BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => const Center( + child: + CircularProgressIndicator(), + ), + error: (message) { + return Text(message); + }, + success: (data) { + return ProfitLossWidget( + data: data, + title: title, + searchDateFormatted: + searchDateFormatted, + ); + }, + ); + }, + ) + : const SizedBox.shrink()), ], ), ), diff --git a/lib/presentation/report/widgets/profit_loss_widget.dart b/lib/presentation/report/widgets/profit_loss_widget.dart new file mode 100644 index 0000000..b773b38 --- /dev/null +++ b/lib/presentation/report/widgets/profit_loss_widget.dart @@ -0,0 +1,1135 @@ +import 'package:enaklo_pos/data/models/response/profit_loss_response_model.dart'; +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'package:intl/intl.dart'; + +// Warna Aplikasi +class AppColorProfitLoss { + static const primary = Color(0xff36175e); + static const secondary = Color(0xff7c3aed); + static const success = Color(0xff10b981); + static const warning = Color(0xfff59e0b); + static const danger = Color(0xffef4444); + static const info = Color(0xff3b82f6); +} + +class ProfitLossWidget extends StatelessWidget { + final ProfitLossData data; + final String title; + final String searchDateFormatted; + + const ProfitLossWidget({ + super.key, + required this.data, + required this.title, + required this.searchDateFormatted, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: const Color(0xFFF1F5F9), + padding: const EdgeInsets.all(20.0), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 20), + _buildSummaryCards(), + const SizedBox(height: 20), + _buildProfitTrendChart(), + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 3, + child: _buildProductProfitability(), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: _buildProfitBreakdown(), + ), + ], + ), + const SizedBox(height: 20), + _buildDetailedMetrics(), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 6), + Text( + 'Analisis profitabilitas dan margin keuntungan', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColorProfitLoss.primary, + AppColorProfitLoss.secondary + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.date_range, color: Colors.white, size: 18), + const SizedBox(width: 8), + Text( + searchDateFormatted, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + // Fungsi untuk menghitung tren berdasarkan data historis + String _calculateTrend(String metric) { + if (data.data.length < 2) return '+0.0%'; + + double current = 0; + double previous = 0; + + // Ambil data dari beberapa hari terakhir untuk perhitungan tren + final dataLength = data.data.length; + final splitPoint = + (dataLength * 0.6).round(); // 60% data lama, 40% data baru + + final recentData = data.data.skip(splitPoint).toList(); + final olderData = data.data.take(splitPoint).toList(); + + if (recentData.isEmpty || olderData.isEmpty) return '+0.0%'; + + switch (metric) { + case 'revenue': + current = recentData.fold(0.0, (sum, item) => sum + item.revenue) / + recentData.length; + previous = olderData.fold(0.0, (sum, item) => sum + item.revenue) / + olderData.length; + break; + case 'cost': + current = recentData.fold(0.0, (sum, item) => sum + item.cost) / + recentData.length; + previous = olderData.fold(0.0, (sum, item) => sum + item.cost) / + olderData.length; + break; + case 'grossProfit': + current = recentData.fold(0.0, (sum, item) => sum + item.grossProfit) / + recentData.length; + previous = olderData.fold(0.0, (sum, item) => sum + item.grossProfit) / + olderData.length; + break; + case 'netProfit': + current = recentData.fold(0.0, (sum, item) => sum + item.netProfit) / + recentData.length; + previous = olderData.fold(0.0, (sum, item) => sum + item.netProfit) / + olderData.length; + break; + } + + if (previous == 0) return '+0.0%'; + + final trendPercentage = ((current - previous) / previous) * 100; + final sign = trendPercentage >= 0 ? '+' : ''; + + return '$sign${trendPercentage.toStringAsFixed(1)}%'; + } + + Widget _buildSummaryCards() { + final summaryItems = [ + { + 'title': 'Jumlah Pendapatan', + 'value': _formatCurrency(data.summary.totalRevenue), + 'subtitle': '${data.summary.totalOrders} pesanan', + 'icon': Icons.attach_money, + 'color': AppColorProfitLoss.success, + 'trend': _calculateTrend('revenue'), + }, + { + 'title': 'Jumlah Biaya', + 'value': _formatCurrency(data.summary.totalCost), + 'subtitle': 'HPP & Biaya', + 'icon': Icons.receipt, + 'color': AppColorProfitLoss.danger, + 'trend': _calculateTrend('cost'), + }, + { + 'title': 'Laba Kotor', + 'value': _formatCurrency(data.summary.grossProfit), + 'subtitle': + '${data.summary.grossProfitMargin.toStringAsFixed(1)}% margin', + 'icon': Icons.trending_up, + 'color': AppColorProfitLoss.primary, + 'trend': _calculateTrend('grossProfit'), + }, + { + 'title': 'Laba Bersih', + 'value': _formatCurrency(data.summary.netProfit), + 'subtitle': + '${data.summary.netProfitMargin.toStringAsFixed(1)}% margin', + 'icon': Icons.account_balance, + 'color': AppColorProfitLoss.info, + 'trend': _calculateTrend('netProfit'), + }, + ]; + + return Row( + children: summaryItems.map((item) { + final trendValue = item['trend'] as String; + final isPositive = !trendValue.startsWith('-'); + final trendColor = + isPositive ? AppColorProfitLoss.success : AppColorProfitLoss.danger; + + return Expanded( + child: Container( + margin: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: (item['color'] as Color).withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + item['icon'] as IconData, + color: item['color'] as Color, + size: 22, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: trendColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + trendValue, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: trendColor, + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Text( + item['value'] as String, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + item['title'] as String, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 2), + Text( + item['subtitle'] as String, + style: TextStyle( + fontSize: 11, + color: Colors.grey[500], + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + Widget _buildProfitTrendChart() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Analisis Tren Keuntungan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + Row( + children: [ + _buildLegendItem('Pendapatan', AppColorProfitLoss.info), + const SizedBox(width: 16), + _buildLegendItem('Biaya', AppColorProfitLoss.danger), + const SizedBox(width: 16), + _buildLegendItem('Laba Bersih', AppColorProfitLoss.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) { + return Text( + '${(value / 1000).toInt()}K', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + ), + ); + }, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + final index = value.toInt(); + if (index >= 0 && index < data.data.length) { + final date = DateTime.parse(data.data[index].date); + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '${date.day}/${date.month}', + style: TextStyle( + color: Colors.grey[600], + fontSize: 10, + ), + ), + ); + } + return const SizedBox(); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false)), + ), + borderData: FlBorderData(show: false), + lineBarsData: [ + // Garis Pendapatan + LineChartBarData( + spots: data.data.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), entry.value.revenue.toDouble()); + }).toList(), + isCurved: true, + color: AppColorProfitLoss.info, + dotData: const FlDotData(show: false), + ), + // Garis Biaya + LineChartBarData( + spots: data.data.asMap().entries.map((entry) { + return FlSpot( + entry.key.toDouble(), entry.value.cost.toDouble()); + }).toList(), + isCurved: true, + color: AppColorProfitLoss.danger, + dotData: const FlDotData(show: false), + ), + // Garis Laba Bersih + LineChartBarData( + spots: data.data.asMap().entries.map((entry) { + return FlSpot(entry.key.toDouble(), + entry.value.netProfit.toDouble()); + }).toList(), + isCurved: true, + color: AppColorProfitLoss.success, + dotData: const FlDotData(show: true), + belowBarData: BarAreaData( + show: true, + color: AppColorProfitLoss.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: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF64748B), + ), + ), + ], + ); + } + + Widget _buildProductProfitability() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Profitabilitas Produk', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + 'Analisis margin keuntungan per produk', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 16), + Column( + children: data.productData.take(4).map((product) { + return _buildProductItem(product); + }).toList(), + ), + ], + ), + ); + } + + Widget _buildProductItem(ProfitLossProduct product) { + final profitColor = product.grossProfitMargin >= 35 + ? AppColorProfitLoss.success + : product.grossProfitMargin >= 25 + ? AppColorProfitLoss.warning + : AppColorProfitLoss.danger; + + 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: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: AppColorProfitLoss.primary.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + _getProductIcon(product.categoryName), + color: AppColorProfitLoss.primary, + size: 22, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + product.productName, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 2), + Text( + '${product.quantitySold} unit • ${product.categoryName}', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + ], + ), + ), + 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( + '${product.grossProfitMargin.toStringAsFixed(1)}%', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: profitColor, + ), + ), + ), + const SizedBox(height: 4), + Text( + _formatCurrency(product.grossProfit), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF0F172A), + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildProductMetric( + 'Pendapatan', product.revenue, AppColorProfitLoss.info), + ), + const SizedBox(width: 8), + Expanded( + child: _buildProductMetric( + 'Biaya', product.cost, AppColorProfitLoss.danger), + ), + const SizedBox(width: 8), + Expanded( + child: _buildProductMetric('Laba/Unit', product.profitPerUnit, + AppColorProfitLoss.success), + ), + ], + ), + ], + ), + ); + } + + Widget _buildProductMetric(String label, int 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], + ), + ), + const SizedBox(height: 2), + Text( + _formatCurrency(value), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildProfitBreakdown() { + final breakdownData = [ + { + 'label': 'Laba Kotor', + 'value': data.summary.grossProfit, + 'color': AppColorProfitLoss.success + }, + { + 'label': 'Pajak', + 'value': data.summary.totalTax, + 'color': AppColorProfitLoss.warning + }, + { + 'label': 'Diskon', + 'value': data.summary.totalDiscount, + 'color': AppColorProfitLoss.info + }, + { + 'label': 'Laba Bersih', + 'value': data.summary.netProfit, + 'color': AppColorProfitLoss.primary + }, + ]; + + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Rincian Keuntungan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 4), + Text( + 'Komponen pembentuk profit', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 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 percentage = + (item['value'] as int) / data.summary.grossProfit * 100; + + return PieChartSectionData( + color: item['color'] as Color, + value: (item['value'] as int).toDouble(), + title: '${percentage.toStringAsFixed(1)}%', + radius: 40, + titleStyle: const TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ); + }).toList(), + ), + ), + ), + + const SizedBox(height: 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: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + _formatCurrency(item['value'] as int), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + ], + ), + ); + }).toList(), + ), + + const SizedBox(height: 16), + + // Rasio Profitabilitas + Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColorProfitLoss.primary.withOpacity(0.1), + AppColorProfitLoss.secondary.withOpacity(0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + const Text( + 'Rasio Profitabilitas', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 8), + Text( + '${(data.summary.profitabilityRatio * 100).toStringAsFixed(1)}%', + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColorProfitLoss.primary, + ), + ), + const SizedBox(height: 4), + Text( + 'Tingkat Pengembalian Pendapatan', + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDetailedMetrics() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.grey[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Metrik Kinerja Terperinci', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF0F172A), + ), + ), + const SizedBox(height: 16), + + Row( + children: [ + Expanded( + child: _buildMetricCard( + 'Nilai Rata-rata Pesanan', + _formatCurrency( + (data.summary.totalRevenue / data.summary.totalOrders) + .round()), + 'Per transaksi', + Icons.shopping_cart_outlined, + AppColorProfitLoss.info, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + 'Keuntungan Rata-rata', + _formatCurrency(data.summary.averageProfit), + 'Per pesanan', + Icons.trending_up, + AppColorProfitLoss.success, + ), + ), + const SizedBox(width: 12), + Expanded( + child: _buildMetricCard( + 'Rasio Biaya', + '${((data.summary.totalCost / data.summary.totalRevenue) * 100).toStringAsFixed(1)}%', + 'Dari total pendapatan', + Icons.pie_chart, + AppColorProfitLoss.danger, + ), + ), + ], + ), + + const SizedBox(height: 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: AppColorProfitLoss.primary.withOpacity(0.05), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: const Row( + children: [ + Expanded( + flex: 2, + child: Text( + 'Tanggal', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF475569), + ), + ), + ), + Expanded( + child: Text( + 'Pendapatan', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF475569), + ), + ), + ), + Expanded( + child: Text( + 'Biaya', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF475569), + ), + ), + ), + Expanded( + child: Text( + 'Laba Bersih', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF475569), + ), + ), + ), + Expanded( + child: Text( + 'Margin', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFF475569), + ), + ), + ), + ], + ), + ), + ...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: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF0F172A), + ), + ), + ), + Expanded( + child: Text( + _formatCurrencyShort(item.revenue), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColorProfitLoss.info, + ), + ), + ), + Expanded( + child: Text( + _formatCurrencyShort(item.cost), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: AppColorProfitLoss.danger, + ), + ), + ), + Expanded( + child: Text( + _formatCurrencyShort(item.netProfit), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: AppColorProfitLoss.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( + '${item.netProfitMargin.toStringAsFixed(1)}%', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: _getMarginColor(item.netProfitMargin), + ), + ), + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ], + ), + ); + } + + 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), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF475569), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + ), + ), + ], + ), + ); + } + + IconData _getProductIcon(String category) { + switch (category.toLowerCase()) { + case 'coffee': + case 'kopi': + return Icons.local_cafe; + case 'pastry': + case 'kue': + return Icons.cake; + case 'food': + case 'makanan': + return Icons.restaurant; + default: + return Icons.inventory; + } + } + + Color _getMarginColor(double margin) { + if (margin >= 25) return AppColorProfitLoss.success; + if (margin >= 15) return AppColorProfitLoss.warning; + return AppColorProfitLoss.danger; + } + + String _formatCurrency(int amount) { + final formatter = NumberFormat.currency( + locale: 'id_ID', + symbol: 'Rp ', + decimalDigits: 0, + ); + return formatter.format(amount); + } + + String _formatCurrencyShort(int amount) { + if (amount >= 1000000) { + return 'Rp ${(amount / 1000000).toStringAsFixed(1)}M'; + } else if (amount >= 1000) { + return 'Rp ${(amount / 1000).toStringAsFixed(0)}K'; + } + return 'Rp $amount'; + } +}