This commit is contained in:
Phuoc Nguyen
2025-10-28 16:48:31 +07:00
parent 5cfc56f40d
commit 4b35d236df
11 changed files with 390 additions and 102 deletions

View File

@@ -78,6 +78,12 @@ class ApiEndpoints {
/// Response: Product details with stage information
static const String productDetail = '/portalWareHouse/GetProductStageInWareHouse';
/// Create product warehouse (import/export)
/// POST: /portalWareHouse/createProductWareHouse
/// Body: Array of product warehouse creation objects
/// Response: Created product warehouse record
static const String createProductWarehouse = '/portalWareHouse/createProductWareHouse';
/// Get product by ID
/// GET: (requires auth token)
/// Parameter: productId

View File

@@ -81,32 +81,24 @@ class AppRouter {
),
/// Products List Route
/// Path: /products
/// Takes warehouse, warehouseName, and operationType as extra parameter
/// Path: /products/:warehouseId/:operationType
/// Query params: name (warehouse name)
/// Shows products for selected warehouse and operation
GoRoute(
path: '/products',
path: '/products/:warehouseId/:operationType',
name: 'products',
builder: (context, state) {
final params = state.extra as Map<String, dynamic>?;
// Extract path parameters
final warehouseIdStr = state.pathParameters['warehouseId'];
final operationType = state.pathParameters['operationType'];
if (params == null) {
// If no params, redirect to warehouses
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Product parameters are required',
);
}
// Extract query parameter
final warehouseName = state.uri.queryParameters['name'];
// Extract required parameters
final warehouse = params['warehouse'] as WarehouseEntity?;
final warehouseName = params['warehouseName'] as String?;
final operationType = params['operationType'] as String?;
// Parse and validate parameters
final warehouseId = int.tryParse(warehouseIdStr ?? '');
// Validate parameters
if (warehouse == null || warehouseName == null || operationType == null) {
if (warehouseId == null || warehouseName == null || operationType == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
@@ -116,7 +108,7 @@ class AppRouter {
}
return ProductsPage(
warehouseId: warehouse.id,
warehouseId: warehouseId,
warehouseName: warehouseName,
operationType: operationType,
);
@@ -124,34 +116,28 @@ class AppRouter {
),
/// Product Detail Route
/// Path: /product-detail
/// Takes warehouseId, productId, warehouseName, and optional stageId as extra parameter
/// Path: /product-detail/:warehouseId/:productId/:operationType
/// Query params: name (warehouse name), stageId (optional)
/// Shows detailed information for a specific product
/// If stageId is provided, only that stage is shown, otherwise all stages are shown
GoRoute(
path: '/product-detail',
path: '/product-detail/:warehouseId/:productId/:operationType',
name: 'product-detail',
builder: (context, state) {
final params = state.extra as Map<String, dynamic>?;
// Extract path parameters
final warehouseIdStr = state.pathParameters['warehouseId'];
final productIdStr = state.pathParameters['productId'];
final operationType = state.pathParameters['operationType'] ?? 'import';
if (params == null) {
// If no params, redirect to warehouses
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
});
return const _ErrorScreen(
message: 'Product detail parameters are required',
);
}
// Extract query parameters
final warehouseName = state.uri.queryParameters['name'];
final stageIdStr = state.uri.queryParameters['stageId'];
// Extract required parameters
final warehouseId = params['warehouseId'] as int?;
final productId = params['productId'] as int?;
final warehouseName = params['warehouseName'] as String?;
// Extract optional stageId
final stageId = params['stageId'] as int?;
// Parse and validate parameters
final warehouseId = int.tryParse(warehouseIdStr ?? '');
final productId = int.tryParse(productIdStr ?? '');
final stageId = stageIdStr != null ? int.tryParse(stageIdStr) : null;
// Validate parameters
if (warehouseId == null || productId == null || warehouseName == null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.go('/warehouses');
@@ -166,6 +152,7 @@ class AppRouter {
productId: productId,
warehouseName: warehouseName,
stageId: stageId,
operationType: operationType,
);
},
),
@@ -360,18 +347,11 @@ extension AppRouterExtension on BuildContext {
///
/// [warehouse] - Selected warehouse entity
/// [operationType] - Either 'import' or 'export'
void goToProducts({
void pushToProducts({
required WarehouseEntity warehouse,
required String operationType,
}) {
go(
'/products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': operationType,
},
);
push('/products/${warehouse.id}/$operationType?name=${Uri.encodeQueryComponent(warehouse.name)}');
}
/// Navigate to product detail page
@@ -379,22 +359,23 @@ extension AppRouterExtension on BuildContext {
/// [warehouseId] - ID of the warehouse
/// [productId] - ID of the product to view
/// [warehouseName] - Name of the warehouse (for display)
/// [operationType] - Either 'import' or 'export'
/// [stageId] - Optional ID of specific stage to show (if null, show all stages)
void goToProductDetail({
required int warehouseId,
required int productId,
required String warehouseName,
required String operationType,
int? stageId,
}) {
push(
'/product-detail',
extra: {
'warehouseId': warehouseId,
'productId': productId,
'warehouseName': warehouseName,
if (stageId != null) 'stageId': stageId,
},
);
final queryParams = <String, String>{
'name': warehouseName,
if (stageId != null) 'stageId': stageId.toString(),
};
final queryString = queryParams.entries
.map((e) => '${e.key}=${Uri.encodeQueryComponent(e.value)}')
.join('&');
push('/product-detail/$warehouseId/$productId/$operationType?$queryString');
}
/// Pop current route
@@ -421,11 +402,13 @@ extension AppRouterNamedExtension on BuildContext {
}) {
goNamed(
'products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
pathParameters: {
'warehouseId': warehouse.id.toString(),
'operationType': operationType,
},
queryParameters: {
'name': warehouse.name,
},
);
}
}

View File

@@ -2,6 +2,7 @@ import '../../../../core/constants/api_endpoints.dart';
import '../../../../core/errors/exceptions.dart';
import '../../../../core/network/api_client.dart';
import '../../../../core/network/api_response.dart';
import '../models/create_product_warehouse_request.dart';
import '../models/product_detail_request_model.dart';
import '../models/product_model.dart';
import '../models/product_stage_model.dart';
@@ -24,6 +25,14 @@ abstract class ProductsRemoteDataSource {
/// Returns List<ProductStageModel> with all stages for the product
/// Throws [ServerException] if the API call fails
Future<List<ProductStageModel>> getProductDetail(ProductDetailRequestModel request);
/// Create product warehouse entry (import/export operation)
///
/// [request] - Request containing all product warehouse details
///
/// Returns void on success
/// Throws [ServerException] if the API call fails
Future<void> createProductWarehouse(CreateProductWarehouseRequest request);
}
/// Implementation of ProductsRemoteDataSource using ApiClient
@@ -126,4 +135,47 @@ class ProductsRemoteDataSourceImpl implements ProductsRemoteDataSource {
throw ServerException('Failed to get product stages: ${e.toString()}');
}
}
@override
Future<void> createProductWarehouse(
CreateProductWarehouseRequest request) async {
try {
// The API expects an array of requests
final requestData = [request.toJson()];
// Make API call to create product warehouse
final response = await apiClient.post(
ApiEndpoints.createProductWarehouse,
data: requestData,
);
// Parse the API response
final apiResponse = ApiResponse.fromJson(
response.data as Map<String, dynamic>,
(json) => json, // We don't need to parse the response value
);
// Check if the API call was successful
if (!apiResponse.isSuccess) {
// Throw exception with error message from API
throw ServerException(
apiResponse.errors.isNotEmpty
? apiResponse.errors.first
: 'Failed to create product warehouse entry',
);
}
} catch (e) {
// Re-throw ServerException as-is
if (e is ServerException) {
rethrow;
}
// Re-throw NetworkException as-is
if (e is NetworkException) {
rethrow;
}
// Wrap other exceptions in ServerException
throw ServerException(
'Failed to create product warehouse entry: ${e.toString()}');
}
}
}

View File

@@ -0,0 +1,77 @@
/// Request model for creating product warehouse (import/export)
class CreateProductWarehouseRequest {
final int typeId;
final int productId;
final int stageId;
final int? orderId;
final String recordDate;
final double passedQuantityWeight;
final int passedQuantity;
final double issuedQuantityWeight;
final int issuedQuantity;
final int responsibleUserId;
final String description;
final String productName;
final String productCode;
final double stockPassedQuantityWeight;
final int stockPassedQuantity;
final int stockIssuedQuantity;
final double stockIssuedQuantityWeight;
final int receiverUserId;
final int actionTypeId;
final int wareHouseId;
final int productStageId;
final bool isConfirm;
CreateProductWarehouseRequest({
required this.typeId,
required this.productId,
required this.stageId,
this.orderId,
required this.recordDate,
required this.passedQuantityWeight,
required this.passedQuantity,
required this.issuedQuantityWeight,
required this.issuedQuantity,
required this.responsibleUserId,
this.description = '',
required this.productName,
required this.productCode,
this.stockPassedQuantityWeight = 0.0,
this.stockPassedQuantity = 0,
this.stockIssuedQuantity = 0,
this.stockIssuedQuantityWeight = 0.0,
required this.receiverUserId,
required this.actionTypeId,
required this.wareHouseId,
required this.productStageId,
this.isConfirm = true,
});
Map<String, dynamic> toJson() {
return {
'TypeId': typeId,
'ProductId': productId,
'StageId': stageId,
'OrderId': orderId,
'RecordDate': recordDate,
'PassedQuantityWeight': passedQuantityWeight,
'PassedQuantity': passedQuantity,
'IssuedQuantityWeight': issuedQuantityWeight,
'IssuedQuantity': issuedQuantity,
'ResponsibleUserId': responsibleUserId,
'Description': description,
'ProductName': productName,
'ProductCode': productCode,
'StockPassedQuantityWeight': stockPassedQuantityWeight,
'StockPassedQuantity': stockPassedQuantity,
'StockIssuedQuantity': stockIssuedQuantity,
'StockIssuedQuantityWeight': stockIssuedQuantityWeight,
'ReceiverUserId': receiverUserId,
'ActionTypeId': actionTypeId,
'WareHouseId': wareHouseId,
'ProductStageId': productStageId,
'IsConfirm': isConfirm,
};
}
}

View File

@@ -13,6 +13,9 @@ class ProductStageModel extends ProductStageEntity {
required super.passedQuantityWeight,
required super.stageName,
required super.createdDate,
super.productName,
super.productCode,
super.stageId,
});
/// Create ProductStageModel from JSON
@@ -27,6 +30,9 @@ class ProductStageModel extends ProductStageEntity {
passedQuantityWeight: (json['PassedQuantityWeight'] as num).toDouble(),
stageName: json['StageName'] as String?,
createdDate: json['CreatedDate'] as String,
productName: json['ProductName'] as String? ?? '',
productCode: json['ProductCode'] as String? ?? '',
stageId: json['StageId'] as int?,
);
}
@@ -42,6 +48,9 @@ class ProductStageModel extends ProductStageEntity {
'PassedQuantityWeight': passedQuantityWeight,
'StageName': stageName,
'CreatedDate': createdDate,
'ProductName': productName,
'ProductCode': productCode,
'StageId': stageId,
};
}
@@ -57,6 +66,9 @@ class ProductStageModel extends ProductStageEntity {
passedQuantityWeight: passedQuantityWeight,
stageName: stageName,
createdDate: createdDate,
productName: productName,
productCode: productCode,
stageId: stageId,
);
}

