feat: add updateCategoryMine endpoint to manage product category updates
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
- Implemented a new PATCH endpoint in ProductsController to update the category of a user's product. - Added corresponding service method in ProductsService to handle business logic and validation. - Created UpdateCategoryMineDto for request validation. - Enhanced error handling for forbidden actions and not found resources. - Updated API error mapping in Flutter to handle specific forbidden messages. - Modified ProductPickerField to allow product creation directly from the picker. - Added tests for the new endpoint and service method to ensure proper functionality and error handling.
This commit is contained in:
@@ -4,6 +4,11 @@ import 'package:flutter/services.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
import 'api_exception.dart';
|
||||
|
||||
const _safeForbiddenMessages = {
|
||||
'Du kan inte omkategorisera, ta bort eller ändra produkter som är globala.',
|
||||
'Du kan bara omkategorisera dina egna produkter',
|
||||
};
|
||||
|
||||
String mapErrorToUserMessage(Object error, BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
if (error is ApiException) {
|
||||
@@ -11,7 +16,9 @@ String mapErrorToUserMessage(Object error, BuildContext context) {
|
||||
case ApiErrorType.unauthorized:
|
||||
return l10n.sessionExpiredError;
|
||||
case ApiErrorType.forbidden:
|
||||
return l10n.forbiddenError;
|
||||
return _safeForbiddenMessages.contains(error.message)
|
||||
? error.message
|
||||
: l10n.forbiddenError;
|
||||
case ApiErrorType.server:
|
||||
return l10n.serverError;
|
||||
case ApiErrorType.network:
|
||||
|
||||
@@ -13,6 +13,7 @@ class ProductApiPaths {
|
||||
static const deleted = '/products/deleted';
|
||||
static const merge = '/products/merge';
|
||||
static const mergePrivate = '/products/private/merge';
|
||||
static String updateMineCategory(int id) => '/products/mine/$id/category';
|
||||
static String mergePreview(int sourceProductId, int targetProductId) =>
|
||||
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
|
||||
static String setStatus(int id) => '/products/$id/status';
|
||||
|
||||
@@ -36,6 +36,9 @@ class ProductPickerField extends StatelessWidget {
|
||||
/// If set, the picker bottom sheet opens with this text pre-filled in the search field.
|
||||
final String? initialQuery;
|
||||
|
||||
/// Optional callback to create a new product directly from the picker.
|
||||
final Future<ProductOption?> Function(String name)? onCreate;
|
||||
|
||||
const ProductPickerField({
|
||||
super.key,
|
||||
required this.products,
|
||||
@@ -46,6 +49,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
this.label = 'Produkt',
|
||||
this.errorText,
|
||||
this.initialQuery,
|
||||
this.onCreate,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,7 +61,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
orElse: () => null,
|
||||
);
|
||||
|
||||
final interactive = enabled && !isLoading && products.isNotEmpty;
|
||||
final interactive = enabled && !isLoading && (products.isNotEmpty || onCreate != null);
|
||||
|
||||
return MouseRegion(
|
||||
cursor: interactive ? SystemMouseCursors.click : MouseCursor.defer,
|
||||
@@ -81,7 +85,9 @@ class ProductPickerField extends StatelessWidget {
|
||||
: selected == null
|
||||
? Text(
|
||||
products.isEmpty
|
||||
? 'Inga produkter tillgängliga'
|
||||
? (onCreate != null
|
||||
? 'Inga produkter tillgängliga. Tryck för att välja eller skapa.'
|
||||
: 'Inga produkter tillgängliga')
|
||||
: 'Tryck för att välja produkt',
|
||||
)
|
||||
: Text(selected.name),
|
||||
@@ -98,6 +104,7 @@ class ProductPickerField extends StatelessWidget {
|
||||
value: value,
|
||||
label: label,
|
||||
initialQuery: initialQuery,
|
||||
onCreate: onCreate,
|
||||
);
|
||||
if (!context.mounted) return;
|
||||
if (result == null) return;
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
|
||||
import '../../import/data/receipt_import_session.dart' show ImportDestination;
|
||||
import 'inventory_category_helpers.dart';
|
||||
import 'inventory_category_product_section.dart';
|
||||
import 'inventory_product_mutations.dart';
|
||||
|
||||
class CreateInventoryScreen extends ConsumerStatefulWidget {
|
||||
final String? initialDestination;
|
||||
@@ -185,18 +186,24 @@ class _CreateInventoryScreenState
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedCategoryId = selected.id;
|
||||
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||
final current = _selectedProduct();
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (!productInSelectedBranch(
|
||||
productCategoryId: currentCategoryId,
|
||||
selectedCategoryIds: selectedCategoryIds,
|
||||
)) {
|
||||
_selectedProductId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncSelectedProductCategory(String? token) async {
|
||||
await syncSelectedProductCategory(
|
||||
ref: ref,
|
||||
token: token,
|
||||
selectedProductId: _selectedProductId,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(bool isBestBefore) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
@@ -215,6 +222,21 @@ class _CreateInventoryScreenState
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
|
||||
return createProductInSelectedCategory(
|
||||
ref: ref,
|
||||
context: context,
|
||||
rawName: rawName,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedProductId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -253,6 +275,7 @@ class _CreateInventoryScreenState
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await _syncSelectedProductCategory(token);
|
||||
final body = <String, dynamic>{
|
||||
'productId': _selectedProductId,
|
||||
'quantity':
|
||||
@@ -351,6 +374,7 @@ class _CreateInventoryScreenState
|
||||
productOptions: productOptions,
|
||||
selectedProductId: _selectedProductId,
|
||||
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
|
||||
isLoadingProducts: _loadingProducts,
|
||||
productEnabled: !_saving,
|
||||
productLabel: context.l10n.inventoryProductLabel,
|
||||
|
||||
@@ -17,6 +17,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
final List<product_picker.ProductOption> productOptions;
|
||||
final int? selectedProductId;
|
||||
final ValueChanged<int?> onProductChanged;
|
||||
final Future<product_picker.ProductOption?> Function(String name)? onCreateProduct;
|
||||
final bool isLoadingProducts;
|
||||
final bool productEnabled;
|
||||
final String productLabel;
|
||||
@@ -36,6 +37,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
required this.productOptions,
|
||||
required this.selectedProductId,
|
||||
required this.onProductChanged,
|
||||
this.onCreateProduct,
|
||||
required this.isLoadingProducts,
|
||||
required this.productEnabled,
|
||||
required this.productLabel,
|
||||
@@ -78,6 +80,7 @@ class InventoryCategoryProductSection extends StatelessWidget {
|
||||
enabled: productEnabled,
|
||||
label: productLabel,
|
||||
onChanged: onProductChanged,
|
||||
onCreate: onCreateProduct,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ import '../data/inventory_providers.dart';
|
||||
import '../domain/inventory_item.dart';
|
||||
import 'inventory_category_helpers.dart';
|
||||
import 'inventory_category_product_section.dart';
|
||||
import 'inventory_product_mutations.dart';
|
||||
|
||||
class InventoryEditScreen extends ConsumerStatefulWidget {
|
||||
final int itemId;
|
||||
@@ -206,18 +207,24 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
if (selected == null || !mounted) return;
|
||||
setState(() {
|
||||
_selectedCategoryId = selected.id;
|
||||
final selectedCategoryIds = _selectedCategoryBranchIds();
|
||||
final current = _selectedProduct();
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (!productInSelectedBranch(
|
||||
productCategoryId: currentCategoryId,
|
||||
selectedCategoryIds: selectedCategoryIds,
|
||||
)) {
|
||||
_selectedProductId = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _syncSelectedProductCategory(String? token) async {
|
||||
await syncSelectedProductCategory(
|
||||
ref: ref,
|
||||
token: token,
|
||||
selectedProductId: _selectedProductId,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickDate(bool isBestBefore) async {
|
||||
final picked = await showDatePicker(
|
||||
context: context,
|
||||
@@ -236,6 +243,21 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ProductOption?> _createProductInSelectedCategory(String rawName) async {
|
||||
return createProductInSelectedCategory(
|
||||
ref: ref,
|
||||
context: context,
|
||||
rawName: rawName,
|
||||
selectedCategoryId: _selectedCategoryId,
|
||||
products: _products,
|
||||
tryParseDynamicInt: tryParseDynamicInt,
|
||||
setProducts: (updated) {
|
||||
if (!mounted) return;
|
||||
setState(() => _products = updated);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_selectedProductId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -248,6 +270,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
await _syncSelectedProductCategory(token);
|
||||
final body = <String, dynamic>{
|
||||
'productId': _selectedProductId,
|
||||
'quantity':
|
||||
@@ -343,6 +366,7 @@ class _InventoryEditScreenState extends ConsumerState<InventoryEditScreen> {
|
||||
productOptions: _productOptions(),
|
||||
selectedProductId: _selectedProductId,
|
||||
onProductChanged: (value) => setState(() => _selectedProductId = value),
|
||||
onCreateProduct: _selectedCategoryId == null ? null : _createProductInSelectedCategory,
|
||||
isLoadingProducts: _loadingProducts,
|
||||
productEnabled: !_saving,
|
||||
productLabel: context.l10n.inventoryProductLabel,
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../../../core/api/api_paths.dart';
|
||||
import '../../../core/api/api_providers.dart';
|
||||
import '../../../core/ui/product_picker_field.dart' show ProductOption;
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
|
||||
Future<void> syncSelectedProductCategory({
|
||||
required WidgetRef ref,
|
||||
required String? token,
|
||||
required int? selectedProductId,
|
||||
required int? selectedCategoryId,
|
||||
required List<Map<String, dynamic>> products,
|
||||
required int? Function(dynamic) tryParseDynamicInt,
|
||||
required void Function(List<Map<String, dynamic>>) setProducts,
|
||||
}) async {
|
||||
final productId = selectedProductId;
|
||||
final categoryId = selectedCategoryId;
|
||||
if (productId == null || categoryId == null || token == null || token.isEmpty) return;
|
||||
|
||||
final current = products.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(p) => tryParseDynamicInt(p?['id']) == productId,
|
||||
orElse: () => null,
|
||||
);
|
||||
final currentCategoryId = tryParseDynamicInt(current?['categoryId']);
|
||||
if (currentCategoryId == categoryId) return;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.patchJson(
|
||||
ProductApiPaths.updateMineCategory(productId),
|
||||
body: {'categoryId': categoryId},
|
||||
token: token,
|
||||
);
|
||||
|
||||
final updatedProducts = products
|
||||
.map(
|
||||
(p) => tryParseDynamicInt(p['id']) == productId
|
||||
? {...p, 'categoryId': categoryId}
|
||||
: p,
|
||||
)
|
||||
.toList();
|
||||
setProducts(updatedProducts);
|
||||
}
|
||||
|
||||
Future<ProductOption?> createProductInSelectedCategory({
|
||||
required WidgetRef ref,
|
||||
required BuildContext context,
|
||||
required String rawName,
|
||||
required int? selectedCategoryId,
|
||||
required List<Map<String, dynamic>> products,
|
||||
required int? Function(dynamic) tryParseDynamicInt,
|
||||
required void Function(List<Map<String, dynamic>>) setProducts,
|
||||
}) async {
|
||||
final name = rawName.trim();
|
||||
final categoryId = selectedCategoryId;
|
||||
if (name.isEmpty || categoryId == null) return null;
|
||||
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.postJson(
|
||||
ProductApiPaths.createPrivate,
|
||||
body: {
|
||||
'name': name,
|
||||
'categoryId': categoryId,
|
||||
},
|
||||
token: token,
|
||||
);
|
||||
|
||||
final created = Map<String, dynamic>.from(data as Map);
|
||||
final createdId = tryParseDynamicInt(created['id']);
|
||||
if (createdId == null) return null;
|
||||
|
||||
setProducts([...products, created]);
|
||||
|
||||
return (
|
||||
id: createdId,
|
||||
name: (created['canonicalName'] ?? created['name'] ?? '').toString(),
|
||||
categoryId: tryParseDynamicInt(created['categoryId']),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user