feat: enhance inventory management with category and location filters
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-07 07:51:47 +02:00
parent e7251fd94c
commit 7f7e4c24a8
6 changed files with 238 additions and 188 deletions
+18 -4
View File
@@ -14,6 +14,20 @@ type InventoryQuery = {
export class InventoryService { export class InventoryService {
constructor(private prisma: PrismaService) {} constructor(private prisma: PrismaService) {}
private readonly productWithCategoryInclude = {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
};
private throwInventoryItemNotFound(id: number): never { private throwInventoryItemNotFound(id: number): never {
throw new NotFoundException(`Inventory item with id ${id} not found`); throw new NotFoundException(`Inventory item with id ${id} not found`);
} }
@@ -59,7 +73,7 @@ export class InventoryService {
return this.prisma.inventoryItem.findMany({ return this.prisma.inventoryItem.findMany({
where, where,
include: { include: {
product: true, product: this.productWithCategoryInclude,
}, },
orderBy, orderBy,
}); });
@@ -78,7 +92,7 @@ export class InventoryService {
quantity: new Prisma.Decimal(newQuantity), quantity: new Prisma.Decimal(newQuantity),
}, },
include: { include: {
product: true, product: this.productWithCategoryInclude,
}, },
}); });
@@ -154,7 +168,7 @@ export class InventoryService {
: undefined, : undefined,
}, },
include: { include: {
product: true, product: this.productWithCategoryInclude,
}, },
}); });
} }
@@ -222,7 +236,7 @@ export class InventoryService {
where: { id }, where: { id },
data: updateData, data: updateData,
include: { include: {
product: true, product: this.productWithCategoryInclude,
}, },
}); });
} }
+4 -1
View File
@@ -121,7 +121,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
// /inventory/create must be listed before /inventory/:id. // /inventory/create must be listed before /inventory/:id.
GoRoute( GoRoute(
path: '/inventory/create', path: '/inventory/create',
builder: (context, state) => const CreateInventoryScreen(), builder: (context, state) {
final destination = state.uri.queryParameters['destination'];
return CreateInventoryScreen(initialDestination: destination);
},
), ),
GoRoute( GoRoute(
path: '/inventory/:id', path: '/inventory/:id',
@@ -2,6 +2,8 @@ class InventoryItem {
final int id; final int id;
final int productId; final int productId;
final String productName; final String productName;
final String? productCanonicalName;
final String? categoryPath;
final double quantity; final double quantity;
final String unit; final String unit;
final String? location; final String? location;
@@ -15,6 +17,8 @@ class InventoryItem {
required this.id, required this.id,
required this.productId, required this.productId,
required this.productName, required this.productName,
this.productCanonicalName,
this.categoryPath,
required this.quantity, required this.quantity,
required this.unit, required this.unit,
this.location, this.location,
@@ -25,11 +29,27 @@ class InventoryItem {
this.comment, 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<String, dynamic> json) { factory InventoryItem.fromJson(Map<String, dynamic> json) {
final product = (json['product'] as Map<String, dynamic>?) ?? const {};
return InventoryItem( return InventoryItem(
id: json['id'] as int, id: json['id'] as int,
productId: json['productId'] as int, productId: json['productId'] as int,
productName: (json['product'] as Map<String, dynamic>?)?['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, quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
unit: json['unit'] as String? ?? '', unit: json['unit'] as String? ?? '',
location: json['location'] as String?, location: json['location'] as String?,
@@ -40,4 +60,21 @@ class InventoryItem {
comment: json['comment'] as String?, comment: json['comment'] as String?,
); );
} }
static String? _buildCategoryPath(dynamic rawCategoryRef) {
if (rawCategoryRef is! Map<String, dynamic>) return null;
final names = <String>[];
dynamic current = rawCategoryRef;
while (current is Map<String, dynamic>) {
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(' > ');
}
} }
@@ -15,7 +15,9 @@ import '../data/inventory_providers.dart';
import '../../import/data/receipt_import_session.dart' show ImportDestination; import '../../import/data/receipt_import_session.dart' show ImportDestination;
class CreateInventoryScreen extends ConsumerStatefulWidget { class CreateInventoryScreen extends ConsumerStatefulWidget {
const CreateInventoryScreen({super.key}); final String? initialDestination;
const CreateInventoryScreen({super.key, this.initialDestination});
@override @override
ConsumerState<CreateInventoryScreen> createState() => ConsumerState<CreateInventoryScreen> createState() =>
@@ -43,6 +45,9 @@ class _CreateInventoryScreenState
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.initialDestination == 'pantry') {
_destination = ImportDestination.pantry;
}
_loadProducts(); _loadProducts();
} }
@@ -17,6 +17,7 @@ class InventoryScreen extends ConsumerWidget {
(value: 'nameAsc', label: context.l10n.inventorySortNameAsc), (value: 'nameAsc', label: context.l10n.inventorySortNameAsc),
(value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc), (value: 'bestBeforeAsc', label: context.l10n.inventorySortBestBeforeAsc),
(value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc), (value: 'bestBeforeDesc', label: context.l10n.inventorySortBestBeforeDesc),
(value: 'l1CategoryAsc', label: 'L1-kategori (A-O)'),
]; ];
@override @override
@@ -32,6 +33,17 @@ class InventoryScreen extends ConsumerWidget {
onRetry: () => ref.invalidate(inventoryProvider), onRetry: () => ref.invalidate(inventoryProvider),
), ),
data: (items) { 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( final filterSection = Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Column( child: Column(
@@ -83,7 +95,7 @@ class InventoryScreen extends ConsumerWidget {
), ),
); );
if (items.isEmpty) { if (visibleItems.isEmpty) {
return Stack( return Stack(
children: [ children: [
ListView( ListView(
@@ -109,11 +121,11 @@ class InventoryScreen extends ConsumerWidget {
children: [ children: [
ListView.separated( ListView.separated(
padding: const EdgeInsets.only(bottom: 88), padding: const EdgeInsets.only(bottom: 88),
itemCount: items.length + 1, itemCount: visibleItems.length + 1,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index == 0) return filterSection; if (index == 0) return filterSection;
final item = items[index - 1]; final item = visibleItems[index - 1];
return SwipeableInventoryTile(item: item); return SwipeableInventoryTile(item: item);
}, },
), ),
@@ -7,7 +7,6 @@ import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart'; import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/ui/async_state_views.dart'; import '../../../core/ui/async_state_views.dart';
import '../../../core/ui/product_picker_field.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../../inventory/data/inventory_providers.dart'; import '../../inventory/data/inventory_providers.dart';
import '../data/pantry_providers.dart'; import '../data/pantry_providers.dart';
@@ -24,8 +23,16 @@ class PantryScreen extends ConsumerStatefulWidget {
} }
class _PantryScreenState extends ConsumerState<PantryScreen> { class _PantryScreenState extends ConsumerState<PantryScreen> {
int? _selectedProductId; static const _locationOptions = <String>['', 'Kyl', 'Frys', 'Skafferi'];
bool _isSubmitting = false; 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 @override
void initState() { void initState() {
@@ -171,27 +178,6 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
} }
} }
Future<void> _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<void> _removeItem(PantryItem item) async { Future<void> _removeItem(PantryItem item) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -226,13 +212,13 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
} }
} }
String _resolveCategory(PantryItem item, Map<int, PantryProduct> productById) { String _resolveL1Category(PantryItem item, Map<int, PantryProduct> productById) {
final fromTree = productById[item.productId]?.categoryPath; final path = productById[item.productId]?.categoryPath?.trim();
if (fromTree != null && fromTree.trim().isNotEmpty) { if (path != null && path.isNotEmpty) {
return fromTree; return path.split('>').first.trim();
} }
if (item.category != null && item.category!.trim().isNotEmpty) { if (item.category != null && item.category!.trim().isNotEmpty) {
return item.category!; return item.category!.trim();
} }
return context.l10n.pantryOtherCategory; return context.l10n.pantryOtherCategory;
} }
@@ -263,137 +249,112 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
final products = final products =
productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const []; productsAsync.maybeWhen(data: (d) => d, orElse: () => null) ?? const [];
final productById = {for (final product in products) product.id: product}; 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 = <String, List<PantryItem>>{}; final filteredItems = pantryItems.where((item) {
for (final item in pantryItems) { if (_locationFilter.isEmpty) return true;
final category = _resolveCategory(item, productById); return (item.location ?? '').trim() == _locationFilter;
grouped.putIfAbsent(category, () => []).add(item); }).toList();
filteredItems.sort((a, b) {
if (_sort == 'nameDesc') {
return b.displayName.toLowerCase().compareTo(a.displayName.toLowerCase());
} }
final categories = grouped.keys.toList() if (_sort == 'locationAsc') {
..sort((a, b) { final byLocation = (a.location ?? '').toLowerCase().compareTo(
if (a == 'Övrigt') return 1; (b.location ?? '').toLowerCase(),
if (b == 'Övrigt') return -1; );
return a.toLowerCase().compareTo(b.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());
}); });
return ListView( final filterSection = Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.fromLTRB(12, 12, 12, 4),
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),
Text(
'Inga produkter tillgängliga att lägga till.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
),
],
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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
category, context.l10n.inventoryFilterAndSort,
style: Theme.of(context).textTheme.titleSmall, style: const TextStyle(fontWeight: FontWeight.w600),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Column( Wrap(
children: items spacing: 8,
runSpacing: 8,
children: _locationOptions
.map( .map(
(item) => Card( (option) => ChoiceChip(
margin: const EdgeInsets.only(bottom: 8), label: Text(option.isEmpty ? context.l10n.inventoryAllFilter : option),
child: ListTile( selected: _locationFilter == option,
onSelected: (_) => setState(() => _locationFilter = option),
),
)
.toList(),
),
const SizedBox(height: 8),
DropdownButtonFormField<String>(
initialValue: _sort,
isExpanded: true,
decoration: InputDecoration(
labelText: context.l10n.inventorySortLabel,
border: const OutlineInputBorder(),
),
items: _sortOptions()
.map(
(option) => DropdownMenuItem<String>(
value: option.value,
child: Text(option.label),
),
)
.toList(),
onChanged: (value) {
setState(() => _sort = value ?? 'nameAsc');
},
),
],
),
);
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.',
),
],
)
: 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), title: Text(item.displayName),
subtitle: item.location == null || item.location!.trim().isEmpty subtitle: Text(
? null [
: Text('Plats: ${item.location}'), 'L1: $l1Category',
if (item.location != null && item.location!.trim().isNotEmpty)
'Plats: ${item.location}',
].join(''),
),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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( IconButton(
tooltip: 'Lägg i inventarie', tooltip: 'Lägg i inventarie',
icon: const Icon(Icons.inventory_2_outlined), icon: const Icon(Icons.inventory_2_outlined),
@@ -409,15 +370,33 @@ class _PantryScreenState extends ConsumerState<PantryScreen> {
), ),
], ],
), ),
);
},
);
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(
.toList(), onPressed: () => context.go('/recipes'),
icon: const Icon(Icons.restaurant_menu),
label: Text(context.l10n.inventoryRecipesAction),
), ),
], ],
), ),
); ),
}).toList(),
], ],
); );
} }