From 1b1d01c1e8544e84dde428cc8db020e0886b6491 Mon Sep 17 00:00:00 2001 From: efrilm Date: Mon, 4 Aug 2025 23:13:52 +0700 Subject: [PATCH] feat: refund page --- .../datasources/order_remote_datasource.dart | 40 + lib/main.dart | 4 + lib/presentation/refund/bloc/refund_bloc.dart | 40 + .../refund/bloc/refund_bloc.freezed.dart | 855 ++++++++++++++++++ .../refund/bloc/refund_event.dart | 10 + .../refund/bloc/refund_state.dart | 9 + .../refund/dialog/refund_error_dialog.dart | 90 ++ .../refund/dialog/refund_success_dialog.dart | 131 +++ .../refund/pages/refund_page.dart | 818 +++++++++++++++++ .../refund/widgets/refund_appbar.dart | 104 +++ .../refund/widgets/refund_info_tile.dart | 57 ++ .../widgets/refund_order_Item_tile.dart | 130 +++ .../refund/widgets/refund_reason_tile.dart | 53 ++ lib/presentation/sales/pages/sales_page.dart | 40 +- 14 files changed, 2349 insertions(+), 32 deletions(-) create mode 100644 lib/presentation/refund/bloc/refund_bloc.dart create mode 100644 lib/presentation/refund/bloc/refund_bloc.freezed.dart create mode 100644 lib/presentation/refund/bloc/refund_event.dart create mode 100644 lib/presentation/refund/bloc/refund_state.dart create mode 100644 lib/presentation/refund/dialog/refund_error_dialog.dart create mode 100644 lib/presentation/refund/dialog/refund_success_dialog.dart create mode 100644 lib/presentation/refund/pages/refund_page.dart create mode 100644 lib/presentation/refund/widgets/refund_appbar.dart create mode 100644 lib/presentation/refund/widgets/refund_info_tile.dart create mode 100644 lib/presentation/refund/widgets/refund_order_Item_tile.dart create mode 100644 lib/presentation/refund/widgets/refund_reason_tile.dart diff --git a/lib/data/datasources/order_remote_datasource.dart b/lib/data/datasources/order_remote_datasource.dart index 6f14843..ea1346c 100644 --- a/lib/data/datasources/order_remote_datasource.dart +++ b/lib/data/datasources/order_remote_datasource.dart @@ -527,4 +527,44 @@ class OrderRemoteDatasource { return const Left('Terjadi kesalahan tak terduga'); } } + + Future> refundPayment({ + required String paymentId, + required String reason, + required int refundAmount, + }) async { + final authData = await AuthLocalDataSource().getAuthData(); + final url = '${Variables.baseUrl}/api/v1/payments/$paymentId/refund'; + + try { + final response = await dio.post( + url, + data: { + 'refund_amount': refundAmount, + 'reason': reason, + }, + options: Options( + headers: { + 'Authorization': 'Bearer ${authData.token}', + 'Accept': 'application/json', + 'Content-Type': 'application/json', + }, + ), + ); + + if (response.statusCode == 200) { + return Right(true); + } else { + return const Left('Gagal refund'); + } + } on DioException catch (e) { + final errorMessage = e.response?.data['message'] ?? 'Kesalahan jaringan'; + log("💥 Dio error: ${e.message}"); + log("💥 Dio response: ${e.response?.data}"); + return Left(errorMessage); + } catch (e) { + log("💥 Unexpected error: $e"); + return const Left('Terjadi kesalahan tak terduga'); + } + } } diff --git a/lib/main.dart b/lib/main.dart index 39982eb..be2f27f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -9,6 +9,7 @@ import 'package:enaklo_pos/presentation/home/bloc/current_outlet/current_outlet_ import 'package:enaklo_pos/presentation/home/bloc/order_form/order_form_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/outlet_loader/outlet_loader_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/product_loader/product_loader_bloc.dart'; +import 'package:enaklo_pos/presentation/refund/bloc/refund_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/void/bloc/void_order_bloc.dart'; @@ -256,6 +257,9 @@ class _MyAppState extends State { BlocProvider( create: (context) => VoidOrderBloc(OrderRemoteDatasource()), ), + BlocProvider( + create: (context) => RefundBloc(OrderRemoteDatasource()), + ), ], child: MaterialApp( debugShowCheckedModeBanner: false, diff --git a/lib/presentation/refund/bloc/refund_bloc.dart b/lib/presentation/refund/bloc/refund_bloc.dart new file mode 100644 index 0000000..24098e5 --- /dev/null +++ b/lib/presentation/refund/bloc/refund_bloc.dart @@ -0,0 +1,40 @@ +import 'package:bloc/bloc.dart'; +import 'package:enaklo_pos/data/datasources/order_remote_datasource.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'refund_event.dart'; +part 'refund_state.dart'; +part 'refund_bloc.freezed.dart'; + +class RefundBloc extends Bloc { + final OrderRemoteDatasource _orderRemoteDatasource; + + RefundBloc(this._orderRemoteDatasource) : super(const RefundState.initial()) { + on((event, emit) async { + await event.when( + refundPayment: (paymentId, reason, refundAmount) => + _onRefundPayment(paymentId, reason, refundAmount, emit), + ); + }); + } + + Future _onRefundPayment( + String paymentId, + String reason, + int refundAmount, + Emitter emit, + ) async { + emit(const RefundState.loading()); + + final result = await _orderRemoteDatasource.refundPayment( + paymentId: paymentId, + reason: reason, + refundAmount: refundAmount, + ); + + result.fold( + (error) => emit(RefundState.error(error)), + (success) => emit(const RefundState.success()), + ); + } +} diff --git a/lib/presentation/refund/bloc/refund_bloc.freezed.dart b/lib/presentation/refund/bloc/refund_bloc.freezed.dart new file mode 100644 index 0000000..79f9b00 --- /dev/null +++ b/lib/presentation/refund/bloc/refund_bloc.freezed.dart @@ -0,0 +1,855 @@ +// 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 'refund_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 _$RefundEvent { + String get paymentId => throw _privateConstructorUsedError; + String get reason => throw _privateConstructorUsedError; + int get refundAmount => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(String paymentId, String reason, int refundAmount) + refundPayment, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String paymentId, String reason, int refundAmount)? + refundPayment, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String paymentId, String reason, int refundAmount)? + refundPayment, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_RefundPayment value) refundPayment, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_RefundPayment value)? refundPayment, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_RefundPayment value)? refundPayment, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + /// Create a copy of RefundEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $RefundEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $RefundEventCopyWith<$Res> { + factory $RefundEventCopyWith( + RefundEvent value, $Res Function(RefundEvent) then) = + _$RefundEventCopyWithImpl<$Res, RefundEvent>; + @useResult + $Res call({String paymentId, String reason, int refundAmount}); +} + +/// @nodoc +class _$RefundEventCopyWithImpl<$Res, $Val extends RefundEvent> + implements $RefundEventCopyWith<$Res> { + _$RefundEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RefundEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? paymentId = null, + Object? reason = null, + Object? refundAmount = null, + }) { + return _then(_value.copyWith( + paymentId: null == paymentId + ? _value.paymentId + : paymentId // ignore: cast_nullable_to_non_nullable + as String, + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + refundAmount: null == refundAmount + ? _value.refundAmount + : refundAmount // ignore: cast_nullable_to_non_nullable + as int, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$RefundPaymentImplCopyWith<$Res> + implements $RefundEventCopyWith<$Res> { + factory _$$RefundPaymentImplCopyWith( + _$RefundPaymentImpl value, $Res Function(_$RefundPaymentImpl) then) = + __$$RefundPaymentImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String paymentId, String reason, int refundAmount}); +} + +/// @nodoc +class __$$RefundPaymentImplCopyWithImpl<$Res> + extends _$RefundEventCopyWithImpl<$Res, _$RefundPaymentImpl> + implements _$$RefundPaymentImplCopyWith<$Res> { + __$$RefundPaymentImplCopyWithImpl( + _$RefundPaymentImpl _value, $Res Function(_$RefundPaymentImpl) _then) + : super(_value, _then); + + /// Create a copy of RefundEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? paymentId = null, + Object? reason = null, + Object? refundAmount = null, + }) { + return _then(_$RefundPaymentImpl( + paymentId: null == paymentId + ? _value.paymentId + : paymentId // ignore: cast_nullable_to_non_nullable + as String, + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + refundAmount: null == refundAmount + ? _value.refundAmount + : refundAmount // ignore: cast_nullable_to_non_nullable + as int, + )); + } +} + +/// @nodoc + +class _$RefundPaymentImpl implements _RefundPayment { + const _$RefundPaymentImpl( + {required this.paymentId, + required this.reason, + required this.refundAmount}); + + @override + final String paymentId; + @override + final String reason; + @override + final int refundAmount; + + @override + String toString() { + return 'RefundEvent.refundPayment(paymentId: $paymentId, reason: $reason, refundAmount: $refundAmount)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$RefundPaymentImpl && + (identical(other.paymentId, paymentId) || + other.paymentId == paymentId) && + (identical(other.reason, reason) || other.reason == reason) && + (identical(other.refundAmount, refundAmount) || + other.refundAmount == refundAmount)); + } + + @override + int get hashCode => Object.hash(runtimeType, paymentId, reason, refundAmount); + + /// Create a copy of RefundEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$RefundPaymentImplCopyWith<_$RefundPaymentImpl> get copyWith => + __$$RefundPaymentImplCopyWithImpl<_$RefundPaymentImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String paymentId, String reason, int refundAmount) + refundPayment, + }) { + return refundPayment(paymentId, reason, refundAmount); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String paymentId, String reason, int refundAmount)? + refundPayment, + }) { + return refundPayment?.call(paymentId, reason, refundAmount); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String paymentId, String reason, int refundAmount)? + refundPayment, + required TResult orElse(), + }) { + if (refundPayment != null) { + return refundPayment(paymentId, reason, refundAmount); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_RefundPayment value) refundPayment, + }) { + return refundPayment(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_RefundPayment value)? refundPayment, + }) { + return refundPayment?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_RefundPayment value)? refundPayment, + required TResult orElse(), + }) { + if (refundPayment != null) { + return refundPayment(this); + } + return orElse(); + } +} + +abstract class _RefundPayment implements RefundEvent { + const factory _RefundPayment( + {required final String paymentId, + required final String reason, + required final int refundAmount}) = _$RefundPaymentImpl; + + @override + String get paymentId; + @override + String get reason; + @override + int get refundAmount; + + /// Create a copy of RefundEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$RefundPaymentImplCopyWith<_$RefundPaymentImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$RefundState { + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function() success, + required TResult Function(String message) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function()? success, + TResult? Function(String message)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function()? 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 $RefundStateCopyWith<$Res> { + factory $RefundStateCopyWith( + RefundState value, $Res Function(RefundState) then) = + _$RefundStateCopyWithImpl<$Res, RefundState>; +} + +/// @nodoc +class _$RefundStateCopyWithImpl<$Res, $Val extends RefundState> + implements $RefundStateCopyWith<$Res> { + _$RefundStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of RefundState + /// 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 _$RefundStateCopyWithImpl<$Res, _$InitialImpl> + implements _$$InitialImplCopyWith<$Res> { + __$$InitialImplCopyWithImpl( + _$InitialImpl _value, $Res Function(_$InitialImpl) _then) + : super(_value, _then); + + /// Create a copy of RefundState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$InitialImpl implements _Initial { + const _$InitialImpl(); + + @override + String toString() { + return 'RefundState.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() success, + required TResult Function(String message) error, + }) { + return initial(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function()? success, + TResult? Function(String message)? error, + }) { + return initial?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function()? 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 RefundState { + const factory _Initial() = _$InitialImpl; +} + +/// @nodoc +abstract class _$$LoadingImplCopyWith<$Res> { + factory _$$LoadingImplCopyWith( + _$LoadingImpl value, $Res Function(_$LoadingImpl) then) = + __$$LoadingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$LoadingImplCopyWithImpl<$Res> + extends _$RefundStateCopyWithImpl<$Res, _$LoadingImpl> + implements _$$LoadingImplCopyWith<$Res> { + __$$LoadingImplCopyWithImpl( + _$LoadingImpl _value, $Res Function(_$LoadingImpl) _then) + : super(_value, _then); + + /// Create a copy of RefundState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$LoadingImpl implements _Loading { + const _$LoadingImpl(); + + @override + String toString() { + return 'RefundState.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() success, + required TResult Function(String message) error, + }) { + return loading(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function()? success, + TResult? Function(String message)? error, + }) { + return loading?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function()? 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 RefundState { + const factory _Loading() = _$LoadingImpl; +} + +/// @nodoc +abstract class _$$SuccessImplCopyWith<$Res> { + factory _$$SuccessImplCopyWith( + _$SuccessImpl value, $Res Function(_$SuccessImpl) then) = + __$$SuccessImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$SuccessImplCopyWithImpl<$Res> + extends _$RefundStateCopyWithImpl<$Res, _$SuccessImpl> + implements _$$SuccessImplCopyWith<$Res> { + __$$SuccessImplCopyWithImpl( + _$SuccessImpl _value, $Res Function(_$SuccessImpl) _then) + : super(_value, _then); + + /// Create a copy of RefundState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$SuccessImpl implements _Success { + const _$SuccessImpl(); + + @override + String toString() { + return 'RefundState.success()'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && other is _$SuccessImpl); + } + + @override + int get hashCode => runtimeType.hashCode; + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function() success, + required TResult Function(String message) error, + }) { + return success(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function()? success, + TResult? Function(String message)? error, + }) { + return success?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function()? success, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (success != null) { + return success(); + } + 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 RefundState { + const factory _Success() = _$SuccessImpl; +} + +/// @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 _$RefundStateCopyWithImpl<$Res, _$ErrorImpl> + implements _$$ErrorImplCopyWith<$Res> { + __$$ErrorImplCopyWithImpl( + _$ErrorImpl _value, $Res Function(_$ErrorImpl) _then) + : super(_value, _then); + + /// Create a copy of RefundState + /// 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 'RefundState.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 RefundState + /// 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() success, + required TResult Function(String message) error, + }) { + return error(message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function()? success, + TResult? Function(String message)? error, + }) { + return error?.call(message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function()? 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 RefundState { + const factory _Error(final String message) = _$ErrorImpl; + + String get message; + + /// Create a copy of RefundState + /// 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/refund/bloc/refund_event.dart b/lib/presentation/refund/bloc/refund_event.dart new file mode 100644 index 0000000..2ea1eb0 --- /dev/null +++ b/lib/presentation/refund/bloc/refund_event.dart @@ -0,0 +1,10 @@ +part of 'refund_bloc.dart'; + +@freezed +class RefundEvent with _$RefundEvent { + const factory RefundEvent.refundPayment({ + required String paymentId, + required String reason, + required int refundAmount, + }) = _RefundPayment; +} diff --git a/lib/presentation/refund/bloc/refund_state.dart b/lib/presentation/refund/bloc/refund_state.dart new file mode 100644 index 0000000..5cb1be7 --- /dev/null +++ b/lib/presentation/refund/bloc/refund_state.dart @@ -0,0 +1,9 @@ +part of 'refund_bloc.dart'; + +@freezed +class RefundState with _$RefundState { + const factory RefundState.initial() = _Initial; + const factory RefundState.loading() = _Loading; + const factory RefundState.success() = _Success; + const factory RefundState.error(String message) = _Error; +} diff --git a/lib/presentation/refund/dialog/refund_error_dialog.dart b/lib/presentation/refund/dialog/refund_error_dialog.dart new file mode 100644 index 0000000..dc13519 --- /dev/null +++ b/lib/presentation/refund/dialog/refund_error_dialog.dart @@ -0,0 +1,90 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:flutter/material.dart'; + +class RefundErrorDialog extends StatelessWidget { + final String message; + const RefundErrorDialog({super.key, required this.message}); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + padding: EdgeInsets.all(32), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [Colors.red[50]!, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.red.withOpacity(0.3), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + child: Icon( + Icons.error, + color: Colors.white, + size: 40, + ), + ), + SpaceHeight(24), + Text( + 'Error!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.red[600], + ), + ), + SpaceHeight(12), + Text( + message, + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + SpaceHeight(32), + Container( + width: double.infinity, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.symmetric(vertical: 16), + ), + child: Text( + 'OK', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/refund/dialog/refund_success_dialog.dart b/lib/presentation/refund/dialog/refund_success_dialog.dart new file mode 100644 index 0000000..99535f8 --- /dev/null +++ b/lib/presentation/refund/dialog/refund_success_dialog.dart @@ -0,0 +1,131 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/int_ext.dart'; +import 'package:flutter/material.dart'; + +class RefundSuccessDialog extends StatelessWidget { + final int refundAmount; + final String selectedReason; + const RefundSuccessDialog( + {super.key, required this.refundAmount, required this.selectedReason}); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + padding: EdgeInsets.all(32), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: [Colors.green[50]!, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.3), + blurRadius: 20, + offset: Offset(0, 10), + ), + ], + ), + child: Icon( + Icons.check, + color: Colors.white, + size: 40, + ), + ), + SpaceHeight(24), + Text( + 'Refund Berhasil!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + SpaceHeight(12), + Text( + 'Refund sebesar ${(refundAmount).currencyFormatRpV2} telah diproses.', + style: TextStyle( + color: Colors.grey[600], + fontSize: 16, + ), + textAlign: TextAlign.center, + ), + SpaceHeight(8), + Text( + 'Alasan: $selectedReason', + style: TextStyle( + color: Colors.grey[500], + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + SpaceHeight(32), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () { + Navigator.pop(context); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColors.primary), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.symmetric(vertical: 16), + ), + child: Text( + 'Print Receipt', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + SizedBox(width: 16), + Expanded( + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + Navigator.pop(context); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: EdgeInsets.symmetric(vertical: 16), + ), + child: Text( + 'Selesai', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/refund/pages/refund_page.dart b/lib/presentation/refund/pages/refund_page.dart new file mode 100644 index 0000000..0b17cc0 --- /dev/null +++ b/lib/presentation/refund/pages/refund_page.dart @@ -0,0 +1,818 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/date_time_ext.dart'; +import 'package:enaklo_pos/core/extensions/int_ext.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:enaklo_pos/presentation/refund/bloc/refund_bloc.dart'; +import 'package:enaklo_pos/presentation/refund/dialog/refund_error_dialog.dart'; +import 'package:enaklo_pos/presentation/refund/dialog/refund_success_dialog.dart'; +import 'package:enaklo_pos/presentation/refund/widgets/refund_appbar.dart'; +import 'package:enaklo_pos/presentation/refund/widgets/refund_info_tile.dart'; +import 'package:enaklo_pos/presentation/refund/widgets/refund_order_Item_tile.dart'; +import 'package:enaklo_pos/presentation/refund/widgets/refund_reason_tile.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class RefundPage extends StatefulWidget { + final Order selectedOrder; + + const RefundPage({super.key, required this.selectedOrder}); + + @override + State createState() => _RefundPageState(); +} + +class _RefundPageState extends State with TickerProviderStateMixin { + final TextEditingController _reasonController = TextEditingController(); + final TextEditingController _refundAmountController = TextEditingController(); + final ScrollController _leftPanelScrollController = ScrollController(); + final ScrollController _rightPanelScrollController = ScrollController(); + final ScrollController _itemsScrollController = ScrollController(); + + String selectedReason = 'Barang Rusak'; + + late AnimationController _slideController; + late AnimationController _fadeController; + late AnimationController _scaleController; + late Animation _slideAnimation; + late Animation _fadeAnimation; + late Animation _scaleAnimation; + + final List> refundReasons = [ + {'value': 'Barang Rusak', 'icon': Icons.broken_image, 'color': Colors.red}, + {'value': 'Salah Item', 'icon': Icons.swap_horiz, 'color': Colors.orange}, + { + 'value': 'Tidak Sesuai Pesanan', + 'icon': Icons.error_outline, + 'color': Colors.amber + }, + { + 'value': 'Permintaan Customer', + 'icon': Icons.person, + 'color': Colors.blue + }, + { + 'value': 'Kualitas Tidak Baik', + 'icon': Icons.thumb_down, + 'color': Colors.purple + }, + {'value': 'Lainnya', 'icon': Icons.more_horiz, 'color': Colors.grey}, + ]; + + @override + void initState() { + super.initState(); + _initializeAnimations(); + _refundAmountController.text = + (widget.selectedOrder.totalAmount ?? 0).toString(); + } + + void _initializeAnimations() { + _slideController = AnimationController( + duration: Duration(milliseconds: 1200), + vsync: this, + ); + _fadeController = AnimationController( + duration: Duration(milliseconds: 800), + vsync: this, + ); + _scaleController = AnimationController( + duration: Duration(milliseconds: 600), + vsync: this, + ); + + _slideAnimation = Tween( + begin: Offset(0.0, 1.0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _slideController, + curve: Curves.elasticOut, + )); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _fadeController, + curve: Curves.easeInOut, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _scaleController, + curve: Curves.elasticOut, + )); + + _fadeController.forward(); + _slideController.forward(); + _scaleController.forward(); + } + + @override + void dispose() { + _slideController.dispose(); + _fadeController.dispose(); + _scaleController.dispose(); + _reasonController.dispose(); + _refundAmountController.dispose(); + _leftPanelScrollController.dispose(); + _rightPanelScrollController.dispose(); + _itemsScrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + state.when( + initial: () {}, + loading: () {}, + success: () { + _showSuccessDialog(); + }, + error: (message) { + _showErrorDialog(message); + }, + ); + }, + child: Scaffold( + backgroundColor: Color(0xFFF5F7FA), + body: FadeTransition( + opacity: _fadeAnimation, + child: Column( + children: [ + RefundAppbar( + order: widget.selectedOrder, + ), + Expanded( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Panel - Order Summary (Scrollable) + Expanded( + flex: 3, + child: Scrollbar( + controller: _leftPanelScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _leftPanelScrollController, + child: _buildOrderSummaryPanel(), + ), + ), + ), + + SizedBox(width: 24), + + // Right Panel - Refund Configuration (Scrollable) + Expanded( + flex: 4, + child: Scrollbar( + controller: _rightPanelScrollController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _rightPanelScrollController, + child: _buildRefundConfigPanel(context), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildOrderSummaryPanel() { + return ScaleTransition( + scale: _scaleAnimation, + child: Column( + children: [ + // Order Info Card + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 30, + offset: Offset(0, 15), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.green[400]!, Colors.green[600]!], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.3), + blurRadius: 15, + offset: Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.receipt_long, + color: Colors.white, + size: 24, + ), + ), + SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Detail Pesanan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + SpaceHeight(4), + Text( + (widget.selectedOrder.createdAt ?? DateTime.now()) + .toFormattedDate3(), + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + ), + ), + ], + ), + + SpaceHeight(20), + + // Order Details Grid + Row( + children: [ + Expanded( + child: RefundInfoTile( + title: 'Meja', + value: widget.selectedOrder.tableNumber ?? 'Takeaway', + icon: Icons.table_restaurant, + color: Colors.blue, + ), + ), + SizedBox(width: 16), + Expanded( + child: RefundInfoTile( + title: 'Tipe', + value: widget.selectedOrder.orderType ?? 'N/A', + icon: Icons.shopping_bag_outlined, + color: Colors.purple, + ), + ), + ], + ), + + SpaceHeight(16), + + Row( + children: [ + Expanded( + child: RefundInfoTile( + title: 'Status', + value: widget.selectedOrder.status?.toUpperCase() ?? + 'N/A', + icon: Icons.check_circle_outline, + color: _getStatusColor(widget.selectedOrder.status), + ), + ), + SizedBox(width: 16), + Expanded( + child: RefundInfoTile( + title: 'Items', + value: + '${widget.selectedOrder.orderItems?.length ?? 0}', + icon: Icons.inventory_2_outlined, + color: Colors.orange, + ), + ), + ], + ), + ], + ), + ), + ), + + SpaceHeight(24), + + // Payment Summary Card + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 30, + offset: Offset(0, 15), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.account_balance_wallet, + color: Colors.amber[700], + size: 24, + ), + ), + SizedBox(width: 16), + Text( + 'Ringkasan Pembayaran', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + SpaceHeight(24), + _buildPaymentRow( + 'Subtotal', widget.selectedOrder.subtotal ?? 0), + _buildPaymentRow( + 'Pajak', widget.selectedOrder.taxAmount ?? 0), + _buildPaymentRow( + 'Diskon', -(widget.selectedOrder.discountAmount ?? 0)), + Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Divider(thickness: 2, color: Colors.grey[200]), + ), + _buildPaymentRow( + 'Total Dibayar', + widget.selectedOrder.totalAmount ?? 0, + isTotal: true, + ), + ], + ), + ), + ), + + SpaceHeight(24), // Extra space for scroll + ], + ), + ); + } + + Widget _buildRefundConfigPanel(BuildContext context) { + return SlideTransition( + position: _slideAnimation, + child: Column( + children: [ + // Refund Reason Card + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 30, + offset: Offset(0, 15), + ), + ], + ), + child: Padding( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.red[400]!, Colors.red[600]!], + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.red.withOpacity(0.3), + blurRadius: 15, + offset: Offset(0, 8), + ), + ], + ), + child: Icon( + Icons.assignment_return, + color: Colors.white, + size: 24, + ), + ), + SizedBox(width: 20), + Text( + 'Konfigurasi Refund', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + + SpaceHeight(20), + + Text( + 'Pilih Alasan Refund', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + + // Reason Selection Grid + GridView.builder( + shrinkWrap: true, + physics: NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + childAspectRatio: 2.5, + ), + itemCount: refundReasons.length, + itemBuilder: (context, index) { + final reason = refundReasons[index]; + final isSelected = selectedReason == reason['value']; + + return RefundReasonTile( + isSelected: isSelected, + reason: reason, + onTap: () { + setState(() { + selectedReason = reason['value']; + }); + }, + ); + }, + ), + + if (selectedReason == 'Lainnya') ...[ + SpaceHeight(24), + TextField( + controller: _reasonController, + maxLines: 3, + decoration: InputDecoration( + hintText: 'Jelaskan alasan refund secara detail...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: + BorderSide(color: AppColors.primary, width: 2), + ), + filled: true, + fillColor: Colors.grey[50], + contentPadding: EdgeInsets.all(20), + ), + ), + ], + + SpaceHeight(32), + + // Refund Amount Input + Text( + 'Jumlah Refund', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey[700], + ), + ), + + SpaceHeight(16), + + TextField( + controller: _refundAmountController, + keyboardType: TextInputType.number, + readOnly: true, + decoration: InputDecoration( + hintText: 'Masukkan jumlah refund', + prefixText: 'Rp ', + prefixStyle: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: + BorderSide(color: AppColors.primary, width: 2), + ), + filled: true, + fillColor: Colors.grey[50], + contentPadding: EdgeInsets.all(20), + ), + ), + ], + ), + ), + ), + + SpaceHeight(24), + + // Items Display Card + Container( + height: 500, // Fixed height untuk items list + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(24), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 30, + offset: Offset(0, 15), + ), + ], + ), + child: Column( + children: [ + Padding( + padding: EdgeInsets.all(20), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.list_alt, + color: Colors.blue[700], + size: 24, + ), + ), + SizedBox(width: 16), + Expanded( + child: Text( + 'Item Pesanan', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ), + Container( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${widget.selectedOrder.orderItems?.length ?? 0} item', + style: TextStyle( + color: AppColors.primary, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + ), + + // Scrollable Items List + Expanded( + child: Scrollbar( + controller: _itemsScrollController, + thumbVisibility: true, + child: ListView.builder( + controller: _itemsScrollController, + padding: EdgeInsets.symmetric(horizontal: 32), + itemCount: widget.selectedOrder.orderItems?.length ?? 0, + itemBuilder: (context, index) { + final item = widget.selectedOrder.orderItems![index]; + return RefundOrderItemTile(item: item); + }, + ), + ), + ), + + // Process Refund Button + Container( + padding: EdgeInsets.all(32), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(24), + bottomRight: Radius.circular(24), + ), + ), + child: BlocBuilder( + builder: (context, state) { + final isLoading = state.maybeWhen( + loading: () => true, + orElse: () => false, + ); + + return Container( + width: double.infinity, + height: 64, + child: ElevatedButton( + onPressed: + isLoading ? null : () => _processRefund(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + elevation: 0, + shadowColor: Colors.red.withOpacity(0.3), + ), + child: isLoading + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 3, + ), + ), + SizedBox(width: 16), + Text( + 'Memproses Refund...', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.monetization_on, + color: Colors.white, size: 28), + SizedBox(width: 16), + Text( + 'PROSES REFUND', + style: TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ), + + SpaceHeight(24), // Extra space for scroll + ], + ), + ); + } + + Widget _buildPaymentRow(String label, int amount, {bool isTotal = false}) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: isTotal ? 18 : 16, + fontWeight: isTotal ? FontWeight.bold : FontWeight.w500, + color: isTotal ? AppColors.primary : Colors.grey[700], + ), + ), + Text( + amount.currencyFormatRpV2, + style: TextStyle( + fontSize: isTotal ? 18 : 16, + fontWeight: isTotal ? FontWeight.bold : FontWeight.w600, + color: isTotal ? AppColors.primary : Colors.grey[800], + ), + ), + ], + ), + ); + } + + Color _getStatusColor(String? status) { + switch (status?.toLowerCase()) { + case 'completed': + case 'paid': + return Colors.green; + case 'pending': + return Colors.orange; + case 'cancelled': + return Colors.red; + default: + return Colors.grey; + } + } + + void _processRefund(BuildContext context) { + // Validate refund amount + final refundAmount = int.tryParse(_refundAmountController.text) ?? 0; + if (refundAmount <= 0) { + _showErrorDialog('Jumlah refund harus lebih dari 0'); + return; + } + + final totalAmount = widget.selectedOrder.totalAmount ?? 0; + if (refundAmount > totalAmount) { + _showErrorDialog('Jumlah refund tidak boleh melebihi total Pesanan'); + return; + } + + // Get reason text + String reason = selectedReason; + if (selectedReason == 'Lainnya' && _reasonController.text.isNotEmpty) { + reason = _reasonController.text; + } + + // Trigger refund event + context.read().add( + RefundEvent.refundPayment( + paymentId: widget.selectedOrder.id ?? + '', // Assuming order ID is payment ID + reason: reason, + refundAmount: refundAmount, + ), + ); + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + builder: (context) => RefundErrorDialog(message: message), + ); + } + + void _showSuccessDialog() { + final refundAmount = int.tryParse(_refundAmountController.text) ?? 0; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => RefundSuccessDialog( + selectedReason: selectedReason, + refundAmount: refundAmount, + ), + ); + } +} diff --git a/lib/presentation/refund/widgets/refund_appbar.dart b/lib/presentation/refund/widgets/refund_appbar.dart new file mode 100644 index 0000000..cad2618 --- /dev/null +++ b/lib/presentation/refund/widgets/refund_appbar.dart @@ -0,0 +1,104 @@ +import 'package:enaklo_pos/core/extensions/date_time_ext.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:flutter/material.dart'; + +class RefundAppbar extends StatelessWidget { + final Order order; + const RefundAppbar({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + return Container( + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0xFF36175E), + Color(0xFF4A2C6B), + Color(0xFF5D3F78), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: Color(0xFF36175E).withOpacity(0.4), + blurRadius: 30, + offset: Offset(0, 15), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Row( + children: [ + Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: Colors.white.withOpacity(0.3)), + ), + child: IconButton( + icon: Icon(Icons.arrow_back_ios_new, + color: Colors.white, size: 24), + onPressed: () => Navigator.pop(context), + ), + ), + SizedBox(width: 24), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Refund Pesanan', + style: TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + ), + SizedBox(height: 4), + Text( + 'Order #${order.orderNumber}', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(25), + border: Border.all(color: Colors.white.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.schedule, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + DateTime.now().toFormattedDate3(), + style: TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/refund/widgets/refund_info_tile.dart b/lib/presentation/refund/widgets/refund_info_tile.dart new file mode 100644 index 0000000..61748f8 --- /dev/null +++ b/lib/presentation/refund/widgets/refund_info_tile.dart @@ -0,0 +1,57 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:flutter/material.dart'; + +class RefundInfoTile extends StatelessWidget { + final String title; + final String value; + final IconData icon; + final Color color; + + const RefundInfoTile({ + super.key, + required this.title, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, color: color, size: 20), + SizedBox(width: 8), + Text( + title, + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + SpaceHeight(8), + Text( + value, + style: TextStyle( + color: Colors.grey[800], + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +} diff --git a/lib/presentation/refund/widgets/refund_order_Item_tile.dart b/lib/presentation/refund/widgets/refund_order_Item_tile.dart new file mode 100644 index 0000000..6585641 --- /dev/null +++ b/lib/presentation/refund/widgets/refund_order_Item_tile.dart @@ -0,0 +1,130 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/int_ext.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:flutter/material.dart'; + +class RefundOrderItemTile extends StatelessWidget { + final OrderItem item; + const RefundOrderItemTile({super.key, required this.item}); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.grey[200]!, width: 1), + ), + child: Padding( + padding: EdgeInsets.all(24), + child: Row( + children: [ + // Item Icon + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.restaurant, + color: AppColors.primary, + size: 24, + ), + ), + + SizedBox(width: 20), + + // Item Details + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? 'N/A', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 18, + color: Colors.grey[800], + ), + ), + if (item.productVariantName != null) ...[ + SpaceHeight(4), + Text( + item.productVariantName!, + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + ), + ), + ], + if (item.notes != null && item.notes!.isNotEmpty) ...[ + SpaceHeight(8), + Container( + padding: + EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.amber.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'Catatan: ${item.notes}', + style: TextStyle( + color: Colors.amber[700], + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ), + + // Price & Quantity + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + (item.unitPrice ?? 0).currencyFormatRpV2, + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppColors.primary, + fontSize: 16, + ), + ), + SpaceHeight(4), + Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'x${item.quantity}', + style: TextStyle( + color: Colors.blue[700], + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + SpaceHeight(8), + Text( + (item.totalPrice ?? 0).currencyFormatRpV2, + style: TextStyle( + color: Colors.grey[600], + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/refund/widgets/refund_reason_tile.dart b/lib/presentation/refund/widgets/refund_reason_tile.dart new file mode 100644 index 0000000..058af7b --- /dev/null +++ b/lib/presentation/refund/widgets/refund_reason_tile.dart @@ -0,0 +1,53 @@ +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:flutter/material.dart'; + +class RefundReasonTile extends StatelessWidget { + final bool isSelected; + final Map reason; + final Function() onTap; + const RefundReasonTile({ + super.key, + required this.isSelected, + required this.reason, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: Duration(milliseconds: 300), + decoration: BoxDecoration( + color: + isSelected ? reason['color'].withOpacity(0.2) : Colors.grey[100], + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: isSelected ? reason['color'] : Colors.transparent, + width: 2, + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + reason['icon'], + color: isSelected ? reason['color'] : Colors.grey[600], + size: 20, + ), + SpaceHeight(4), + Text( + reason['value'], + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isSelected ? reason['color'] : Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/sales/pages/sales_page.dart b/lib/presentation/sales/pages/sales_page.dart index a11a6a8..86935eb 100644 --- a/lib/presentation/sales/pages/sales_page.dart +++ b/lib/presentation/sales/pages/sales_page.dart @@ -1,13 +1,12 @@ import 'package:enaklo_pos/core/components/buttons.dart'; -import 'package:enaklo_pos/core/components/flushbar.dart'; import 'package:enaklo_pos/core/components/spaces.dart'; import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; import 'package:enaklo_pos/data/models/response/order_response_model.dart'; import 'package:enaklo_pos/presentation/home/bloc/order_form/order_form_bloc.dart'; +import 'package:enaklo_pos/presentation/refund/pages/refund_page.dart'; import 'package:enaklo_pos/presentation/sales/blocs/day_sales/day_sales_bloc.dart'; import 'package:enaklo_pos/presentation/sales/blocs/order_loader/order_loader_bloc.dart'; import 'package:enaklo_pos/presentation/sales/dialog/payment_dialog.dart'; -import 'package:enaklo_pos/presentation/sales/dialog/refund_dialog.dart'; import 'package:enaklo_pos/presentation/void/pages/void_page.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_detail.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_list_order.dart'; @@ -214,37 +213,14 @@ class _SalesPageState extends State { ), ], if (widget.status == 'completed') - BlocBuilder( - builder: (context, state) { - return state.maybeWhen( - orElse: () => Button.outlined( - onPressed: () {}, - label: 'Refund', - icon: Icon(Icons.autorenew), - ), - loaded: (order, selectedItems, - totalVoidOrRefund, isAllSelected) => - Button.outlined( - onPressed: () { - if (selectedItems.isEmpty) { - AppFlushbar.showError(context, - 'Silahkan pilih item yang ingin di refund.'); - return; - } - - showDialog( - context: context, - builder: (context) => RefundDialog( - order: orderDetail!, - selectedItems: selectedItems, - ), - ); - }, - label: 'Refund', - icon: Icon(Icons.autorenew), - ), - ); + Button.outlined( + onPressed: () { + context.push(RefundPage( + selectedOrder: orderDetail!, + )); }, + label: 'Refund', + icon: Icon(Icons.autorenew), ), ], ),