diff --git a/lib/core/components/buttons.dart b/lib/core/components/buttons.dart index e0614b1..bcdc058 100644 --- a/lib/core/components/buttons.dart +++ b/lib/core/components/buttons.dart @@ -43,7 +43,7 @@ class Button extends StatelessWidget { this.crossAxisAlignment = CrossAxisAlignment.center, }); - final Function() onPressed; + final Function()? onPressed; final String label; final ButtonStyle style; final Color color; diff --git a/lib/main.dart b/lib/main.dart index 1ed7136..39982eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -11,6 +11,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/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'; import 'package:flutter/material.dart'; import 'package:enaklo_pos/data/datasources/auth_local_datasource.dart'; import 'package:enaklo_pos/data/datasources/auth_remote_datasource.dart'; @@ -252,6 +253,9 @@ class _MyAppState extends State { BlocProvider( create: (context) => CurrentOutletBloc(OutletRemoteDataSource()), ), + BlocProvider( + create: (context) => VoidOrderBloc(OrderRemoteDatasource()), + ), ], child: MaterialApp( debugShowCheckedModeBanner: false, diff --git a/lib/presentation/sales/pages/sales_page.dart b/lib/presentation/sales/pages/sales_page.dart index cd3f41a..a11a6a8 100644 --- a/lib/presentation/sales/pages/sales_page.dart +++ b/lib/presentation/sales/pages/sales_page.dart @@ -1,13 +1,14 @@ 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/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/sales/dialog/void_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'; import 'package:enaklo_pos/presentation/sales/widgets/sales_order_information.dart'; @@ -183,13 +184,18 @@ class _SalesPageState extends State { loaded: (order, selectedItems, totalVoidOrRefund, isAllSelected) => Button.outlined( - onPressed: () => showDialog( - context: context, - builder: (context) => VoidDialog( - order: orderDetail!, - selectedItems: selectedItems, - ), - ), + onPressed: () { + context.push(VoidPage( + selectedOrder: order, + )); + // showDialog( + // context: context, + // builder: (context) => VoidDialog( + // order: orderDetail!, + // selectedItems: selectedItems, + // ), + // ); + }, label: 'Void', icon: Icon(Icons.undo), ), diff --git a/lib/presentation/sales/widgets/sales_list_order.dart b/lib/presentation/sales/widgets/sales_list_order.dart index 1a42eb3..30098f1 100644 --- a/lib/presentation/sales/widgets/sales_list_order.dart +++ b/lib/presentation/sales/widgets/sales_list_order.dart @@ -1,6 +1,5 @@ import 'package:enaklo_pos/core/components/spaces.dart'; import 'package:enaklo_pos/core/constants/colors.dart'; -import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; import 'package:enaklo_pos/core/extensions/string_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'; @@ -17,7 +16,7 @@ class SalesListOrder extends StatelessWidget { margin: const EdgeInsets.only(top: 16), decoration: BoxDecoration( color: AppColors.white, - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(16), ), child: BlocBuilder( builder: (context, state) { @@ -27,51 +26,10 @@ class SalesListOrder extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - padding: const EdgeInsets.all(16), - width: double.infinity, - decoration: const BoxDecoration( - border: Border( - bottom: BorderSide(color: AppColors.background), - ), - ), - child: Row( - children: [ - Checkbox( - value: isAllSelected, - activeColor: AppColors.primary, - onChanged: (val) { - context.read().add( - OrderFormEvent.toggleSelectAll(val ?? false)); - }, - ), - Text( - 'Daftar Pembelian', - style: TextStyle( - color: AppColors.black, - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - SpaceHeight(12), - Column( - children: List.generate( - order?.orderItems?.length ?? 0, - (index) { - final item = order!.orderItems![index]; - final isSelected = - selectedItems.any((e) => e.id == item.id); - return _item( - context, - isSelected, - item, - ); - }, - ).toList(), - ), + _buildHeader(context, isAllSelected), + const SpaceHeight(8), + _buildItemsList(context, selectedItems), + const SpaceHeight(8), ], ), ); @@ -80,96 +38,345 @@ class SalesListOrder extends StatelessWidget { ); } - Padding _item(BuildContext context, bool isSelected, OrderItem product) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16) - .copyWith(top: 0), - child: Column( + Widget _buildHeader(BuildContext context, bool isAllSelected) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + AppColors.primary.withOpacity(0.1), + AppColors.primary.withOpacity(0.05), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + border: Border( + bottom: BorderSide( + color: AppColors.primary.withOpacity(0.1), + width: 1, + ), + ), + ), + child: Row( children: [ - Align( - alignment: Alignment.centerRight, - child: Text( - product.status == "pending" - ? "Pending" - : product.status == "cancelled" - ? "Batal" - : product.status == 'refund' - ? "Refund" - : "Selesai", - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.w600, - color: product.status == "pending" - ? Colors.blue - : product.status == "cancelled" - ? Colors.red - : product.status == 'refund' - ? Colors.red - : Colors.green, + Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isAllSelected ? AppColors.primary : Colors.grey.shade300, + width: 2, ), ), + child: Checkbox( + value: isAllSelected, + activeColor: AppColors.primary, + checkColor: AppColors.white, + onChanged: (val) { + context + .read() + .add(OrderFormEvent.toggleSelectAll(val ?? false)); + }, + ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox( - width: context.deviceWidth * 0.2, - child: Row( - children: [ - Checkbox( - value: isSelected, - activeColor: AppColors.primary, - onChanged: (_) { - context - .read() - .add(OrderFormEvent.toggleItem(product)); - }, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Daftar Pembelian', + style: TextStyle( + color: AppColors.black, + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + const SizedBox(height: 4), + Text( + '${order?.orderItems?.length ?? 0} item', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.primary.withOpacity(0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.shopping_cart_outlined, + size: 16, + color: AppColors.primary, + ), + const SizedBox(width: 4), + Text( + 'Order', + style: TextStyle( + color: AppColors.primary, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildItemsList(BuildContext context, List selectedItems) { + return Column( + children: List.generate( + order?.orderItems?.length ?? 0, + (index) { + final item = order!.orderItems![index]; + final isSelected = selectedItems.any((e) => e.id == item.id); + return _buildItem(context, isSelected, item, index); + }, + ).toList(), + ); + } + + Widget _buildItem( + BuildContext context, bool isSelected, OrderItem product, int index) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: + isSelected ? AppColors.primary.withOpacity(0.05) : AppColors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? AppColors.primary.withOpacity(0.3) + : Colors.grey.shade200, + width: isSelected ? 2 : 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.white, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: + isSelected ? AppColors.primary : Colors.grey.shade300, + width: 1.5, ), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - product.productName ?? '', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - if (product.productVariantName != null) - Text( - product.productVariantName ?? '', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, + ), + child: Checkbox( + value: isSelected, + activeColor: AppColors.primary, + checkColor: AppColors.white, + onChanged: (_) { + context + .read() + .add(OrderFormEvent.toggleItem(product)); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + product.productName ?? '', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), ), ), - Text( - (product.unitPrice ?? 0) - .toString() - .currencyFormatRpV2, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, + _buildStatusBadge(product.status), + ], + ), + if (product.productVariantName != null) ...[ + const SizedBox(height: 4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + product.productVariantName ?? '', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), ), ), ], - ), - ], + const SizedBox(height: 12), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Harga Satuan', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + (product.unitPrice ?? 0) + .toString() + .currencyFormatRpV2, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'x${product.quantity}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primary, + ), + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + 'Total', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + (product.totalPrice ?? 0) + .toString() + .currencyFormatRpV2, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primary, + ), + ), + ], + ), + ], + ), + ], + ), ), - ), - Text( - 'X${product.quantity}', - style: const TextStyle( - fontSize: 14, - ), - ), - Text( - (product.totalPrice ?? 0).toString().currencyFormatRpV2, - style: const TextStyle( - fontSize: 14, - ), - ), - ], + ], + ), + ], + ), + ), + ); + } + + Widget _buildStatusBadge(String? status) { + Color backgroundColor; + Color textColor; + String displayText; + IconData icon; + + switch (status) { + case "pending": + backgroundColor = Colors.orange.withOpacity(0.1); + textColor = Colors.orange.shade700; + displayText = "Pending"; + icon = Icons.access_time; + break; + case "cancelled": + backgroundColor = Colors.red.withOpacity(0.1); + textColor = Colors.red.shade700; + displayText = "Batal"; + icon = Icons.cancel_outlined; + break; + case "refund": + backgroundColor = Colors.purple.withOpacity(0.1); + textColor = Colors.purple.shade700; + displayText = "Refund"; + icon = Icons.undo; + break; + default: + backgroundColor = Colors.green.withOpacity(0.1); + textColor = Colors.green.shade700; + displayText = "Selesai"; + icon = Icons.check_circle_outline; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: textColor.withOpacity(0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 14, + color: textColor, + ), + const SizedBox(width: 4), + Text( + displayText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: textColor, + ), ), ], ), diff --git a/lib/presentation/void/bloc/void_order_bloc.dart b/lib/presentation/void/bloc/void_order_bloc.dart new file mode 100644 index 0000000..fb27267 --- /dev/null +++ b/lib/presentation/void/bloc/void_order_bloc.dart @@ -0,0 +1,36 @@ +import 'package:enaklo_pos/data/datasources/order_remote_datasource.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'void_order_event.dart'; +part 'void_order_state.dart'; +part 'void_order_bloc.freezed.dart'; + +class VoidOrderBloc extends Bloc { + final OrderRemoteDatasource _orderRemoteDatasource; + + VoidOrderBloc(this._orderRemoteDatasource) + : super(const VoidOrderState.initial()) { + on<_VoidOrder>(_onVoidOrder); + } + + Future _onVoidOrder( + _VoidOrder event, + Emitter emit, + ) async { + emit(const VoidOrderState.loading()); + + final result = await _orderRemoteDatasource.voidOrder( + orderId: event.orderId, + reason: event.reason, + type: event.type, + orderItems: event.orderItems, + ); + + result.fold( + (error) => emit(VoidOrderState.error(error)), + (success) => emit(const VoidOrderState.success()), + ); + } +} diff --git a/lib/presentation/void/bloc/void_order_bloc.freezed.dart b/lib/presentation/void/bloc/void_order_bloc.freezed.dart new file mode 100644 index 0000000..a0af50a --- /dev/null +++ b/lib/presentation/void/bloc/void_order_bloc.freezed.dart @@ -0,0 +1,889 @@ +// 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 'void_order_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 _$VoidOrderEvent { + String get orderId => throw _privateConstructorUsedError; + String get reason => throw _privateConstructorUsedError; + String get type => throw _privateConstructorUsedError; // "ALL" or "ITEM" + List get orderItems => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(String orderId, String reason, String type, + List orderItems) + voidOrder, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String orderId, String reason, String type, + List orderItems)? + voidOrder, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String orderId, String reason, String type, + List orderItems)? + voidOrder, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_VoidOrder value) voidOrder, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_VoidOrder value)? voidOrder, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_VoidOrder value)? voidOrder, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + /// Create a copy of VoidOrderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $VoidOrderEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $VoidOrderEventCopyWith<$Res> { + factory $VoidOrderEventCopyWith( + VoidOrderEvent value, $Res Function(VoidOrderEvent) then) = + _$VoidOrderEventCopyWithImpl<$Res, VoidOrderEvent>; + @useResult + $Res call( + {String orderId, String reason, String type, List orderItems}); +} + +/// @nodoc +class _$VoidOrderEventCopyWithImpl<$Res, $Val extends VoidOrderEvent> + implements $VoidOrderEventCopyWith<$Res> { + _$VoidOrderEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of VoidOrderEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? orderId = null, + Object? reason = null, + Object? type = null, + Object? orderItems = null, + }) { + return _then(_value.copyWith( + orderId: null == orderId + ? _value.orderId + : orderId // ignore: cast_nullable_to_non_nullable + as String, + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + orderItems: null == orderItems + ? _value.orderItems + : orderItems // ignore: cast_nullable_to_non_nullable + as List, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$VoidOrderImplCopyWith<$Res> + implements $VoidOrderEventCopyWith<$Res> { + factory _$$VoidOrderImplCopyWith( + _$VoidOrderImpl value, $Res Function(_$VoidOrderImpl) then) = + __$$VoidOrderImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String orderId, String reason, String type, List orderItems}); +} + +/// @nodoc +class __$$VoidOrderImplCopyWithImpl<$Res> + extends _$VoidOrderEventCopyWithImpl<$Res, _$VoidOrderImpl> + implements _$$VoidOrderImplCopyWith<$Res> { + __$$VoidOrderImplCopyWithImpl( + _$VoidOrderImpl _value, $Res Function(_$VoidOrderImpl) _then) + : super(_value, _then); + + /// Create a copy of VoidOrderEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? orderId = null, + Object? reason = null, + Object? type = null, + Object? orderItems = null, + }) { + return _then(_$VoidOrderImpl( + orderId: null == orderId + ? _value.orderId + : orderId // ignore: cast_nullable_to_non_nullable + as String, + reason: null == reason + ? _value.reason + : reason // ignore: cast_nullable_to_non_nullable + as String, + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + orderItems: null == orderItems + ? _value._orderItems + : orderItems // ignore: cast_nullable_to_non_nullable + as List, + )); + } +} + +/// @nodoc + +class _$VoidOrderImpl implements _VoidOrder { + const _$VoidOrderImpl( + {required this.orderId, + required this.reason, + required this.type, + required final List orderItems}) + : _orderItems = orderItems; + + @override + final String orderId; + @override + final String reason; + @override + final String type; +// "ALL" or "ITEM" + final List _orderItems; +// "ALL" or "ITEM" + @override + List get orderItems { + if (_orderItems is EqualUnmodifiableListView) return _orderItems; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_orderItems); + } + + @override + String toString() { + return 'VoidOrderEvent.voidOrder(orderId: $orderId, reason: $reason, type: $type, orderItems: $orderItems)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$VoidOrderImpl && + (identical(other.orderId, orderId) || other.orderId == orderId) && + (identical(other.reason, reason) || other.reason == reason) && + (identical(other.type, type) || other.type == type) && + const DeepCollectionEquality() + .equals(other._orderItems, _orderItems)); + } + + @override + int get hashCode => Object.hash(runtimeType, orderId, reason, type, + const DeepCollectionEquality().hash(_orderItems)); + + /// Create a copy of VoidOrderEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VoidOrderImplCopyWith<_$VoidOrderImpl> get copyWith => + __$$VoidOrderImplCopyWithImpl<_$VoidOrderImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(String orderId, String reason, String type, + List orderItems) + voidOrder, + }) { + return voidOrder(orderId, reason, type, orderItems); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(String orderId, String reason, String type, + List orderItems)? + voidOrder, + }) { + return voidOrder?.call(orderId, reason, type, orderItems); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(String orderId, String reason, String type, + List orderItems)? + voidOrder, + required TResult orElse(), + }) { + if (voidOrder != null) { + return voidOrder(orderId, reason, type, orderItems); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_VoidOrder value) voidOrder, + }) { + return voidOrder(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_VoidOrder value)? voidOrder, + }) { + return voidOrder?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_VoidOrder value)? voidOrder, + required TResult orElse(), + }) { + if (voidOrder != null) { + return voidOrder(this); + } + return orElse(); + } +} + +abstract class _VoidOrder implements VoidOrderEvent { + const factory _VoidOrder( + {required final String orderId, + required final String reason, + required final String type, + required final List orderItems}) = _$VoidOrderImpl; + + @override + String get orderId; + @override + String get reason; + @override + String get type; // "ALL" or "ITEM" + @override + List get orderItems; + + /// Create a copy of VoidOrderEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VoidOrderImplCopyWith<_$VoidOrderImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$VoidOrderState { + @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 $VoidOrderStateCopyWith<$Res> { + factory $VoidOrderStateCopyWith( + VoidOrderState value, $Res Function(VoidOrderState) then) = + _$VoidOrderStateCopyWithImpl<$Res, VoidOrderState>; +} + +/// @nodoc +class _$VoidOrderStateCopyWithImpl<$Res, $Val extends VoidOrderState> + implements $VoidOrderStateCopyWith<$Res> { + _$VoidOrderStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of VoidOrderState + /// 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 _$VoidOrderStateCopyWithImpl<$Res, _$InitialImpl> + implements _$$InitialImplCopyWith<$Res> { + __$$InitialImplCopyWithImpl( + _$InitialImpl _value, $Res Function(_$InitialImpl) _then) + : super(_value, _then); + + /// Create a copy of VoidOrderState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$InitialImpl implements _Initial { + const _$InitialImpl(); + + @override + String toString() { + return 'VoidOrderState.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 VoidOrderState { + const factory _Initial() = _$InitialImpl; +} + +/// @nodoc +abstract class _$$LoadingImplCopyWith<$Res> { + factory _$$LoadingImplCopyWith( + _$LoadingImpl value, $Res Function(_$LoadingImpl) then) = + __$$LoadingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$LoadingImplCopyWithImpl<$Res> + extends _$VoidOrderStateCopyWithImpl<$Res, _$LoadingImpl> + implements _$$LoadingImplCopyWith<$Res> { + __$$LoadingImplCopyWithImpl( + _$LoadingImpl _value, $Res Function(_$LoadingImpl) _then) + : super(_value, _then); + + /// Create a copy of VoidOrderState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$LoadingImpl implements _Loading { + const _$LoadingImpl(); + + @override + String toString() { + return 'VoidOrderState.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 VoidOrderState { + const factory _Loading() = _$LoadingImpl; +} + +/// @nodoc +abstract class _$$SuccessImplCopyWith<$Res> { + factory _$$SuccessImplCopyWith( + _$SuccessImpl value, $Res Function(_$SuccessImpl) then) = + __$$SuccessImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$SuccessImplCopyWithImpl<$Res> + extends _$VoidOrderStateCopyWithImpl<$Res, _$SuccessImpl> + implements _$$SuccessImplCopyWith<$Res> { + __$$SuccessImplCopyWithImpl( + _$SuccessImpl _value, $Res Function(_$SuccessImpl) _then) + : super(_value, _then); + + /// Create a copy of VoidOrderState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$SuccessImpl implements _Success { + const _$SuccessImpl(); + + @override + String toString() { + return 'VoidOrderState.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 VoidOrderState { + 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 _$VoidOrderStateCopyWithImpl<$Res, _$ErrorImpl> + implements _$$ErrorImplCopyWith<$Res> { + __$$ErrorImplCopyWithImpl( + _$ErrorImpl _value, $Res Function(_$ErrorImpl) _then) + : super(_value, _then); + + /// Create a copy of VoidOrderState + /// 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 'VoidOrderState.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 VoidOrderState + /// 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 VoidOrderState { + const factory _Error(final String message) = _$ErrorImpl; + + String get message; + + /// Create a copy of VoidOrderState + /// 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/void/bloc/void_order_event.dart b/lib/presentation/void/bloc/void_order_event.dart new file mode 100644 index 0000000..78b2557 --- /dev/null +++ b/lib/presentation/void/bloc/void_order_event.dart @@ -0,0 +1,11 @@ +part of 'void_order_bloc.dart'; + +@freezed +class VoidOrderEvent with _$VoidOrderEvent { + const factory VoidOrderEvent.voidOrder({ + required String orderId, + required String reason, + required String type, // "ALL" or "ITEM" + required List orderItems, + }) = _VoidOrder; +} diff --git a/lib/presentation/void/bloc/void_order_state.dart b/lib/presentation/void/bloc/void_order_state.dart new file mode 100644 index 0000000..b9842a0 --- /dev/null +++ b/lib/presentation/void/bloc/void_order_state.dart @@ -0,0 +1,9 @@ +part of 'void_order_bloc.dart'; + +@freezed +class VoidOrderState with _$VoidOrderState { + const factory VoidOrderState.initial() = _Initial; + const factory VoidOrderState.loading() = _Loading; + const factory VoidOrderState.success() = _Success; + const factory VoidOrderState.error(String message) = _Error; +} diff --git a/lib/presentation/void/dialog/confirm_void_dialog.dart b/lib/presentation/void/dialog/confirm_void_dialog.dart new file mode 100644 index 0000000..b9e1a0d --- /dev/null +++ b/lib/presentation/void/dialog/confirm_void_dialog.dart @@ -0,0 +1,397 @@ +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 ConfirmVoidDialog extends StatelessWidget { + final String message; + final String voidType; + final Order order; + final Map selectedItemQuantities; + final String voidReason; + final Function() onTap; + const ConfirmVoidDialog( + {super.key, + required this.message, + required this.voidType, + required this.order, + required this.selectedItemQuantities, + required this.voidReason, + required this.onTap}); + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + elevation: 8, + child: Container( + width: MediaQuery.of(context).size.width * 0.9, + constraints: BoxConstraints( + maxWidth: 600, + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Header + Container( + width: double.infinity, + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.red[400]!, Colors.red[600]!], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Column( + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + shape: BoxShape.circle, + ), + child: Icon( + Icons.warning_rounded, + color: Colors.white, + size: 28, + ), + ), + SizedBox(height: 12), + Text( + 'Konfirmasi Void', + style: TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + Text( + 'Tindakan ini tidak dapat dibatalkan', + style: TextStyle( + color: Colors.white.withOpacity(0.9), + fontSize: 12, + ), + ), + ], + ), + ), + + // Scrollable Content + Flexible( + child: SingleChildScrollView( + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main message + Container( + width: double.infinity, + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey[200]!), + ), + child: Text( + message, + style: TextStyle( + fontSize: 14, + height: 1.4, + color: Colors.grey[800], + ), + ), + ), + + if (voidType == 'item' && + selectedItemQuantities.isNotEmpty) ...[ + SizedBox(height: 16), + + // Items section + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Colors.orange[50], + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange[200]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.all(16), + child: Row( + children: [ + Container( + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.orange[100], + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.list_alt_rounded, + color: Colors.orange[700], + size: 16, + ), + ), + SizedBox(width: 8), + Text( + 'Item yang akan divoid:', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Colors.orange[800], + ), + ), + ], + ), + ), + Container( + constraints: BoxConstraints(maxHeight: 120), + child: Scrollbar( + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: 16, right: 16, bottom: 16), + child: Column( + children: selectedItemQuantities.entries + .map((entry) { + final item = order.orderItems!.firstWhere( + (item) => item.id == entry.key); + return Container( + margin: EdgeInsets.only(bottom: 6), + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(6), + boxShadow: [ + BoxShadow( + color: Colors.black + .withOpacity(0.03), + blurRadius: 2, + offset: Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: Colors.red[400], + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? + 'Unknown Product', + style: TextStyle( + fontWeight: + FontWeight.w600, + fontSize: 12, + ), + maxLines: 1, + overflow: + TextOverflow.ellipsis, + ), + Text( + '${entry.value} qty', + style: TextStyle( + fontSize: 10, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red[100], + borderRadius: + BorderRadius.circular(4), + ), + child: Text( + ((item.unitPrice ?? 0) * + entry.value) + .currencyFormatRpV2, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.red[700], + ), + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + ], + ), + ), + ], + + SizedBox(height: 16), + + // Reason section + Container( + width: double.infinity, + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue[200]!), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.blue[100], + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.note_alt_rounded, + color: Colors.blue[700], + size: 16, + ), + ), + SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Alasan:', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.blue[800], + fontSize: 12, + ), + ), + SizedBox(height: 2), + Text( + voidReason, + style: TextStyle( + fontStyle: FontStyle.italic, + color: Colors.blue[700], + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ), + ), + + // Action buttons + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(16), + bottomRight: Radius.circular(16), + ), + ), + child: Row( + children: [ + Expanded( + child: Container( + height: 44, + child: ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + foregroundColor: Colors.grey[700], + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.close_rounded, size: 18), + SizedBox(width: 6), + Text( + 'Batal', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + SizedBox(width: 12), + Expanded( + child: Container( + height: 44, + child: ElevatedButton( + onPressed: () { + Navigator.pop(context); + onTap(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[600], + foregroundColor: Colors.white, + elevation: 2, + shadowColor: Colors.red.withOpacity(0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.delete_forever_rounded, size: 18), + SizedBox(width: 6), + Text( + voidType == 'all' ? 'Void Pesanan' : 'Void Item', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/void/pages/void_page.dart b/lib/presentation/void/pages/void_page.dart new file mode 100644 index 0000000..c64a7c9 --- /dev/null +++ b/lib/presentation/void/pages/void_page.dart @@ -0,0 +1,814 @@ +import 'package:enaklo_pos/core/components/buttons.dart'; +import 'package:enaklo_pos/core/components/spaces.dart'; +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/build_context_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/void/bloc/void_order_bloc.dart'; +import 'package:enaklo_pos/presentation/void/dialog/confirm_void_dialog.dart'; +import 'package:enaklo_pos/presentation/void/widgets/product_card.dart'; +import 'package:enaklo_pos/presentation/void/widgets/void_radio.dart'; +import 'package:enaklo_pos/presentation/void/widgets/void_loading.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class VoidPage extends StatefulWidget { + final Order selectedOrder; + + const VoidPage({super.key, required this.selectedOrder}); + + @override + State createState() => _VoidPageState(); +} + +class _VoidPageState extends State { + String voidType = 'all'; // 'all' or 'item' + Map selectedItemQuantities = {}; // itemId -> voidQuantity + String voidReason = ''; + final TextEditingController reasonController = TextEditingController(); + final ScrollController _leftPanelController = ScrollController(); + final ScrollController _rightPanelController = ScrollController(); + + @override + void dispose() { + _leftPanelController.dispose(); + _rightPanelController.dispose(); + reasonController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + state.when( + initial: () {}, + loading: () { + // Show loading indicator if needed + }, + success: () { + _showSuccessDialog(); + }, + error: (message) { + _showErrorDialog(message); + }, + ); + }, + child: Scaffold( + backgroundColor: Colors.grey[100], + body: BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + OrientationBuilder( + builder: (context, orientation) { + return Padding( + padding: EdgeInsets.all(24.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Left Panel - Order Details & Items + Expanded( + flex: 3, + child: _buildOrderDetailsPanel(), + ), + SpaceWidth(24), + // Right Panel - Void Configuration + Expanded( + flex: 2, + child: _buildVoidConfigPanel(), + ), + ], + ), + ); + }, + ), + // Loading Overlay + state.when( + initial: () => SizedBox.shrink(), + loading: () => VoidLoading(), + success: () => SizedBox.shrink(), + error: (message) => SizedBox.shrink(), + ), + ], + ); + }, + ), + ), + ); + } + + Widget _buildOrderDetailsPanel() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Order Header - Fixed + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Icon(Icons.receipt_long, color: AppColors.primary, size: 24), + SpaceWidth(12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pesanan #${widget.selectedOrder.orderNumber}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + Text( + 'Meja: ${widget.selectedOrder.tableNumber ?? 'N/A'} • ${widget.selectedOrder.orderType ?? 'N/A'}', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: _getStatusColor(widget.selectedOrder.status) + .withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + ), + child: Text( + widget.selectedOrder.status?.toUpperCase() ?? 'UNKNOWN', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: _getStatusColor(widget.selectedOrder.status), + ), + ), + ), + ], + ), + ), + + // Scrollable Content + Expanded( + child: Scrollbar( + controller: _leftPanelController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _leftPanelController, + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Order Summary - Fixed in scroll + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + children: [ + _buildSummaryRow( + 'Subtotal:', + (widget.selectedOrder.subtotal ?? 0) + .currencyFormatRpV2), + _buildSummaryRow( + 'Pajak:', + (widget.selectedOrder.taxAmount ?? 0) + .currencyFormatRpV2), + _buildSummaryRow('Diskon:', + '- ${(widget.selectedOrder.discountAmount ?? 0).currencyFormatRpV2}'), + Divider(thickness: 1), + _buildSummaryRow( + 'Total:', + (widget.selectedOrder.totalAmount ?? 0) + .currencyFormatRpV2, + isTotal: true, + ), + if (voidType == 'item' && + selectedItemQuantities.isNotEmpty) ...[ + Divider(thickness: 1, color: Colors.red[300]), + _buildSummaryRow( + 'Total Void:', + '- ${(_calculateVoidAmount().currencyFormatRpV2)}', + isVoid: true, + ), + ], + ], + ), + ), + + SpaceHeight(24), + + // Order Items Section Title + Row( + children: [ + Icon(Icons.shopping_cart, + color: AppColors.primary, size: 20), + SpaceWidth(8), + Text( + 'Produk Pesanan (${widget.selectedOrder.orderItems?.length ?? 0})', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + ], + ), + + SpaceHeight(16), + + // Order Items List - Scrollable + ...List.generate( + widget.selectedOrder.orderItems?.length ?? 0, + (index) { + final item = widget.selectedOrder.orderItems![index]; + final voidQty = selectedItemQuantities[item.id] ?? 0; + final isSelected = voidQty > 0; + final canSelect = voidType == 'item'; + + return VoidProductCard( + isSelected: isSelected, + item: item, + voidQty: voidQty, + canSelect: canSelect, + onTapDecrease: voidQty > 0 + ? () { + setState(() { + if (voidQty == 1) { + selectedItemQuantities.remove(item.id); + } else { + selectedItemQuantities[item.id!] = + voidQty - 1; + } + }); + } + : null, + onTapIncrease: voidQty < (item.quantity ?? 0) + ? () { + setState(() { + selectedItemQuantities[item.id!] = + voidQty + 1; + }); + } + : null, + onTapAll: () { + setState(() { + selectedItemQuantities[item.id!] = + item.quantity ?? 0; + }); + }, + onTapClear: voidQty > 0 + ? () { + setState(() { + selectedItemQuantities.remove(item.id); + }); + } + : null, + ); + }, + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildVoidConfigPanel() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 10, + offset: Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header - Fixed + Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(12), + topRight: Radius.circular(12), + ), + ), + child: Row( + children: [ + Icon(Icons.cancel, color: Colors.red, size: 24), + SpaceWidth(12), + Text( + 'Konfigurasi Void', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.red, + ), + ), + ], + ), + ), + + // Scrollable Content + Expanded( + child: Scrollbar( + controller: _rightPanelController, + thumbVisibility: true, + child: SingleChildScrollView( + controller: _rightPanelController, + padding: EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Void Type Selection + Text( + 'Tipe Void *', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + SpaceHeight(12), + + // Void All Option + VoidRadio( + voidType: voidType, + value: 'all', + title: 'Batalkan Seluruh Pesanan', + subtitle: "Batalkan pesanan lengkap dan semua item", + onChanged: (String? value) { + setState(() { + voidType = value!; + selectedItemQuantities.clear(); + }); + }, + ), + + SpaceHeight(12), + + // Void Items Option + VoidRadio( + voidType: voidType, + value: 'item', + title: 'Batalkan Barang/Jumlah Tertentu', + subtitle: + "Mengurangi atau membatalkan jumlah item tertentu", + onChanged: (String? value) { + setState(() { + voidType = value!; + selectedItemQuantities.clear(); + }); + }, + ), + + SpaceHeight(24), + + // Selected Items Summary (only show for item void) + if (voidType == 'item') ...[ + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: selectedItemQuantities.isEmpty + ? Colors.orange[50] + : Colors.green[50], + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selectedItemQuantities.isEmpty + ? Colors.orange[300]! + : Colors.green[300]!, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + selectedItemQuantities.isEmpty + ? Icons.warning + : Icons.check_circle, + color: selectedItemQuantities.isEmpty + ? Colors.orange[700] + : Colors.green[700], + size: 20, + ), + SpaceWidth(8), + Expanded( + child: Text( + selectedItemQuantities.isEmpty + ? 'Silakan pilih item dan jumlah yang akan dibatalkan' + : 'Item yang dipilih untuk dibatalkan:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: selectedItemQuantities.isEmpty + ? Colors.orange[700] + : Colors.green[700], + ), + ), + ), + ], + ), + if (selectedItemQuantities.isNotEmpty) ...[ + SpaceHeight(12), + Container( + constraints: BoxConstraints(maxHeight: 150), + child: Scrollbar( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: selectedItemQuantities.entries + .map((entry) { + final item = widget + .selectedOrder.orderItems! + .firstWhere( + (item) => item.id == entry.key); + return Container( + margin: EdgeInsets.only(bottom: 8), + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(6), + border: Border.all( + color: Colors.green[200]!), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? + 'Unknown', + style: TextStyle( + fontSize: 12, + fontWeight: + FontWeight.w500, + ), + ), + Text( + 'Qty: ${entry.value}', + style: TextStyle( + fontSize: 11, + color: Colors.grey[600], + ), + ), + ], + ), + ), + Text( + ((item.unitPrice ?? 0) * + entry.value) + .currencyFormatRpV2, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.red[700], + ), + ), + ], + ), + ); + }).toList(), + ), + ), + ), + ), + Divider(height: 16, thickness: 1), + Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Jumlah Total Void:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + Text( + _calculateVoidAmount().currencyFormatRpV2, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.red[700], + ), + ), + ], + ), + ), + ], + ], + ), + ), + SpaceHeight(24), + ], + + // Void Reason + Text( + 'Alasan Void *', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + SpaceHeight(8), + TextField( + controller: reasonController, + maxLines: 4, + decoration: InputDecoration( + hintText: 'Harap berikan alasan untuk membatalkan...', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey[300]!), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: + BorderSide(color: AppColors.primary, width: 2), + ), + contentPadding: EdgeInsets.all(16), + ), + onChanged: (value) { + setState(() { + voidReason = value; + }); + }, + ), + + SpaceHeight(32), + + // Action Buttons + Row( + children: [ + Expanded( + child: Button.outlined( + onPressed: () => context.pop(), + label: 'Batal', + ), + ), + SpaceWidth(12), + Expanded( + child: Button.filled( + onPressed: _canProcessVoid() ? _processVoid : null, + label: voidType == 'all' + ? 'Void Pesanan' + : 'Void Produk', + ), + ), + ], + ), + ], + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSummaryRow(String label, String value, + {bool isTotal = false, bool isVoid = false}) { + return Padding( + padding: EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + label, + style: TextStyle( + fontSize: isTotal ? 16 : 14, + fontWeight: isTotal ? FontWeight.bold : FontWeight.normal, + color: isVoid + ? Colors.red + : (isTotal ? AppColors.primary : Colors.grey[700]), + ), + ), + Text( + value, + style: TextStyle( + fontSize: isTotal ? 16 : 14, + fontWeight: isTotal ? FontWeight.bold : FontWeight.w500, + color: isVoid + ? Colors.red + : (isTotal ? AppColors.primary : Colors.grey[700]), + ), + ), + ], + ), + ); + } + + Color _getStatusColor(String? status) { + switch (status?.toLowerCase()) { + case 'completed': + return Colors.green; + case 'pending': + return Colors.orange; + case 'cancelled': + return Colors.red; + case 'processing': + return Colors.blue; + default: + return Colors.grey; + } + } + + int _calculateVoidAmount() { + int total = 0; + selectedItemQuantities.forEach((itemId, voidQty) { + final item = widget.selectedOrder.orderItems! + .firstWhere((item) => item.id == itemId); + total += (item.unitPrice ?? 0) * voidQty; + }); + return total; + } + + bool _canProcessVoid() { + if (voidReason.trim().isEmpty) return false; + if (voidType == 'item' && selectedItemQuantities.isEmpty) return false; + return true; + } + + void _processVoid() { + String confirmMessage; + if (voidType == 'all') { + confirmMessage = + 'Apakah Anda yakin ingin membatalkan seluruh pesanan #${widget.selectedOrder.orderNumber}?\n\nIni akan membatalkan semua item dalam pesanan.'; + } else { + int totalItems = + selectedItemQuantities.values.fold(0, (sum, qty) => sum + qty); + confirmMessage = + 'Apakah Anda yakin ingin membatalkan $totalItems item dari pesanan #${widget.selectedOrder.orderNumber}?\n\nJumlah Batal: ${(_calculateVoidAmount()).currencyFormatRpV2}'; + } + + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return ConfirmVoidDialog( + message: confirmMessage, + onTap: _performVoid, + order: widget.selectedOrder, + voidType: voidType, + selectedItemQuantities: selectedItemQuantities, + voidReason: voidReason, + ); + }, + ); + } + + void _performVoid() { + // Prepare order items for void + List voidItems = []; + + if (voidType == 'item') { + selectedItemQuantities.forEach((itemId, voidQty) { + final originalItem = widget.selectedOrder.orderItems! + .firstWhere((item) => item.id == itemId); + + // Create new OrderItem with void quantity + voidItems.add(OrderItem( + id: originalItem.id, + orderId: originalItem.orderId, + productId: originalItem.productId, + productName: originalItem.productName, + productVariantId: originalItem.productVariantId, + productVariantName: originalItem.productVariantName, + quantity: voidQty, // This is the void quantity + unitPrice: originalItem.unitPrice, + totalPrice: (originalItem.unitPrice ?? 0) * voidQty, + modifiers: originalItem.modifiers, + notes: originalItem.notes, + status: originalItem.status, + createdAt: originalItem.createdAt, + updatedAt: originalItem.updatedAt, + )); + }); + } + + // Trigger void order event + context.read().add( + VoidOrderEvent.voidOrder( + orderId: widget.selectedOrder.id!, + reason: voidReason, + type: voidType.toUpperCase(), + orderItems: voidItems, + ), + ); + } + + void _showSuccessDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon(Icons.check_circle, color: Colors.green), + SpaceWidth(8), + Text('Void Berhasil'), + ], + ), + content: Text( + voidType == 'all' + ? 'Pesanan #${widget.selectedOrder.orderNumber} telah berhasil dibatalkan.' + : 'Produk yang dipilih dari pesanan #${widget.selectedOrder.orderNumber} telah berhasil dibatalkan.\n\nJumlah yang Dibatalkan: ${(_calculateVoidAmount()).currencyFormatRpV2}', + ), + actions: [ + ElevatedButton( + onPressed: () { + Navigator.pop(context); // Close dialog + Navigator.pop(context); // Go back to previous screen + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text('OK'), + ), + ], + ); + }, + ); + } + + void _showErrorDialog(String message) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Row( + children: [ + Icon(Icons.error, color: Colors.red), + SpaceWidth(8), + Text('Void Gagal'), + ], + ), + content: Text(message), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + foregroundColor: Colors.white, + ), + child: Text('OK'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/presentation/void/widgets/product_card.dart b/lib/presentation/void/widgets/product_card.dart new file mode 100644 index 0000000..5709b29 --- /dev/null +++ b/lib/presentation/void/widgets/product_card.dart @@ -0,0 +1,293 @@ +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 VoidProductCard extends StatelessWidget { + final bool isSelected; + final OrderItem item; + final int voidQty; + final bool canSelect; + final Function()? onTapDecrease; + final Function()? onTapIncrease; + final Function()? onTapAll; + final Function()? onTapClear; + const VoidProductCard({ + super.key, + required this.isSelected, + required this.item, + required this.voidQty, + required this.canSelect, + required this.onTapDecrease, + required this.onTapIncrease, + required this.onTapAll, + required this.onTapClear, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: isSelected ? AppColors.primary.withOpacity(0.1) : Colors.white, + border: Border.all( + color: isSelected ? AppColors.primary : Colors.grey[300]!, + width: isSelected ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Padding( + padding: EdgeInsets.all(16), + child: Column( + children: [ + Row( + children: [ + // Product Icon + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.fastfood, + color: AppColors.primary, + size: 24, + ), + ), + SizedBox(width: 12), + + // Product Info + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? 'Produk Tidak Diketahui', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + if (item.productVariantName != null) + Text( + 'Varian: ${item.productVariantName}', + style: + TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + SizedBox(height: 4), + Row( + children: [ + Container( + padding: EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Qty: ${item.quantity}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Colors.blue[700], + ), + ), + ), + SizedBox(width: 8), + Text( + (item.unitPrice ?? 0).currencyFormatRpV2, + style: TextStyle( + fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + if (item.notes != null && item.notes!.isNotEmpty) + Padding( + padding: EdgeInsets.only(top: 4), + child: Container( + padding: EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Catatan: ${item.notes}', + style: TextStyle( + fontSize: 11, + color: Colors.orange[700], + ), + ), + ), + ), + ], + ), + ), + + // Price + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + (item.totalPrice ?? 0).currencyFormatRpV2, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + color: AppColors.primary, + ), + ), + if (isSelected) + Container( + padding: + EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + 'Void: ${((item.unitPrice ?? 0) * voidQty).currencyFormatRpV2}', + style: TextStyle( + fontSize: 12, + color: Colors.red[700], + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ], + ), + + // Quantity Controls (only show for item void) + if (canSelect) ...[ + SizedBox(height: 16), + Container( + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Row( + children: [ + Text( + 'Kuantitas:', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.grey[700], + ), + ), + Spacer(), + + // Quantity Controls + Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Decrease Button + InkWell( + onTap: onTapDecrease, + child: Container( + padding: EdgeInsets.all(8), + child: Icon( + Icons.remove, + size: 16, + color: voidQty > 0 + ? AppColors.primary + : Colors.grey[400], + ), + ), + ), + + // Quantity Display + Container( + padding: EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border.symmetric( + vertical: BorderSide(color: Colors.grey[300]!), + ), + ), + child: Text( + '$voidQty', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + ), + + // Increase Button + InkWell( + onTap: onTapIncrease, + child: Container( + padding: EdgeInsets.all(8), + child: Icon( + Icons.add, + size: 16, + color: voidQty < (item.quantity ?? 0) + ? AppColors.primary + : Colors.grey[400], + ), + ), + ), + ], + ), + ), + + SizedBox(width: 12), + + // Quick Actions + Row( + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: onTapAll, + style: TextButton.styleFrom( + padding: EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + minimumSize: Size(0, 0), + ), + child: Text( + 'Semua', + style: TextStyle( + fontSize: 12, + color: AppColors.primary, + ), + ), + ), + TextButton( + onPressed: onTapClear, + style: TextButton.styleFrom( + padding: EdgeInsets.symmetric( + horizontal: 8, vertical: 4), + minimumSize: Size(0, 0), + ), + child: Text( + 'Hapus', + style: TextStyle( + fontSize: 12, + color: + voidQty > 0 ? Colors.red : Colors.grey[400], + ), + ), + ), + ], + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/presentation/void/widgets/void_loading.dart b/lib/presentation/void/widgets/void_loading.dart new file mode 100644 index 0000000..3fdcfab --- /dev/null +++ b/lib/presentation/void/widgets/void_loading.dart @@ -0,0 +1,40 @@ +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:flutter/material.dart'; + +class VoidLoading extends StatelessWidget { + const VoidLoading({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.black.withOpacity(0.3), + child: Center( + child: Container( + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.primary), + ), + SizedBox(height: 16), + Text( + 'Processing void...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/presentation/void/widgets/void_radio.dart b/lib/presentation/void/widgets/void_radio.dart new file mode 100644 index 0000000..88b7e14 --- /dev/null +++ b/lib/presentation/void/widgets/void_radio.dart @@ -0,0 +1,44 @@ +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:flutter/material.dart'; + +class VoidRadio extends StatelessWidget { + final String voidType; + final String value; + final Function(String?)? onChanged; + final String title; + final String subtitle; + const VoidRadio({ + super.key, + required this.voidType, + required this.value, + this.onChanged, + required this.title, + required this.subtitle, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + border: Border.all( + color: voidType == value ? AppColors.primary : Colors.grey[300]!, + width: voidType == value ? 2 : 1, + ), + borderRadius: BorderRadius.circular(8), + ), + child: RadioListTile( + title: Text( + title, + style: TextStyle(fontWeight: FontWeight.w600), + ), + subtitle: Text( + subtitle, + style: TextStyle(fontSize: 12, color: Colors.grey[600]), + ), + value: value, + groupValue: voidType, + activeColor: AppColors.primary, + onChanged: onChanged), + ); + } +}