From 7f7e4c24a8d581e2fdb3a00d211f816a711ea8ee Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 7 May 2026 07:51:47 +0200 Subject: [PATCH] feat: enhance inventory management with category and location filters --- backend/src/inventory/inventory.service.ts | 22 +- flutter/lib/core/router/app_router.dart | 5 +- .../inventory/domain/inventory_item.dart | 39 +- .../presentation/create_inventory_screen.dart | 7 +- .../presentation/inventory_screen.dart | 18 +- .../pantry/presentation/pantry_screen.dart | 335 ++++++++---------- 6 files changed, 238 insertions(+), 188 deletions(-) diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index d8f854ac..6982490e 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -14,6 +14,20 @@ type InventoryQuery = { export class InventoryService { constructor(private prisma: PrismaService) {} + private readonly productWithCategoryInclude = { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + }; + private throwInventoryItemNotFound(id: number): never { throw new NotFoundException(`Inventory item with id ${id} not found`); } @@ -59,7 +73,7 @@ export class InventoryService { return this.prisma.inventoryItem.findMany({ where, include: { - product: true, + product: this.productWithCategoryInclude, }, orderBy, }); @@ -78,7 +92,7 @@ export class InventoryService { quantity: new Prisma.Decimal(newQuantity), }, include: { - product: true, + product: this.productWithCategoryInclude, }, }); @@ -154,7 +168,7 @@ export class InventoryService { : undefined, }, include: { - product: true, + product: this.productWithCategoryInclude, }, }); } @@ -222,7 +236,7 @@ export class InventoryService { where: { id }, data: updateData, include: { - product: true, + product: this.productWithCategoryInclude, }, }); } diff --git a/flutter/lib/core/router/app_router.dart b/flutter/lib/core/router/app_router.dart index 46ac5e24..cb1eb61c 100644 --- a/flutter/lib/core/router/app_router.dart +++ b/flutter/lib/core/router/app_router.dart @@ -121,7 +121,10 @@ final appRouterProvider = Provider((ref) { // /inventory/create must be listed before /inventory/:id. GoRoute( path: '/inventory/create', - builder: (context, state) => const CreateInventoryScreen(), + builder: (context, state) { + final destination = state.uri.queryParameters['destination']; + return CreateInventoryScreen(initialDestination: destination); + }, ), GoRoute( path: '/inventory/:id', diff --git a/flutter/lib/features/inventory/domain/inventory_item.dart b/flutter/lib/features/inventory/domain/inventory_item.dart index a119c676..915d2fbf 100644 --- a/flutter/lib/features/inventory/domain/inventory_item.dart +++ b/flutter/lib/features/inventory/domain/inventory_item.dart @@ -2,6 +2,8 @@ class InventoryItem { final int id; final int productId; final String productName; + final String? productCanonicalName; + final String? categoryPath; final double quantity; final String unit; final String? location; @@ -15,6 +17,8 @@ class InventoryItem { required this.id, required this.productId, required this.productName, + this.productCanonicalName, + this.categoryPath, required this.quantity, required this.unit, this.location, @@ -25,11 +29,27 @@ class InventoryItem { this.comment, }); + String get displayName { + if (productCanonicalName != null && productCanonicalName!.trim().isNotEmpty) { + return productCanonicalName!; + } + return productName; + } + + String get l1Category { + final path = categoryPath?.trim(); + if (path == null || path.isEmpty) return 'Ovrigt'; + return path.split('>').first.trim(); + } + factory InventoryItem.fromJson(Map json) { + final product = (json['product'] as Map?) ?? const {}; return InventoryItem( id: json['id'] as int, productId: json['productId'] as int, - productName: (json['product'] as Map?)?['name'] as String? ?? '', + productName: product['name'] as String? ?? '', + productCanonicalName: product['canonicalName'] as String?, + categoryPath: _buildCategoryPath(product['categoryRef']), quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0, unit: json['unit'] as String? ?? '', location: json['location'] as String?, @@ -40,4 +60,21 @@ class InventoryItem { comment: json['comment'] as String?, ); } + + static String? _buildCategoryPath(dynamic rawCategoryRef) { + if (rawCategoryRef is! Map) return null; + + final names = []; + dynamic current = rawCategoryRef; + while (current is Map) { + final name = current['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + names.insert(0, name); + } + current = current['parent']; + } + + if (names.isEmpty) return null; + return names.join(' > '); + } } diff --git a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart index a9129bc6..fe5cca86 100644 --- a/flutter/lib/features/inventory/presentation/create_inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/create_inventory_screen.dart @@ -15,7 +15,9 @@ import '../data/inventory_providers.dart'; import '../../import/data/receipt_import_session.dart' show ImportDestination; class CreateInventoryScreen extends ConsumerStatefulWidget { - const CreateInventoryScreen({super.key}); + final String? initialDestination; + + const CreateInventoryScreen({super.key, this.initialDestination}); @override ConsumerState createState() => @@ -43,6 +45,9 @@ class _CreateInventoryScreenState @override void initState() { super.initState(); + if (widget.initialDestination == 'pantry') { + _destination = ImportDestination.pantry; + } _loadProducts(); } diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index a728f601..bf3aeff5 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -17,6 +17,7 @@ class InventoryScreen extends ConsumerWidget { (value: 'nameAsc', label: context.l10n.inventorySortNameAsc), (value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc), (value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc), + (value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'), ]; @override @@ -32,6 +33,17 @@ class InventoryScreen extends ConsumerWidget { onRetry: () => ref.invalidate(inventoryProvider), ), data: (items) { + final visibleItems = [...items]; + if (sort == 'l1CategoryAsc') { + visibleItems.sort((a, b) { + final byCategory = a.l1Category.toLowerCase().compareTo( + b.l1Category.toLowerCase(), + ); + if (byCategory != 0) return byCategory; + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + }); + } + final filterSection = Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), child: Column( @@ -83,7 +95,7 @@ class InventoryScreen extends ConsumerWidget { ), ); - if (items.isEmpty) { + if (visibleItems.isEmpty) { return Stack( children: [ ListView( @@ -109,11 +121,11 @@ class InventoryScreen extends ConsumerWidget { children: [ ListView.separated( padding: const EdgeInsets.only(bottom: 88), - itemCount: items.length + 1, + itemCount: visibleItems.length + 1, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) return filterSection; - final item = items[index - 1]; + final item = visibleItems[index - 1]; return SwipeableInventoryTile(item: item); }, ), diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index f17fa381..3090d786 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -7,7 +7,6 @@ import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.dart'; import '../../../core/l10n/l10n.dart'; import '../../../core/ui/async_state_views.dart'; -import '../../../core/ui/product_picker_field.dart'; import '../../auth/data/auth_providers.dart'; import '../../inventory/data/inventory_providers.dart'; import '../data/pantry_providers.dart'; @@ -24,8 +23,16 @@ class PantryScreen extends ConsumerStatefulWidget { } class _PantryScreenState extends ConsumerState { - int? _selectedProductId; - bool _isSubmitting = false; + static const _locationOptions = ['', 'Kyl', 'Frys', 'Skafferi']; + String _locationFilter = ''; + String _sort = 'nameAsc'; + + List<({String value, String label})> _sortOptions() => const [ + (value: 'nameAsc', label: 'Namn (A-O)'), + (value: 'nameDesc', label: 'Namn (O-A)'), + (value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'), + (value: 'locationAsc', label: 'Plats (A-O)'), + ]; @override void initState() { @@ -171,27 +178,6 @@ class _PantryScreenState extends ConsumerState { } } - Future _addItem() async { - final selectedId = _selectedProductId; - if (selectedId == null || _isSubmitting) return; - - setState(() => _isSubmitting = true); - try { - final token = await ref.read(authStateProvider.future); - await ref.read(pantryRepositoryProvider).createPantryItem(selectedId, token: token); - ref.invalidate(pantryProvider); - if (mounted) setState(() => _selectedProductId = null); - } catch (error) { - _logger.severe('Failed to add pantry item: $error'); - if (!mounted) return; - ScaffoldMessenger.of(context).showSnackBar( - buildCopyableErrorSnackBar(context, mapErrorToUserMessage(error, context)), - ); - } finally { - if (mounted) setState(() => _isSubmitting = false); - } - } - Future _removeItem(PantryItem item) async { final confirmed = await showDialog( context: context, @@ -226,13 +212,13 @@ class _PantryScreenState extends ConsumerState { } } - String _resolveCategory(PantryItem item, Map productById) { - final fromTree = productById[item.productId]?.categoryPath; - if (fromTree != null && fromTree.trim().isNotEmpty) { - return fromTree; + String _resolveL1Category(PantryItem item, Map productById) { + final path = productById[item.productId]?.categoryPath?.trim(); + if (path != null && path.isNotEmpty) { + return path.split('>').first.trim(); } if (item.category != null && item.category!.trim().isNotEmpty) { - return item.category!; + return item.category!.trim(); } return context.l10n.pantryOtherCategory; } @@ -263,161 +249,154 @@ class _PantryScreenState extends ConsumerState { final products = productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; final productById = {for (final product in products) product.id: product}; - final pantryProductIds = pantryItems.map((e) => e.productId).toSet(); - final availableProducts = products - .where((product) => !pantryProductIds.contains(product.id)) - .toList() - ..sort( - (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), - ); - final availableOptions = availableProducts - .map((p) => (id: p.id, name: p.displayName, categoryId: null as int?)) - .toList(); - final grouped = >{}; - for (final item in pantryItems) { - final category = _resolveCategory(item, productById); - grouped.putIfAbsent(category, () => []).add(item); - } - final categories = grouped.keys.toList() - ..sort((a, b) { - if (a == 'Övrigt') return 1; - if (b == 'Övrigt') return -1; - return a.toLowerCase().compareTo(b.toLowerCase()); - }); + final filteredItems = pantryItems.where((item) { + if (_locationFilter.isEmpty) return true; + return (item.location ?? '').trim() == _locationFilter; + }).toList(); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.pantryDescription, - style: Theme.of(context).textTheme.bodyMedium, - ), - IconButton( - tooltip: context.l10n.pantryGoToRecipesTooltip, - icon: const Icon(Icons.restaurant_menu), - onPressed: () => context.go('/recipes'), - ), - ], - ), - const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: ProductPickerField( - products: availableOptions, - value: _selectedProductId, - enabled: !_isSubmitting && availableProducts.isNotEmpty, - label: 'Produkt', - onChanged: (value) => setState(() => _selectedProductId = value), - ), - ), - const SizedBox(width: 8), - FilledButton( - onPressed: - (_selectedProductId == null || _isSubmitting || availableProducts.isEmpty) - ? null - : _addItem, - child: _isSubmitting - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('Lägg till'), - ), - ], - ), - if (availableProducts.isEmpty) ...[ - const SizedBox(height: 12), + filteredItems.sort((a, b) { + if (_sort == 'nameDesc') { + return b.displayName.toLowerCase().compareTo(a.displayName.toLowerCase()); + } + if (_sort == 'locationAsc') { + final byLocation = (a.location ?? '').toLowerCase().compareTo( + (b.location ?? '').toLowerCase(), + ); + if (byLocation != 0) return byLocation; + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + } + if (_sort == 'l1CategoryAsc') { + final byL1 = _resolveL1Category(a, productById).toLowerCase().compareTo( + _resolveL1Category(b, productById).toLowerCase(), + ); + if (byL1 != 0) return byL1; + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + } + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + }); + + final filterSection = Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - 'Inga produkter tillgängliga att lägga till.', - style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey), + context.l10n.inventoryFilterAndSort, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _locationOptions + .map( + (option) => ChoiceChip( + label: Text(option.isEmpty ? context.l10n.inventoryAllFilter : option), + selected: _locationFilter == option, + onSelected: (_) => setState(() => _locationFilter = option), + ), + ) + .toList(), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _sort, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.inventorySortLabel, + border: const OutlineInputBorder(), + ), + items: _sortOptions() + .map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label), + ), + ) + .toList(), + onChanged: (value) { + setState(() => _sort = value ?? 'nameAsc'); + }, ), ], - const SizedBox(height: 20), - Text( - '${pantryItems.length} ${pantryItems.length == 1 ? 'produkt' : 'produkter'} i baslagret', - style: Theme.of(context).textTheme.titleMedium, - ), - const SizedBox(height: 12), - if (pantryItems.isEmpty) - const EmptyStateView( - title: 'Baslagret är tomt', - description: 'Lägg till produkter ovan.', - ) - else - ...categories.map((category) { - final items = grouped[category]! - ..sort( - (a, b) => - a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), - ); - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - category, - style: Theme.of(context).textTheme.titleSmall, - ), - const SizedBox(height: 8), - Column( - children: items - .map( - (item) => Card( - margin: const EdgeInsets.only(bottom: 8), - child: ListTile( - title: Text(item.displayName), - subtitle: item.location == null || item.location!.trim().isEmpty - ? null - : Text('Plats: ${item.location}'), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Tooltip( - message: 'Konsumera (inte tillgängligt i baslager)', - child: IconButton( - onPressed: null, - icon: Icon(Icons.remove_circle_outline), - ), - ), - const Tooltip( - message: 'Redigera (inte tillgängligt i baslager)', - child: IconButton( - onPressed: null, - icon: Icon(Icons.edit_outlined), - ), - ), - IconButton( - tooltip: 'Lägg i inventarie', - icon: const Icon(Icons.inventory_2_outlined), - onPressed: () => _addToInventory(item), - ), - IconButton( - tooltip: 'Ta bort', - icon: const Icon( - Icons.delete_outline, - color: Colors.red, - ), - onPressed: () => _removeItem(item), - ), - ], - ), - ), - ), - ) - .toList(), - ), - ], + ), + ); + + final content = filteredItems.isEmpty + ? ListView( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), + children: [ + filterSection, + const EmptyStateView( + title: 'Baslagret är tomt', + description: 'Lägg till produkter med plusknappen.', ), - ); - }).toList(), + ], + ) + : ListView.separated( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), + itemCount: filteredItems.length + 1, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + if (index == 0) return filterSection; + final item = filteredItems[index - 1]; + final l1Category = _resolveL1Category(item, productById); + + return ListTile( + title: Text(item.displayName), + subtitle: Text( + [ + 'L1: $l1Category', + if (item.location != null && item.location!.trim().isNotEmpty) + 'Plats: ${item.location}', + ].join(' • '), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Lägg i inventarie', + icon: const Icon(Icons.inventory_2_outlined), + onPressed: () => _addToInventory(item), + ), + IconButton( + tooltip: 'Ta bort', + icon: const Icon( + Icons.delete_outline, + color: Colors.red, + ), + onPressed: () => _removeItem(item), + ), + ], + ), + ); + }, + ); + + return Stack( + children: [ + content, + Positioned( + right: 16, + bottom: 16, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FloatingActionButton.extended( + onPressed: () => context.push('/inventory/create?destination=pantry'), + icon: const Icon(Icons.add), + label: Text(context.l10n.addAction), + ), + const SizedBox(height: 8), + FloatingActionButton.extended( + onPressed: () => context.go('/recipes'), + icon: const Icon(Icons.restaurant_menu), + label: Text(context.l10n.inventoryRecipesAction), + ), + ], + ), + ), ], ); }