View File

@@ -5,6 +5,7 @@ import '../../domain/entities/product_entity.dart';
import '../../domain/entities/product_stage_entity.dart';
import '../../domain/repositories/products_repository.dart';
import '../datasources/products_remote_datasource.dart';
import '../models/create_product_warehouse_request.dart';
import '../models/product_detail_request_model.dart';
/// Implementation of ProductsRepository
@@ -65,4 +66,26 @@ class ProductsRepositoryImpl implements ProductsRepository {
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
@override
Future<Either<Failure, void>> createProductWarehouse(
CreateProductWarehouseRequest request,
) async {
try {
// Call remote data source to create product warehouse
await remoteDataSource.createProductWarehouse(request);
// Return success
return const Right(null);
} on ServerException catch (e) {
// Convert ServerException to ServerFailure
return Left(ServerFailure(e.message));
} on NetworkException catch (e) {
// Convert NetworkException to NetworkFailure
return Left(NetworkFailure(e.message));
} catch (e) {
// Handle any other exceptions
return Left(ServerFailure('Unexpected error: ${e.toString()}'));
}
}
}

View File

@@ -12,6 +12,9 @@ class ProductStageEntity extends Equatable {
final double passedQuantityWeight;
final String? stageName;
final String createdDate;
final String productName;
final String productCode;
final int? stageId;
const ProductStageEntity({
required this.productId,
@@ -23,6 +26,9 @@ class ProductStageEntity extends Equatable {
required this.passedQuantityWeight,
required this.stageName,
required this.createdDate,
this.productName = '',
this.productCode = '',
this.stageId,
});
/// Get display name for the stage
@@ -49,6 +55,9 @@ class ProductStageEntity extends Equatable {
passedQuantityWeight,
stageName,
createdDate,
productName,
productCode,
stageId,
];
@override

View File

@@ -1,5 +1,6 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../../data/models/create_product_warehouse_request.dart';
import '../entities/product_entity.dart';
import '../entities/product_stage_entity.dart';
@@ -27,4 +28,13 @@ abstract class ProductsRepository {
int warehouseId,
int productId,
);
/// Create product warehouse entry (import/export operation)
///
/// [request] - Request containing all product warehouse details
///
/// Returns Either<Failure, void>
Future<Either<Failure, void>> createProductWarehouse(
CreateProductWarehouseRequest request,
);
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/di/providers.dart';
import '../../../users/domain/entities/user_entity.dart';
import '../../data/models/create_product_warehouse_request.dart';
import '../../domain/entities/product_stage_entity.dart';
/// Product detail page
@@ -13,6 +14,7 @@ class ProductDetailPage extends ConsumerStatefulWidget {
final int productId;
final String warehouseName;
final int? stageId;
final String operationType;
const ProductDetailPage({
super.key,
@@ -20,6 +22,7 @@ class ProductDetailPage extends ConsumerStatefulWidget {
required this.productId,
required this.warehouseName,
this.stageId,
this.operationType = 'import',
});
@override
@@ -107,17 +110,23 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
final error = productDetailState.error;
final selectedIndex = productDetailState.selectedStageIndex;
// Get product name from stages if available
final productName = stages.isNotEmpty ? stages.first.productName : 'Product';
// Capitalize first letter of operation type
final operationTitle = widget.operationType == 'import' ? 'Import' : 'Export';
return Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Product Stages',
operationTitle,
style: textTheme.titleMedium,
),
Text(
widget.warehouseName,
productName,
style: textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
@@ -467,17 +476,32 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
),
]),
// Add button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () => _addNewQuantities(stageToShow),
icon: const Icon(Icons.add),
label: const Text('Add Quantities'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
// Action buttons
Row(
spacing: 12,
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _printQuantities(stageToShow),
icon: const Icon(Icons.print),
label: const Text('Print'),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
),
Expanded(
child: FilledButton.icon(
onPressed: () => _addNewQuantities(stageToShow),
icon: const Icon(Icons.save),
label: const Text('Save'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
],
),
],
),
@@ -489,7 +513,17 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
);
}
void _addNewQuantities(ProductStageEntity stage) {
void _printQuantities(ProductStageEntity stage) {
// TODO: Implement print functionality
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Print functionality coming soon'),
duration: Duration(seconds: 2),
),
);
}
Future<void> _addNewQuantities(ProductStageEntity stage) async {
// Parse the values from text fields
final passedQuantity = int.tryParse(_passedQuantityController.text) ?? 0;
final passedWeight = double.tryParse(_passedWeightController.text) ?? 0.0;
@@ -508,31 +542,115 @@ class _ProductDetailPageState extends ConsumerState<ProductDetailPage> {
return;
}
// TODO: Implement API call to add new quantities
// For now, just show a success message
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Added: Passed Q=$passedQuantity, W=$passedWeight | Issued Q=$issuedQuantity, W=$issuedWeight',
// Validate that both users are selected
if (_selectedEmployee == null || _selectedWarehouseUser == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select both Employee and Warehouse User'),
backgroundColor: Colors.orange,
),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
);
return;
}
// Show loading dialog
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
// Log the values for debugging
debugPrint('Adding new quantities for stage ${stage.productStageId}:');
debugPrint(' Warehouse User: ${_selectedWarehouseUser?.fullName ?? "Not selected"}');
debugPrint(' Warehouse User ID: ${_selectedWarehouseUser?.id}');
debugPrint(' Employee: ${_selectedEmployee?.fullName ?? "Not selected"}');
debugPrint(' Employee ID: ${_selectedEmployee?.id}');
debugPrint(' Passed Quantity: $passedQuantity');
debugPrint(' Passed Weight: $passedWeight');
debugPrint(' Issued Quantity: $issuedQuantity');
debugPrint(' Issued Weight: $issuedWeight');
try {
// Determine actionTypeId based on operation type
// 4 = Import, 5 = Export
final typeId = widget.operationType == 'import' ? 4 : 5;
final actionTypeId = widget.operationType == 'import' ? 1 : 2;
// Clear the text fields after successful add
_clearControllers();
// Create request with all required fields
final request = CreateProductWarehouseRequest(
typeId: typeId, // Import type
productId: stage.productId,
stageId: stage.stageId ?? 0,
orderId: null,
recordDate: DateTime.now().toIso8601String(),
passedQuantityWeight: passedWeight,
passedQuantity: passedQuantity,
issuedQuantityWeight: issuedWeight,
issuedQuantity: issuedQuantity,
responsibleUserId: _selectedEmployee!.id,
description: '',
productName: stage.productName,
productCode: stage.productCode,
stockPassedQuantityWeight: stage.passedQuantityWeight,
stockPassedQuantity: stage.passedQuantity,
stockIssuedQuantity: stage.issuedQuantity,
stockIssuedQuantityWeight: stage.issuedQuantityWeight,
receiverUserId: _selectedWarehouseUser!.id,
actionTypeId: actionTypeId,
wareHouseId: widget.warehouseId,
productStageId: stage.productStageId ?? 0,
isConfirm: true,
);
// Call the repository to create product warehouse entry
final repository = ref.read(productsRepositoryProvider);
final result = await repository.createProductWarehouse(request);
// Dismiss loading dialog
if (mounted) Navigator.of(context).pop();
result.fold(
(failure) {
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Failed to add quantities: ${failure.message}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
},
(_) {
// Success - show success message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Quantities added successfully!'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
// Clear the text fields after successful add
_clearControllers();
// Refresh the product detail to show updated quantities
ref.read(productDetailProvider(_providerKey).notifier).refreshProductDetail(
widget.warehouseId,
widget.productId,
);
}
},
);
} catch (e) {
// Dismiss loading dialog
if (mounted) Navigator.of(context).pop();
// Show error message
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: ${e.toString()}'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
}
}
}
Widget _buildStageHeader(ProductStageEntity stage, ThemeData theme) {

View File

@@ -214,6 +214,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
warehouseId: widget.warehouseId,
productId: productId,
warehouseName: widget.warehouseName,
operationType: widget.operationType,
stageId: stageId,
);
}
@@ -466,6 +467,7 @@ class _ProductsPageState extends ConsumerState<ProductsPage>
warehouseId: widget.warehouseId,
productId: product.id,
warehouseName: widget.warehouseName,
operationType: widget.operationType,
);
},
);

View File

@@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../../core/di/providers.dart';
import '../../../../core/router/app_router.dart';
import '../widgets/warehouse_card.dart';
/// Warehouse selection page
@@ -176,13 +176,9 @@ class _WarehouseSelectionPageState
ref.read(warehouseProvider.notifier).selectWarehouse(warehouse);
// Navigate to products page with warehouse data
context.push(
'/products',
extra: {
'warehouse': warehouse,
'warehouseName': warehouse.name,
'operationType': 'import', // Default to import
},
context.pushToProducts(
warehouse: warehouse,
operationType: 'import', // Default to import
);
},
);