diff --git a/lib/data/datasources/analytic_remote_datasource.dart b/lib/data/datasources/analytic_remote_datasource.dart index 226a74d..96e8ddb 100644 --- a/lib/data/datasources/analytic_remote_datasource.dart +++ b/lib/data/datasources/analytic_remote_datasource.dart @@ -7,6 +7,7 @@ import 'package:enaklo_pos/core/network/dio_client.dart'; import 'package:enaklo_pos/data/datasources/auth_local_datasource.dart'; import 'package:enaklo_pos/data/models/response/category_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/dashboard_analytic_response_model.dart'; +import 'package:enaklo_pos/data/models/response/inventory_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/payment_method_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/product_analytic_response_model.dart'; import 'package:enaklo_pos/data/models/response/profit_loss_response_model.dart'; @@ -219,4 +220,38 @@ class AnalyticRemoteDatasource { return left('Unexpected error occurred'); } } + + Future> getInventory({ + required DateTime dateFrom, + required DateTime dateTo, + }) async { + final authData = await AuthLocalDataSource().getAuthData(); + final headers = { + 'Authorization': 'Bearer ${authData.token}', + 'Accept': 'application/json', + }; + + try { + final response = await dio.get( + '${Variables.baseUrl}/api/v1/inventory/report/details/${authData.user?.outletId}', + queryParameters: { + 'date_from': DateFormat('dd-MM-yyyy').format(dateFrom), + 'date_to': DateFormat('dd-MM-yyyy').format(dateTo), + }, + options: Options(headers: headers), + ); + + if (response.statusCode == 200) { + return right(InventoryAnalyticResponseModel.fromMap(response.data)); + } else { + return left('Terjadi Kesalahan, Coba lagi nanti.'); + } + } on DioException catch (e) { + log('Dio error: ${e.message}'); + return left(e.response?.data.toString() ?? e.message ?? 'Unknown error'); + } catch (e) { + log('Unexpected error: $e'); + return left('Unexpected error occurred'); + } + } } diff --git a/lib/data/models/response/inventory_analytic_response_model.dart b/lib/data/models/response/inventory_analytic_response_model.dart new file mode 100644 index 0000000..265ae08 --- /dev/null +++ b/lib/data/models/response/inventory_analytic_response_model.dart @@ -0,0 +1,290 @@ +class InventoryAnalyticResponseModel { + final bool success; + final InventoryAnalyticData? data; + final dynamic errors; + + InventoryAnalyticResponseModel({ + required this.success, + required this.data, + this.errors, + }); + + // From JSON + factory InventoryAnalyticResponseModel.fromJson(Map json) { + return InventoryAnalyticResponseModel( + success: json['success'], + data: json['data'] != null + ? InventoryAnalyticData.fromMap(json['data']) + : null, + errors: json['errors'], + ); + } + + // To JSON + Map toJson() { + return { + 'success': success, + 'data': data?.toMap(), + 'errors': errors, + }; + } + + // From Map + factory InventoryAnalyticResponseModel.fromMap(Map map) { + return InventoryAnalyticResponseModel( + success: map['success'], + data: map['data'] != null + ? InventoryAnalyticData.fromMap(map['data']) + : null, + errors: map['errors'], + ); + } + + // To Map + Map toMap() { + return { + 'success': success, + 'data': data?.toMap(), + 'errors': errors, + }; + } +} + +class InventoryAnalyticData { + final InventorySummary summary; + final List products; + final List ingredients; + + InventoryAnalyticData({ + required this.summary, + required this.products, + required this.ingredients, + }); + + factory InventoryAnalyticData.fromMap(Map map) { + return InventoryAnalyticData( + summary: InventorySummary.fromMap(map['summary']), + products: map['products'] == null + ? [] + : List.from( + map['products']?.map((x) => InventoryProductItem.fromMap(x)) ?? + [], + ), + ingredients: map['ingredients'] == null + ? [] + : List.from( + map['ingredients'] + ?.map((x) => InventoryIngredientItem.fromMap(x)) ?? + [], + ), + ); + } + + Map toMap() { + return { + 'summary': summary.toMap(), + 'products': products.map((x) => x.toMap()).toList(), + 'ingredients': ingredients.map((x) => x.toMap()).toList(), + }; + } +} + +class InventorySummary { + final int totalProducts; + final int totalIngredients; + final int totalValue; + final int lowStockProducts; + final int lowStockIngredients; + final int zeroStockProducts; + final int zeroStockIngredients; + final int totalSoldProducts; + final int totalSoldIngredients; + final String outletId; + final String outletName; + final DateTime generatedAt; + + InventorySummary({ + required this.totalProducts, + required this.totalIngredients, + required this.totalValue, + required this.lowStockProducts, + required this.lowStockIngredients, + required this.zeroStockProducts, + required this.zeroStockIngredients, + required this.totalSoldProducts, + required this.totalSoldIngredients, + required this.outletId, + required this.outletName, + required this.generatedAt, + }); + + factory InventorySummary.fromMap(Map map) { + return InventorySummary( + totalProducts: map['total_products'] ?? 0, + totalIngredients: map['total_ingredients'] ?? 0, + totalValue: map['total_value'] ?? 0, + lowStockProducts: map['low_stock_products'] ?? 0, + lowStockIngredients: map['low_stock_ingredients'] ?? 0, + zeroStockProducts: map['zero_stock_products'] ?? 0, + zeroStockIngredients: map['zero_stock_ingredients'] ?? 0, + totalSoldProducts: map['total_sold_products'] ?? 0, + totalSoldIngredients: map['total_sold_ingredients'] ?? 0, + outletId: map['outlet_id'], + outletName: map['outlet_name'], + generatedAt: DateTime.parse(map['generated_at']), + ); + } + + Map toMap() { + return { + 'total_products': totalProducts, + 'total_ingredients': totalIngredients, + 'total_value': totalValue, + 'low_stock_products': lowStockProducts, + 'low_stock_ingredients': lowStockIngredients, + 'zero_stock_products': zeroStockProducts, + 'zero_stock_ingredients': zeroStockIngredients, + 'total_sold_products': totalSoldProducts, + 'total_sold_ingredients': totalSoldIngredients, + 'outlet_id': outletId, + 'outlet_name': outletName, + 'generated_at': generatedAt.toIso8601String(), + }; + } +} + +class InventoryProductItem { + final String id; + final String productId; + final String productName; + final String categoryName; + final int quantity; + final int reorderLevel; + final int unitCost; + final int totalValue; + final int totalIn; + final int totalOut; + final bool isLowStock; + final bool isZeroStock; + final DateTime updatedAt; + + InventoryProductItem({ + required this.id, + required this.productId, + required this.productName, + required this.categoryName, + required this.quantity, + required this.reorderLevel, + required this.unitCost, + required this.totalValue, + required this.totalIn, + required this.totalOut, + required this.isLowStock, + required this.isZeroStock, + required this.updatedAt, + }); + + factory InventoryProductItem.fromMap(Map map) { + return InventoryProductItem( + id: map['id'], + productId: map['product_id'], + productName: map['product_name'], + categoryName: map['category_name'], + quantity: map['quantity'] ?? 0, + reorderLevel: map['reorder_level'] ?? 0, + unitCost: map['unit_cost'] ?? 0, + totalValue: map['total_value'] ?? 0, + totalIn: map['total_in'] ?? 0, + totalOut: map['total_out'] ?? 0, + isLowStock: map['is_low_stock'] ?? false, + isZeroStock: map['is_zero_stock'] ?? false, + updatedAt: DateTime.parse(map['updated_at']), + ); + } + + Map toMap() { + return { + 'id': id, + 'product_id': productId, + 'product_name': productName, + 'category_name': categoryName, + 'quantity': quantity, + 'reorder_level': reorderLevel, + 'unit_cost': unitCost, + 'total_value': totalValue, + 'total_in': totalIn, + 'total_out': totalOut, + 'is_low_stock': isLowStock, + 'is_zero_stock': isZeroStock, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} + +class InventoryIngredientItem { + final String id; + final String ingredientId; + final String ingredientName; + final String unitName; + final int quantity; + final int reorderLevel; + final int unitCost; + final int totalValue; + final int totalIn; + final int totalOut; + final bool isLowStock; + final bool isZeroStock; + final DateTime updatedAt; + + InventoryIngredientItem({ + required this.id, + required this.ingredientId, + required this.ingredientName, + required this.unitName, + required this.quantity, + required this.reorderLevel, + required this.unitCost, + required this.totalValue, + required this.totalIn, + required this.totalOut, + required this.isLowStock, + required this.isZeroStock, + required this.updatedAt, + }); + + factory InventoryIngredientItem.fromMap(Map map) { + return InventoryIngredientItem( + id: map['id'], + ingredientId: map['ingredient_id'], + ingredientName: map['ingredient_name'], + unitName: map['unit_name'], + quantity: map['quantity'] ?? 0, + reorderLevel: map['reorder_level'] ?? 0, + unitCost: map['unit_cost'] ?? 0, + totalValue: map['total_value'] ?? 0, + totalIn: map['total_in'] ?? 0, + totalOut: map['total_out'] ?? 0, + isLowStock: map['is_low_stock'] ?? false, + isZeroStock: map['is_zero_stock'] ?? false, + updatedAt: DateTime.parse(map['updated_at']), + ); + } + + Map toMap() { + return { + 'id': id, + 'ingredient_id': ingredientId, + 'ingredient_name': ingredientName, + 'unit_name': unitName, + 'quantity': quantity, + 'reorder_level': reorderLevel, + 'unit_cost': unitCost, + 'total_value': totalValue, + 'total_in': totalIn, + 'total_out': totalOut, + 'is_low_stock': isLowStock, + 'is_zero_stock': isZeroStock, + 'updated_at': updatedAt.toIso8601String(), + }; + } +} diff --git a/lib/main.dart b/lib/main.dart index 2185456..d0ff9c6 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -16,6 +16,7 @@ import 'package:enaklo_pos/presentation/home/bloc/outlet_loader/outlet_loader_bl import 'package:enaklo_pos/presentation/home/bloc/product_loader/product_loader_bloc.dart'; import 'package:enaklo_pos/presentation/home/bloc/user_update_outlet/user_update_outlet_bloc.dart'; import 'package:enaklo_pos/presentation/refund/bloc/refund_bloc.dart'; +import 'package:enaklo_pos/presentation/report/blocs/inventory_report/inventory_report_bloc.dart'; import 'package:enaklo_pos/presentation/report/blocs/profit_loss/profit_loss_bloc.dart'; import 'package:enaklo_pos/presentation/report/blocs/report/report_bloc.dart'; import 'package:enaklo_pos/presentation/sales/blocs/order_loader/order_loader_bloc.dart'; @@ -292,6 +293,9 @@ class _MyAppState extends State { BlocProvider( create: (context) => ReportBloc(AnalyticRemoteDatasource()), ), + BlocProvider( + create: (context) => InventoryReportBloc(AnalyticRemoteDatasource()), + ), ], child: MaterialApp( navigatorKey: AuthInterceptor.navigatorKey, diff --git a/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.dart b/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.dart new file mode 100644 index 0000000..26290e2 --- /dev/null +++ b/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.dart @@ -0,0 +1,29 @@ +import 'package:bloc/bloc.dart'; +import 'package:enaklo_pos/data/datasources/analytic_remote_datasource.dart'; +import 'package:enaklo_pos/data/models/response/inventory_analytic_response_model.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'inventory_report_event.dart'; +part 'inventory_report_state.dart'; +part 'inventory_report_bloc.freezed.dart'; + +class InventoryReportBloc + extends Bloc { + final AnalyticRemoteDatasource _datasource; + InventoryReportBloc(this._datasource) + : super(InventoryReportState.initial()) { + on<_Get>((event, emit) async { + emit(_Loading()); + + final result = await _datasource.getInventory( + dateFrom: event.startDate, dateTo: event.endDate); + + result.fold( + (f) => emit(_Error(f)), + (r) => emit( + _Loaded(r.data!), + ), + ); + }); + } +} diff --git a/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.freezed.dart b/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.freezed.dart new file mode 100644 index 0000000..cff6319 --- /dev/null +++ b/lib/presentation/report/blocs/inventory_report/inventory_report_bloc.freezed.dart @@ -0,0 +1,863 @@ +// 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 'inventory_report_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 _$InventoryReportEvent { + DateTime get startDate => throw _privateConstructorUsedError; + DateTime get endDate => throw _privateConstructorUsedError; + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime startDate, DateTime endDate) get, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime startDate, DateTime endDate)? get, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime startDate, DateTime endDate)? get, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult map({ + required TResult Function(_Get value) get, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Get value)? get, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Get value)? get, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; + + /// Create a copy of InventoryReportEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $InventoryReportEventCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $InventoryReportEventCopyWith<$Res> { + factory $InventoryReportEventCopyWith(InventoryReportEvent value, + $Res Function(InventoryReportEvent) then) = + _$InventoryReportEventCopyWithImpl<$Res, InventoryReportEvent>; + @useResult + $Res call({DateTime startDate, DateTime endDate}); +} + +/// @nodoc +class _$InventoryReportEventCopyWithImpl<$Res, + $Val extends InventoryReportEvent> + implements $InventoryReportEventCopyWith<$Res> { + _$InventoryReportEventCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of InventoryReportEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? startDate = null, + Object? endDate = null, + }) { + return _then(_value.copyWith( + startDate: null == startDate + ? _value.startDate + : startDate // ignore: cast_nullable_to_non_nullable + as DateTime, + endDate: null == endDate + ? _value.endDate + : endDate // ignore: cast_nullable_to_non_nullable + as DateTime, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$GetImplCopyWith<$Res> + implements $InventoryReportEventCopyWith<$Res> { + factory _$$GetImplCopyWith(_$GetImpl value, $Res Function(_$GetImpl) then) = + __$$GetImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({DateTime startDate, DateTime endDate}); +} + +/// @nodoc +class __$$GetImplCopyWithImpl<$Res> + extends _$InventoryReportEventCopyWithImpl<$Res, _$GetImpl> + implements _$$GetImplCopyWith<$Res> { + __$$GetImplCopyWithImpl(_$GetImpl _value, $Res Function(_$GetImpl) _then) + : super(_value, _then); + + /// Create a copy of InventoryReportEvent + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? startDate = null, + Object? endDate = null, + }) { + return _then(_$GetImpl( + startDate: null == startDate + ? _value.startDate + : startDate // ignore: cast_nullable_to_non_nullable + as DateTime, + endDate: null == endDate + ? _value.endDate + : endDate // ignore: cast_nullable_to_non_nullable + as DateTime, + )); + } +} + +/// @nodoc + +class _$GetImpl implements _Get { + const _$GetImpl({required this.startDate, required this.endDate}); + + @override + final DateTime startDate; + @override + final DateTime endDate; + + @override + String toString() { + return 'InventoryReportEvent.get(startDate: $startDate, endDate: $endDate)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$GetImpl && + (identical(other.startDate, startDate) || + other.startDate == startDate) && + (identical(other.endDate, endDate) || other.endDate == endDate)); + } + + @override + int get hashCode => Object.hash(runtimeType, startDate, endDate); + + /// Create a copy of InventoryReportEvent + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$GetImplCopyWith<_$GetImpl> get copyWith => + __$$GetImplCopyWithImpl<_$GetImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function(DateTime startDate, DateTime endDate) get, + }) { + return get(startDate, endDate); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function(DateTime startDate, DateTime endDate)? get, + }) { + return get?.call(startDate, endDate); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function(DateTime startDate, DateTime endDate)? get, + required TResult orElse(), + }) { + if (get != null) { + return get(startDate, endDate); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Get value) get, + }) { + return get(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Get value)? get, + }) { + return get?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Get value)? get, + required TResult orElse(), + }) { + if (get != null) { + return get(this); + } + return orElse(); + } +} + +abstract class _Get implements InventoryReportEvent { + const factory _Get( + {required final DateTime startDate, + required final DateTime endDate}) = _$GetImpl; + + @override + DateTime get startDate; + @override + DateTime get endDate; + + /// Create a copy of InventoryReportEvent + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$GetImplCopyWith<_$GetImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$InventoryReportState { + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(InventoryAnalyticData data) loaded, + required TResult Function(String message) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(InventoryAnalyticData data)? loaded, + TResult? Function(String message)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(InventoryAnalyticData data)? loaded, + 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(_Loaded value) loaded, + required TResult Function(_Error value) error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Loaded value)? loaded, + TResult? Function(_Error value)? error, + }) => + throw _privateConstructorUsedError; + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Loaded value)? loaded, + TResult Function(_Error value)? error, + required TResult orElse(), + }) => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $InventoryReportStateCopyWith<$Res> { + factory $InventoryReportStateCopyWith(InventoryReportState value, + $Res Function(InventoryReportState) then) = + _$InventoryReportStateCopyWithImpl<$Res, InventoryReportState>; +} + +/// @nodoc +class _$InventoryReportStateCopyWithImpl<$Res, + $Val extends InventoryReportState> + implements $InventoryReportStateCopyWith<$Res> { + _$InventoryReportStateCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of InventoryReportState + /// 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 _$InventoryReportStateCopyWithImpl<$Res, _$InitialImpl> + implements _$$InitialImplCopyWith<$Res> { + __$$InitialImplCopyWithImpl( + _$InitialImpl _value, $Res Function(_$InitialImpl) _then) + : super(_value, _then); + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$InitialImpl implements _Initial { + const _$InitialImpl(); + + @override + String toString() { + return 'InventoryReportState.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(InventoryAnalyticData data) loaded, + required TResult Function(String message) error, + }) { + return initial(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(InventoryAnalyticData data)? loaded, + TResult? Function(String message)? error, + }) { + return initial?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(InventoryAnalyticData data)? loaded, + 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(_Loaded value) loaded, + 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(_Loaded value)? loaded, + 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(_Loaded value)? loaded, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (initial != null) { + return initial(this); + } + return orElse(); + } +} + +abstract class _Initial implements InventoryReportState { + const factory _Initial() = _$InitialImpl; +} + +/// @nodoc +abstract class _$$LoadingImplCopyWith<$Res> { + factory _$$LoadingImplCopyWith( + _$LoadingImpl value, $Res Function(_$LoadingImpl) then) = + __$$LoadingImplCopyWithImpl<$Res>; +} + +/// @nodoc +class __$$LoadingImplCopyWithImpl<$Res> + extends _$InventoryReportStateCopyWithImpl<$Res, _$LoadingImpl> + implements _$$LoadingImplCopyWith<$Res> { + __$$LoadingImplCopyWithImpl( + _$LoadingImpl _value, $Res Function(_$LoadingImpl) _then) + : super(_value, _then); + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. +} + +/// @nodoc + +class _$LoadingImpl implements _Loading { + const _$LoadingImpl(); + + @override + String toString() { + return 'InventoryReportState.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(InventoryAnalyticData data) loaded, + required TResult Function(String message) error, + }) { + return loading(); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(InventoryAnalyticData data)? loaded, + TResult? Function(String message)? error, + }) { + return loading?.call(); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(InventoryAnalyticData data)? loaded, + 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(_Loaded value) loaded, + 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(_Loaded value)? loaded, + 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(_Loaded value)? loaded, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (loading != null) { + return loading(this); + } + return orElse(); + } +} + +abstract class _Loading implements InventoryReportState { + const factory _Loading() = _$LoadingImpl; +} + +/// @nodoc +abstract class _$$LoadedImplCopyWith<$Res> { + factory _$$LoadedImplCopyWith( + _$LoadedImpl value, $Res Function(_$LoadedImpl) then) = + __$$LoadedImplCopyWithImpl<$Res>; + @useResult + $Res call({InventoryAnalyticData data}); +} + +/// @nodoc +class __$$LoadedImplCopyWithImpl<$Res> + extends _$InventoryReportStateCopyWithImpl<$Res, _$LoadedImpl> + implements _$$LoadedImplCopyWith<$Res> { + __$$LoadedImplCopyWithImpl( + _$LoadedImpl _value, $Res Function(_$LoadedImpl) _then) + : super(_value, _then); + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? data = null, + }) { + return _then(_$LoadedImpl( + null == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as InventoryAnalyticData, + )); + } +} + +/// @nodoc + +class _$LoadedImpl implements _Loaded { + const _$LoadedImpl(this.data); + + @override + final InventoryAnalyticData data; + + @override + String toString() { + return 'InventoryReportState.loaded(data: $data)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LoadedImpl && + (identical(other.data, data) || other.data == data)); + } + + @override + int get hashCode => Object.hash(runtimeType, data); + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LoadedImplCopyWith<_$LoadedImpl> get copyWith => + __$$LoadedImplCopyWithImpl<_$LoadedImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function() initial, + required TResult Function() loading, + required TResult Function(InventoryAnalyticData data) loaded, + required TResult Function(String message) error, + }) { + return loaded(data); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(InventoryAnalyticData data)? loaded, + TResult? Function(String message)? error, + }) { + return loaded?.call(data); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(InventoryAnalyticData data)? loaded, + TResult Function(String message)? error, + required TResult orElse(), + }) { + if (loaded != null) { + return loaded(data); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(_Initial value) initial, + required TResult Function(_Loading value) loading, + required TResult Function(_Loaded value) loaded, + required TResult Function(_Error value) error, + }) { + return loaded(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(_Initial value)? initial, + TResult? Function(_Loading value)? loading, + TResult? Function(_Loaded value)? loaded, + TResult? Function(_Error value)? error, + }) { + return loaded?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(_Initial value)? initial, + TResult Function(_Loading value)? loading, + TResult Function(_Loaded value)? loaded, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (loaded != null) { + return loaded(this); + } + return orElse(); + } +} + +abstract class _Loaded implements InventoryReportState { + const factory _Loaded(final InventoryAnalyticData data) = _$LoadedImpl; + + InventoryAnalyticData get data; + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LoadedImplCopyWith<_$LoadedImpl> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$$ErrorImplCopyWith<$Res> { + factory _$$ErrorImplCopyWith( + _$ErrorImpl value, $Res Function(_$ErrorImpl) then) = + __$$ErrorImplCopyWithImpl<$Res>; + @useResult + $Res call({String message}); +} + +/// @nodoc +class __$$ErrorImplCopyWithImpl<$Res> + extends _$InventoryReportStateCopyWithImpl<$Res, _$ErrorImpl> + implements _$$ErrorImplCopyWith<$Res> { + __$$ErrorImplCopyWithImpl( + _$ErrorImpl _value, $Res Function(_$ErrorImpl) _then) + : super(_value, _then); + + /// Create a copy of InventoryReportState + /// 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 'InventoryReportState.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 InventoryReportState + /// 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(InventoryAnalyticData data) loaded, + required TResult Function(String message) error, + }) { + return error(message); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function()? initial, + TResult? Function()? loading, + TResult? Function(InventoryAnalyticData data)? loaded, + TResult? Function(String message)? error, + }) { + return error?.call(message); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function()? initial, + TResult Function()? loading, + TResult Function(InventoryAnalyticData data)? loaded, + 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(_Loaded value) loaded, + 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(_Loaded value)? loaded, + 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(_Loaded value)? loaded, + TResult Function(_Error value)? error, + required TResult orElse(), + }) { + if (error != null) { + return error(this); + } + return orElse(); + } +} + +abstract class _Error implements InventoryReportState { + const factory _Error(final String message) = _$ErrorImpl; + + String get message; + + /// Create a copy of InventoryReportState + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + _$$ErrorImplCopyWith<_$ErrorImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/presentation/report/blocs/inventory_report/inventory_report_event.dart b/lib/presentation/report/blocs/inventory_report/inventory_report_event.dart new file mode 100644 index 0000000..a3eec73 --- /dev/null +++ b/lib/presentation/report/blocs/inventory_report/inventory_report_event.dart @@ -0,0 +1,9 @@ +part of 'inventory_report_bloc.dart'; + +@freezed +class InventoryReportEvent with _$InventoryReportEvent { + const factory InventoryReportEvent.get({ + required DateTime startDate, + required DateTime endDate, + }) = _Get; +} diff --git a/lib/presentation/report/blocs/inventory_report/inventory_report_state.dart b/lib/presentation/report/blocs/inventory_report/inventory_report_state.dart new file mode 100644 index 0000000..316fe28 --- /dev/null +++ b/lib/presentation/report/blocs/inventory_report/inventory_report_state.dart @@ -0,0 +1,10 @@ +part of 'inventory_report_bloc.dart'; + +@freezed +class InventoryReportState with _$InventoryReportState { + const factory InventoryReportState.initial() = _Initial; + const factory InventoryReportState.loading() = _Loading; + const factory InventoryReportState.loaded(InventoryAnalyticData data) = + _Loaded; + const factory InventoryReportState.error(String message) = _Error; +} diff --git a/lib/presentation/report/pages/report_page.dart b/lib/presentation/report/pages/report_page.dart index fd94ad5..c052804 100644 --- a/lib/presentation/report/pages/report_page.dart +++ b/lib/presentation/report/pages/report_page.dart @@ -4,9 +4,11 @@ import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; import 'package:enaklo_pos/core/utils/helper_pdf_service.dart'; import 'package:enaklo_pos/core/utils/permession_handler.dart'; import 'package:enaklo_pos/core/utils/transaction_report.dart'; +import 'package:enaklo_pos/presentation/report/blocs/inventory_report/inventory_report_bloc.dart'; import 'package:enaklo_pos/presentation/report/blocs/profit_loss/profit_loss_bloc.dart'; import 'package:enaklo_pos/presentation/report/blocs/report/report_bloc.dart'; import 'package:enaklo_pos/presentation/report/widgets/dashboard_analytic_widget.dart'; +import 'package:enaklo_pos/presentation/report/widgets/inventory_report_widget.dart'; import 'package:enaklo_pos/presentation/report/widgets/profit_loss_widget.dart'; import 'package:enaklo_pos/presentation/sales/pages/sales_page.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -288,6 +290,23 @@ class _ReportPageState extends State { }, isActive: selectedMenu == 5, ), + ReportMenu( + label: 'Laporan Inventori', + subtitle: 'Laporan inventori produk', + icon: Icons.archive_outlined, + onPressed: () { + selectedMenu = 6; + title = 'Laporan Inventori'; + setState(() {}); + context.read().add( + InventoryReportEvent.get( + startDate: fromDate, + endDate: toDate, + ), + ); + }, + isActive: selectedMenu == 6, + ), ], ), ), @@ -438,7 +457,32 @@ class _ReportPageState extends State { ); }, ) - : const SizedBox.shrink()), + : selectedMenu == 6 + ? BlocBuilder< + InventoryReportBloc, + InventoryReportState>( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + const Center( + child: + CircularProgressIndicator(), + ), + error: (message) { + return Text(message); + }, + loaded: (data) { + return InventoryReportWidget( + title: title, + searchDateFormatted: + searchDateFormatted, + inventory: data, + ); + }, + ); + }, + ) + : const SizedBox.shrink()), ], ), ), diff --git a/lib/presentation/report/widgets/inventory_report_widget.dart b/lib/presentation/report/widgets/inventory_report_widget.dart new file mode 100644 index 0000000..3be1d44 --- /dev/null +++ b/lib/presentation/report/widgets/inventory_report_widget.dart @@ -0,0 +1,586 @@ +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/string_ext.dart'; +import 'package:enaklo_pos/data/models/response/inventory_analytic_response_model.dart'; +import 'package:flutter/material.dart'; + +class InventoryReportWidget extends StatefulWidget { + final String title; + final String searchDateFormatted; + final InventoryAnalyticData inventory; + const InventoryReportWidget({ + super.key, + required this.title, + required this.searchDateFormatted, + required this.inventory, + }); + + @override + State createState() => _InventoryReportWidgetState(); +} + +class _InventoryReportWidgetState extends State { + int _selectedTabIndex = 0; + + @override + Widget build(BuildContext context) { + return Expanded( + flex: 4, + child: Container( + width: double.infinity, + decoration: BoxDecoration( + color: AppColors.white, + border: Border.all(color: AppColors.stroke, width: 1), + ), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Report Header + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.light, + border: Border( + bottom: BorderSide(color: AppColors.stroke, width: 1), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColors.primary, + ), + ), + const SizedBox(height: 4), + Text( + widget.searchDateFormatted, + style: TextStyle( + fontSize: 12, + color: AppColors.greyDark, + ), + ), + ], + ), + Row( + children: [ + // Download Button + GestureDetector( + onTap: () {}, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.primary, width: 1), + ), + child: Icon( + Icons.download_outlined, + size: 18, + color: AppColors.primary, + ), + ), + ), + const SizedBox(width: 12), + // Status Badge + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + decoration: BoxDecoration( + color: AppColors.green.withOpacity(0.1), + borderRadius: BorderRadius.circular(20), + border: + Border.all(color: AppColors.green, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 14, + color: AppColors.green, + ), + const SizedBox(width: 6), + Text( + 'Aktif', + style: TextStyle( + fontSize: 12, + color: AppColors.green, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ], + ), + ], + ), + ), + + // Summary Section + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.analytics_outlined, + size: 20, + color: AppColors.primary, + ), + const SizedBox(width: 8), + Text( + 'Ringkasan Inventori', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColors.black, + ), + ), + ], + ), + const SizedBox(height: 16), + + // Summary Grid + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + childAspectRatio: 2.2, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + _buildSummaryCard( + 'Total Produk', + (widget.inventory.summary.totalProducts).toString(), + AppColors.primary, + Icons.inventory_2_outlined, + ), + _buildSummaryCard( + 'Total Bahan', + widget.inventory.summary.totalIngredients.toString(), + AppColors.subtitle, + Icons.list_alt_outlined, + ), + _buildSummaryCard( + 'Total Nilai', + widget.inventory.summary.totalValue + .toString() + .currencyFormatRpV2, + AppColors.green, + Icons.monetization_on_outlined, + ), + ], + ), + ], + ), + ), + + // Divider + Container( + height: 1, + margin: const EdgeInsets.symmetric(horizontal: 20), + color: AppColors.stroke, + ), + + // Tabs + Container( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + _buildTab('Produk', 0), + const SizedBox(width: 12), + _buildTab('Bahan Baku', 1), + ], + ), + ), + + // Content based on selected tab + _selectedTabIndex == 0 + ? _buildProductsContent() + : _buildIngredientsContent(), + ], + ), + ), + ), + ); + } + + Widget _buildSummaryCard( + String title, String value, Color color, IconData icon) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.2), width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + icon, + size: 16, + color: color, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 10, + color: AppColors.greyDark, + fontWeight: FontWeight.w500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: color, + ), + ), + ], + ), + ); + } + + Widget _buildTab(String title, int index) { + bool isActive = _selectedTabIndex == index; + return GestureDetector( + onTap: () { + setState(() { + _selectedTabIndex = index; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + decoration: BoxDecoration( + color: isActive ? AppColors.primary : AppColors.white, + borderRadius: BorderRadius.circular(25), + border: Border.all( + color: isActive ? AppColors.primary : AppColors.stroke, + width: 1, + ), + ), + child: Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isActive ? AppColors.whiteText : AppColors.greyDark, + ), + ), + ), + ); + } + + Widget _buildProductsContent() { + return Container( + margin: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.primary, // Purple color + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Table( + columnWidths: const { + 0: FlexColumnWidth(2.5), // Produk + 1: FlexColumnWidth(2), // Kategori + 2: FlexColumnWidth(1), // Stock + 3: FlexColumnWidth(2), // Masuk + 4: FlexColumnWidth(2), // Keluar + }, + children: [ + TableRow( + children: [ + _buildHeaderCell('Nama'), + _buildHeaderCell('Kategori'), + _buildHeaderCell('Stock'), + _buildHeaderCell('Masuk'), + _buildHeaderCell('Keluar'), + ], + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: AppColors.white, + ), + child: Table( + columnWidths: { + 0: FlexColumnWidth(2.5), // Produk + 1: FlexColumnWidth(2), // Kategori + 2: FlexColumnWidth(1), // Stock + 3: FlexColumnWidth(2), // Masuk + 4: FlexColumnWidth(2), // Keluar + }, + children: widget.inventory.products + .map((item) => _buildProductDataRow( + item, + widget.inventory.products.indexOf(item) % 2 == 0, + )) + .toList(), + ), + ), + Container( + decoration: BoxDecoration( + color: AppColors.primary, // Purple color + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Table( + columnWidths: const { + 0: FlexColumnWidth(2.5), // Produk + 1: FlexColumnWidth(2), // Kategori + 2: FlexColumnWidth(1), // Stock + 3: FlexColumnWidth(2), // Masuk + 4: FlexColumnWidth(2), // Keluar + }, + children: [ + TableRow( + children: [ + _buildTotalCell('TOTAL'), + _buildTotalCell(''), + _buildTotalCell( + (widget.inventory.products.fold( + 0, (sum, item) => sum + (item.quantity))).toString(), + ), + _buildTotalCell( + (widget.inventory.products.fold( + 0, (sum, item) => sum + (item.totalIn))).toString(), + ), + _buildTotalCell( + (widget.inventory.products.fold( + 0, (sum, item) => sum + (item.totalOut))).toString(), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildIngredientsContent() { + return Container( + margin: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + children: [ + Container( + decoration: BoxDecoration( + color: AppColors.primary, // Purple color + borderRadius: BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Table( + columnWidths: const { + 0: FlexColumnWidth(2.5), // Name + 1: FlexColumnWidth(1), // Stock + 2: FlexColumnWidth(2), // Masuk + 3: FlexColumnWidth(2), // Keluar + }, + children: [ + TableRow( + children: [ + _buildHeaderCell('Nama'), + _buildHeaderCell('Stock'), + _buildHeaderCell('Masuk'), + _buildHeaderCell('Keluar'), + ], + ), + ], + ), + ), + Container( + decoration: BoxDecoration( + color: AppColors.white, + ), + child: Table( + columnWidths: { + 0: FlexColumnWidth(2.5), // Name + 1: FlexColumnWidth(1), // Stock + 2: FlexColumnWidth(2), // Masuk + 3: FlexColumnWidth(2), // Keluar + }, + children: widget.inventory.ingredients + .map((item) => _buildIngredientsDataRow( + item, + widget.inventory.ingredients.indexOf(item) % 2 == 0, + )) + .toList(), + ), + ), + Container( + decoration: BoxDecoration( + color: AppColors.primary, // Purple color + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(8), + bottomRight: Radius.circular(8), + ), + ), + child: Table( + columnWidths: const { + 0: FlexColumnWidth(2.5), // Name + 1: FlexColumnWidth(1), // Stock + 2: FlexColumnWidth(2), // Masuk + 3: FlexColumnWidth(2), // Keluar + }, + children: [ + TableRow( + children: [ + _buildTotalCell('TOTAL'), + _buildTotalCell( + (widget.inventory.ingredients.fold( + 0, (sum, item) => sum + (item.quantity))).toString(), + ), + _buildTotalCell( + (widget.inventory.ingredients.fold( + 0, (sum, item) => sum + (item.totalIn))).toString(), + ), + _buildTotalCell( + (widget.inventory.ingredients.fold( + 0, (sum, item) => sum + (item.totalOut))).toString(), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + TableRow _buildProductDataRow(InventoryProductItem product, bool isEven) { + return TableRow( + decoration: BoxDecoration( + color: product.isZeroStock + ? Colors.red.shade100 + : product.isLowStock + ? Colors.yellow.shade100 + : isEven + ? Colors.grey.shade50 + : AppColors.white, + ), + children: [ + _buildDataCell(product.productName, alignment: Alignment.centerLeft), + _buildDataCell(product.categoryName, alignment: Alignment.centerLeft), + _buildDataCell(product.quantity.toString()), + _buildDataCell(product.totalIn.toString()), + _buildDataCell(product.totalOut.toString()), + ], + ); + } + + TableRow _buildIngredientsDataRow(InventoryIngredientItem item, bool isEven) { + return TableRow( + decoration: BoxDecoration( + color: item.isZeroStock + ? Colors.red.shade100 + : item.isLowStock + ? Colors.yellow.shade100 + : isEven + ? Colors.grey.shade50 + : AppColors.white, + ), + children: [ + _buildDataCell(item.ingredientName, alignment: Alignment.centerLeft), + _buildDataCell(item.quantity.toString()), + _buildDataCell(item.totalIn.toString()), + _buildDataCell(item.totalOut.toString()), + ], + ); + } + + Widget _buildHeaderCell(String text) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: Text( + text, + style: TextStyle( + color: AppColors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ); + } + + Widget _buildDataCell(String text, + {Alignment alignment = Alignment.center, Color? textColor}) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), + alignment: alignment, + child: Text( + text, + style: TextStyle( + fontSize: 12, + color: textColor ?? AppColors.black, + fontWeight: FontWeight.normal, + ), + textAlign: alignment == Alignment.centerLeft + ? TextAlign.left + : TextAlign.center, + ), + ); + } + + Widget _buildTotalCell(String text) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 12, vertical: 16), + child: Text( + text, + style: TextStyle( + color: AppColors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + textAlign: TextAlign.center, + ), + ); + } +}