From a4f65c6065a47275a3d8b822d1d5c21172b378d2 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 11 May 2026 18:42:35 +0200 Subject: [PATCH] feat: Implement caching for selectable products and enhance product filtering in admin panels --- .../features/admin/data/admin_repository.dart | 96 +++++++++- .../features/admin/domain/admin_product.dart | 3 + .../admin/domain/pending_product.dart | 6 + .../presentation/admin_aliases_panel.dart | 15 +- .../presentation/admin_database_panel.dart | 2 +- .../admin/presentation/admin_form_shared.dart | 93 ++++++++++ .../presentation/admin_inventory_panel.dart | 164 ++++++++++++++---- .../presentation/admin_pantry_panel.dart | 162 +++++++++++++---- .../admin_pending_products_panel.dart | 9 +- .../admin_private_products_panel.dart | 10 +- 10 files changed, 481 insertions(+), 79 deletions(-) create mode 100644 flutter/lib/features/admin/presentation/admin_form_shared.dart diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 5b3e2646..0d0fa2bf 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -21,9 +21,18 @@ final adminRepositoryProvider = Provider((ref) { class AdminRepository { final ApiClient _apiClient; final Ref _ref; + List? _selectableProductsCache; + DateTime? _selectableProductsCacheAt; + + static const Duration _selectableProductsCacheTtl = Duration(seconds: 45); AdminRepository(this._apiClient, this._ref); + void _invalidateSelectableProductsCache() { + _selectableProductsCache = null; + _selectableProductsCacheAt = null; + } + // ── Interna helpers ──────────────────────────────────────────────────────── Future _token() => _ref.read(authStateProvider.future); @@ -202,6 +211,50 @@ class AdminRepository { Future> listPrivateProducts() => _getList(ProductApiPaths.privateList, PendingProduct.fromJson); + Future> listSelectableProductsForAdmin({ + bool forceRefresh = false, + }) async { + final now = DateTime.now(); + final cached = _selectableProductsCache; + final cacheAt = _selectableProductsCacheAt; + if (!forceRefresh && cached != null && cacheAt != null) { + if (now.difference(cacheAt) <= _selectableProductsCacheTtl) { + return List.from(cached); + } + } + + final results = await Future.wait([ + listProducts(), + listPrivateProducts(), + ]); + final globalProducts = results[0] as List; + final privateProducts = results[1] as List; + + final merged = { + for (final product in globalProducts) product.id: product, + }; + + for (final product in privateProducts) { + merged[product.id] = AdminProduct( + id: product.id, + name: product.name, + canonicalName: product.canonicalName, + ownerId: product.ownerId, + categoryId: product.categoryId, + categoryPath: product.categoryPath, + status: 'private', + ); + } + + final list = merged.values.toList(); + list.sort( + (a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + _selectableProductsCache = List.from(list); + _selectableProductsCacheAt = now; + return list; + } + Future> listDeletedProducts() => _getList(ProductApiPaths.deleted, AdminProduct.fromJson); @@ -209,36 +262,51 @@ class AdminRepository { _getList(ProductApiPaths.pending, PendingProduct.fromJson); Future setProductStatus(int productId, String status) => - _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}); + _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}).then((_) { + _invalidateSelectableProductsCache(); + }); Future promotePrivateProduct(int productId) => _post( ProductApiPaths.promotePrivate(productId), body: null, parse: (d) => AdminProduct.fromJson(Map.from(d as Map)), - ); + ).then((value) { + _invalidateSelectableProductsCache(); + return value; + }); Future setProductCategory(int productId, {required int? categoryId}) => - _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}); + _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}).then((_) { + _invalidateSelectableProductsCache(); + }); Future removeProduct(int productId) => - _deleteVoid(ProductApiPaths.remove(productId)); + _deleteVoid(ProductApiPaths.remove(productId)).then((_) { + _invalidateSelectableProductsCache(); + }); Future restoreProduct(int productId) => - _postVoid(ProductApiPaths.restore(productId)); + _postVoid(ProductApiPaths.restore(productId)).then((_) { + _invalidateSelectableProductsCache(); + }); // ── Product canonical name updates ──────────────────────────────────────── Future updateCanonicalName(int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalName(productId), {'canonicalName': canonicalName.trim()}, - ); + ).then((_) { + _invalidateSelectableProductsCache(); + }); Future updateCanonicalNamePrivate(int productId, String canonicalName) => _patchVoid( ProductApiPaths.canonicalNamePrivate(productId), {'canonicalName': canonicalName.trim()}, - ); + ).then((_) { + _invalidateSelectableProductsCache(); + }); // ── Product merging ──────────────────────────────────────────────────────── Future mergeProductsPrivate({ @@ -248,6 +316,8 @@ class AdminRepository { _postVoid(ProductApiPaths.mergePrivate, { 'sourceProductId': sourceProductId, 'targetProductId': targetProductId, + }).then((_) { + _invalidateSelectableProductsCache(); }); /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. @@ -258,7 +328,10 @@ class AdminRepository { 'name': name.trim(), if (categoryId != null) 'categoryId': categoryId, }, - ); + ).then((value) { + _invalidateSelectableProductsCache(); + return value; + }); int _parseUpdatedCount(dynamic data) { if (data is! Map) { @@ -274,7 +347,10 @@ class AdminRepository { ProductApiPaths.bulkUpdate, body: {'ids': ids, 'categoryId': categoryId}, parse: _parseUpdatedCount, - ); + ).then((value) { + _invalidateSelectableProductsCache(); + return value; + }); Future mergeProducts({ required int sourceProductId, @@ -283,6 +359,8 @@ class AdminRepository { _postVoid(ProductApiPaths.merge, { 'sourceProductId': sourceProductId, 'targetProductId': targetProductId, + }).then((_) { + _invalidateSelectableProductsCache(); }); Future> previewMerge({ diff --git a/flutter/lib/features/admin/domain/admin_product.dart b/flutter/lib/features/admin/domain/admin_product.dart index a6d296e8..361fdbd6 100644 --- a/flutter/lib/features/admin/domain/admin_product.dart +++ b/flutter/lib/features/admin/domain/admin_product.dart @@ -3,6 +3,7 @@ class AdminProduct { final String name; final String? canonicalName; final String? normalizedName; + final int? ownerId; final int? categoryId; final String? categoryPath; final bool? isActive; @@ -14,6 +15,7 @@ class AdminProduct { required this.name, this.canonicalName, this.normalizedName, + this.ownerId, this.categoryId, this.categoryPath, this.isActive, @@ -43,6 +45,7 @@ class AdminProduct { name: (json['name'] ?? '').toString(), canonicalName: json['canonicalName']?.toString(), normalizedName: json['normalizedName']?.toString(), + ownerId: ((json['owner'] as Map?)?['id'] as num?)?.toInt(), categoryId: (json['categoryId'] as num?)?.toInt(), categoryPath: names.isEmpty ? null : names.join(' > '), isActive: json['isActive'] as bool?, diff --git a/flutter/lib/features/admin/domain/pending_product.dart b/flutter/lib/features/admin/domain/pending_product.dart index 56c24bf6..17f98d3e 100644 --- a/flutter/lib/features/admin/domain/pending_product.dart +++ b/flutter/lib/features/admin/domain/pending_product.dart @@ -3,6 +3,8 @@ class PendingProduct { final String name; final String? canonicalName; final DateTime? createdAt; + final int? ownerId; + final int? categoryId; final String? categoryPath; final String? ownerUsername; @@ -11,6 +13,8 @@ class PendingProduct { required this.name, this.canonicalName, this.createdAt, + this.ownerId, + this.categoryId, this.categoryPath, this.ownerUsername, }); @@ -45,6 +49,8 @@ class PendingProduct { createdAt: json['createdAt'] == null ? null : DateTime.tryParse(json['createdAt'].toString()), + ownerId: (owner is Map) ? (owner['id'] as num?)?.toInt() : null, + categoryId: (json['categoryId'] as num?)?.toInt(), categoryPath: parts.isEmpty ? null : parts.join(' > '), ownerUsername: owner is Map ? owner['username']?.toString() diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index f9261a7d..38ddddad 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; +import 'admin_form_shared.dart'; import '../data/admin_repository.dart'; import '../domain/admin_product.dart'; import '../domain/receipt_alias.dart'; @@ -162,11 +163,23 @@ class _AdminAliasesPanelState extends ConsumerState { alias.displayProductName.toLowerCase().contains(query); }).toList(); + final productById = { + for (final product in _products) product.id: product, + }; + Widget buildAliasCard(ReceiptAlias alias) { + final product = productById[alias.productId]; + final categoryPath = product?.categoryPath ?? 'okänd'; + return Card( child: ListTile( leading: const Icon(Icons.link_outlined), - title: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500)), + title: Row( + children: [ + Expanded(child: Text(alias.receiptName, style: const TextStyle(fontWeight: FontWeight.w500))), + buildCategoryPathChip(categoryPath), + ], + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index 973a9c34..c0925046 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -52,7 +52,7 @@ class _AdminDatabasePanelState extends ConsumerState { ), _DatabaseTabConfig( tab: _DatabaseTab.products, - title: context.l10n.profileProductsTab, + title: 'Globala produkter', panel: const AdminProductsPanel(embedded: true), ), _DatabaseTabConfig( diff --git a/flutter/lib/features/admin/presentation/admin_form_shared.dart b/flutter/lib/features/admin/presentation/admin_form_shared.dart new file mode 100644 index 00000000..3eecacae --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_form_shared.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; + +import '../../../core/ui/product_picker_field.dart'; +import '../domain/admin_product.dart'; + +enum ProductScopeFilter { all, globalOnly, privateOnly } + +List buildLocationOptionsFromValues(Iterable values) { + final set = {}; + for (final value in values) { + final trimmed = value?.trim(); + if (trimmed != null && trimmed.isNotEmpty) { + set.add(trimmed); + } + } + final list = set.toList() + ..sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase())); + return list; +} + +String? resolveLocationDropdownValue({ + required bool useManualLocation, + required String currentValue, + required List options, + required String manualLocationValue, +}) { + if (useManualLocation) return manualLocationValue; + final value = currentValue.trim(); + if (value.isEmpty) return null; + return options.contains(value) ? value : manualLocationValue; +} + +List filterSelectableAdminProducts({ + required List products, + required int? ownerUserId, + required int? categoryId, + required ProductScopeFilter scopeFilter, + required AdminProduct? selectedProduct, +}) { + final ownerFiltered = ownerUserId == null + ? products.where((p) => p.ownerId == null).toList() + : products.where((p) => p.ownerId == null || p.ownerId == ownerUserId).toList(); + + final scopeFiltered = switch (scopeFilter) { + ProductScopeFilter.all => ownerFiltered, + ProductScopeFilter.globalOnly => ownerFiltered.where((p) => p.ownerId == null).toList(), + ProductScopeFilter.privateOnly => ownerFiltered.where((p) => p.ownerId != null).toList(), + }; + + final source = categoryId == null + ? scopeFiltered + : scopeFiltered.where((p) => p.categoryId == categoryId).toList(); + + if (selectedProduct != null && !source.any((p) => p.id == selectedProduct.id)) { + source.add(selectedProduct); + } + + source.sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); + return source; +} + +List toProductOptions(List products) { + return products + .map( + (p) => ( + id: p.id, + name: p.ownerId == null ? p.displayName : '${p.displayName} (privat)', + categoryId: p.categoryId, + ), + ) + .toList(); +} + +Widget buildCategoryPathChip(String? categoryPath, {double maxWidth = 220}) { + final value = (categoryPath == null || categoryPath.trim().isEmpty) + ? 'okänd' + : categoryPath.trim(); + return Tooltip( + message: value, + child: Chip( + label: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Text( + value, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: const TextStyle(fontSize: 12), + ), + ), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + ); +} diff --git a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart index e1ad092d..38b98cc0 100644 --- a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart @@ -6,6 +6,7 @@ import '../../../core/api/api_error_mapper.dart'; import '../../../core/ui/category_then_product_picker.dart'; import '../../../core/ui/searchable_category_field.dart'; import '../../../core/ui/product_picker_field.dart'; +import 'admin_form_shared.dart'; import '../data/admin_repository.dart'; import '../domain/admin_category_node.dart'; import '../domain/admin_inventory_item.dart'; @@ -39,7 +40,6 @@ class _AdminInventoryPanelState extends ConsumerState { List _items = []; List _products = []; List _categories = []; - List _categoryOptions = []; List _users = []; @override @@ -60,7 +60,7 @@ class _AdminInventoryPanelState extends ConsumerState { userId: _selectedUserId, sort: _sortParam, ), - ref.read(adminRepositoryProvider).listGlobalProducts(), + ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(), ref.read(adminRepositoryProvider).listCategoryTree(), ref.read(adminRepositoryProvider).listUsers(), ]); @@ -69,7 +69,6 @@ class _AdminInventoryPanelState extends ConsumerState { _items = results[0] as List; _products = results[1] as List; _categories = results[2] as List; - _categoryOptions = _flattenCategoryOptions(_categories); _users = results[3] as List; }); } catch (e) { @@ -96,20 +95,6 @@ class _AdminInventoryPanelState extends ConsumerState { _InventorySort.quantityDesc => 'Mängd fallande', }; - List _flattenCategoryOptions( - List nodes, [ - List parents = const [], - ]) { - final result = []; - for (final node in nodes) { - final pathParts = [...parents, node.name]; - final path = pathParts.join(' > '); - result.add((value: node.id.toString(), label: path)); - result.addAll(_flattenCategoryOptions(node.children, pathParts)); - } - return result; - } - List get _filtered { final q = _search.trim().toLowerCase(); if (q.isEmpty) return _items; @@ -122,6 +107,10 @@ class _AdminInventoryPanelState extends ConsumerState { }).toList(); } + List get _locationOptions { + return buildLocationOptionsFromValues(_items.map((item) => item.location)); + } + Future _addItem() async { final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId); if (values == null) return; @@ -478,6 +467,7 @@ class _AdminInventoryPanelState extends ConsumerState { users: _users, products: _products, categories: _categories, + locationOptions: _locationOptions, initial: initial, initialOwnerUserId: initialOwnerUserId, ), @@ -678,11 +668,15 @@ class _AdminInventoryPanelState extends ConsumerState { itemBuilder: (context, index) { final item = filtered[index]; return ListTile( - title: Text(item.displayName), + title: Row( + children: [ + Expanded(child: Text(item.displayName)), + buildCategoryPathChip(item.categoryPath), + ], + ), subtitle: Text( '${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})' - '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}' - '${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}', + '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}', ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -757,6 +751,7 @@ class _InventoryFormDialog extends StatefulWidget { final List users; final List products; final List categories; + final List locationOptions; final AdminInventoryItem? initial; final int? initialOwnerUserId; @@ -764,6 +759,7 @@ class _InventoryFormDialog extends StatefulWidget { required this.users, required this.products, required this.categories, + required this.locationOptions, this.initial, this.initialOwnerUserId, }); @@ -773,6 +769,8 @@ class _InventoryFormDialog extends StatefulWidget { } class _InventoryFormDialogState extends State<_InventoryFormDialog> { + static const String _manualLocationValue = '__manual_location__'; + final _formKey = GlobalKey(); late final TextEditingController _quantityController; late final TextEditingController _unitController; @@ -789,6 +787,8 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { int? _categoryId; String? _categoryPath; String? _productErrorText; + bool _useManualLocation = false; + ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly; @override void initState() { @@ -810,6 +810,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { _commentController = TextEditingController(text: initial?.comment ?? ''); _categorySearchController = TextEditingController(text: _categoryPath ?? ''); _categoryOptions = _flattenCategoryOptions(widget.categories); + final initialLocation = _locationController.text.trim(); + _useManualLocation = initialLocation.isNotEmpty && + !widget.locationOptions.contains(initialLocation); } @override @@ -844,18 +847,39 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { for (final product in widget.products) { if (product.id == id) return product; } + final initial = widget.initial; + if (initial != null && initial.productId == id) { + return AdminProduct( + id: initial.productId, + name: initial.productName, + canonicalName: initial.productCanonicalName, + ownerId: initial.userId, + categoryId: initial.categoryId, + categoryPath: initial.categoryPath, + status: 'private', + ); + } return null; } List _productOptions() { - final source = _categoryId == null - ? widget.products - : widget.products.where((p) => p.categoryId == _categoryId).toList(); - final sorted = [...source] - ..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); - return sorted - .map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId)) - .toList(); + final filtered = filterSelectableAdminProducts( + products: widget.products, + ownerUserId: _ownerUserId, + categoryId: _categoryId, + scopeFilter: _productScopeFilter, + selectedProduct: _productById(_productId), + ); + return toProductOptions(filtered); + } + + String? _locationDropdownValue() { + return resolveLocationDropdownValue( + useManualLocation: _useManualLocation, + currentValue: _locationController.text, + options: widget.locationOptions, + manualLocationValue: _manualLocationValue, + ); } List _flattenCategoryOptions( @@ -914,12 +938,26 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { ), )) .toList(), - onChanged: (value) => setState(() => _ownerUserId = value), + onChanged: (value) => setState(() { + _ownerUserId = value; + if (value == null) { + _productScopeFilter = ProductScopeFilter.globalOnly; + final selected = _productById(_productId); + if (selected?.ownerId != null) { + _productId = null; + } + } + }), decoration: const InputDecoration(labelText: 'Ägare (användare)'), validator: (value) => value == null ? 'Välj användare' : null, ), const SizedBox(height: 12), ] else ...[ + Text( + 'Produkt: ${widget.initial!.displayName}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: Text( @@ -975,7 +1013,33 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { }); }, ), - const SizedBox(height: 12), + if (_ownerUserId != null) ...[ + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ProductScopeFilter.all, + label: Text('Alla'), + ), + ButtonSegment( + value: ProductScopeFilter.globalOnly, + label: Text('Globala'), + ), + ButtonSegment( + value: ProductScopeFilter.privateOnly, + label: Text('Privata'), + ), + ], + selected: {_productScopeFilter}, + onSelectionChanged: (selection) { + if (selection.isEmpty) return; + setState(() => _productScopeFilter = selection.first); + }, + ), + const SizedBox(height: 12), + ] else ...[ + const SizedBox(height: 12), + ], TextFormField( controller: _quantityController, keyboardType: const TextInputType.numberWithOptions(decimal: true), @@ -994,10 +1058,46 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { (value == null || value.trim().isEmpty) ? 'Ange enhet' : null, ), const SizedBox(height: 12), - TextFormField( - controller: _locationController, + DropdownButtonFormField( + initialValue: _locationDropdownValue(), decoration: const InputDecoration(labelText: 'Plats (valfritt)'), + items: [ + ...widget.locationOptions.map( + (option) => DropdownMenuItem( + value: option, + child: Text(option), + ), + ), + const DropdownMenuItem( + value: _manualLocationValue, + child: Text('Ange manuellt...'), + ), + ], + onChanged: (value) { + if (value == null) { + setState(() { + _useManualLocation = false; + _locationController.clear(); + }); + return; + } + if (value == _manualLocationValue) { + setState(() => _useManualLocation = true); + return; + } + setState(() { + _useManualLocation = false; + _locationController.text = value; + }); + }, ), + if (_useManualLocation) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _locationController, + decoration: const InputDecoration(labelText: 'Plats (manuell)'), + ), + ], const SizedBox(height: 12), TextFormField( controller: _brandController, diff --git a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart index 844248e9..7a5ebc33 100644 --- a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart @@ -7,6 +7,7 @@ import '../../../core/l10n/l10n.dart'; import '../../../core/ui/category_then_product_picker.dart'; import '../../../core/ui/searchable_category_field.dart'; import '../../../core/ui/product_picker_field.dart'; +import 'admin_form_shared.dart'; import '../data/admin_repository.dart'; import '../domain/admin_category_node.dart'; import '../domain/admin_pantry_item.dart'; @@ -30,7 +31,6 @@ class _AdminPantryPanelState extends ConsumerState { List _items = []; List _products = []; List _categories = []; - List _categoryOptions = []; List _users = []; @override @@ -48,7 +48,7 @@ class _AdminPantryPanelState extends ConsumerState { try { final results = await Future.wait([ ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId), - ref.read(adminRepositoryProvider).listGlobalProducts(), + ref.read(adminRepositoryProvider).listSelectableProductsForAdmin(), ref.read(adminRepositoryProvider).listCategoryTree(), ref.read(adminRepositoryProvider).listUsers(), ]); @@ -57,7 +57,6 @@ class _AdminPantryPanelState extends ConsumerState { _items = results[0] as List; _products = results[1] as List; _categories = results[2] as List; - _categoryOptions = _flattenCategoryOptions(_categories); _users = results[3] as List; }); } catch (e) { @@ -80,18 +79,8 @@ class _AdminPantryPanelState extends ConsumerState { }).toList(); } - List _flattenCategoryOptions( - List nodes, [ - List parents = const [], - ]) { - final result = []; - for (final node in nodes) { - final pathParts = [...parents, node.name]; - final path = pathParts.join(' > '); - result.add((value: node.id.toString(), label: path)); - result.addAll(_flattenCategoryOptions(node.children, pathParts)); - } - return result; + List get _locationOptions { + return buildLocationOptionsFromValues(_items.map((item) => item.location)); } Future _addItem() async { @@ -152,6 +141,7 @@ class _AdminPantryPanelState extends ConsumerState { users: _users, products: _products, categories: _categories, + locationOptions: _locationOptions, initial: initial, initialOwnerUserId: initialOwnerUserId, ), @@ -427,11 +417,15 @@ class _AdminPantryPanelState extends ConsumerState { itemBuilder: (context, index) { final item = filtered[index]; return ListTile( - title: Text(item.displayName), + title: Row( + children: [ + Expanded(child: Text(item.displayName)), + buildCategoryPathChip(item.categoryPath), + ], + ), subtitle: Text( '${item.username} (${item.userEmail})' - '${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}' - '${item.categoryPath == null || item.categoryPath!.trim().isEmpty ? '' : ' · ${item.categoryPath}'}', + '${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}', ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -489,6 +483,7 @@ class _PantryFormDialog extends StatefulWidget { final List users; final List products; final List categories; + final List locationOptions; final AdminPantryItem? initial; final int? initialOwnerUserId; @@ -496,6 +491,7 @@ class _PantryFormDialog extends StatefulWidget { required this.users, required this.products, required this.categories, + required this.locationOptions, this.initial, this.initialOwnerUserId, }); @@ -505,6 +501,8 @@ class _PantryFormDialog extends StatefulWidget { } class _PantryFormDialogState extends State<_PantryFormDialog> { + static const String _manualLocationValue = '__manual_location__'; + late final TextEditingController _locationController; late final TextEditingController _categorySearchController; late List _categoryOptions; @@ -514,6 +512,8 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { int? _categoryId; String? _categoryPath; String? _productErrorText; + bool _useManualLocation = false; + ProductScopeFilter _productScopeFilter = ProductScopeFilter.globalOnly; @override void initState() { @@ -527,6 +527,9 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { _locationController = TextEditingController(text: initial?.location ?? ''); _categorySearchController = TextEditingController(text: _categoryPath ?? ''); _categoryOptions = _flattenCategoryOptions(widget.categories); + final initialLocation = _locationController.text.trim(); + _useManualLocation = initialLocation.isNotEmpty && + !widget.locationOptions.contains(initialLocation); } @override @@ -555,18 +558,39 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { for (final product in widget.products) { if (product.id == id) return product; } + final initial = widget.initial; + if (initial != null && initial.productId == id) { + return AdminProduct( + id: initial.productId, + name: initial.productName, + canonicalName: initial.productCanonicalName, + ownerId: initial.userId, + categoryId: initial.categoryId, + categoryPath: initial.categoryPath, + status: 'private', + ); + } return null; } List _productOptions() { - final source = _categoryId == null - ? widget.products - : widget.products.where((p) => p.categoryId == _categoryId).toList(); - final sorted = [...source] - ..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase())); - return sorted - .map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId)) - .toList(); + final filtered = filterSelectableAdminProducts( + products: widget.products, + ownerUserId: _ownerUserId, + categoryId: _categoryId, + scopeFilter: _productScopeFilter, + selectedProduct: _productById(_productId), + ); + return toProductOptions(filtered); + } + + String? _locationDropdownValue() { + return resolveLocationDropdownValue( + useManualLocation: _useManualLocation, + currentValue: _locationController.text, + options: widget.locationOptions, + manualLocationValue: _manualLocationValue, + ); } List _flattenCategoryOptions( @@ -616,11 +640,25 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { ), )) .toList(), - onChanged: (value) => setState(() => _ownerUserId = value), + onChanged: (value) => setState(() { + _ownerUserId = value; + if (value == null) { + _productScopeFilter = ProductScopeFilter.globalOnly; + final selected = _productById(_productId); + if (selected?.ownerId != null) { + _productId = null; + } + } + }), decoration: const InputDecoration(labelText: 'Ägare (användare)'), ), const SizedBox(height: 12), ] else ...[ + Text( + 'Produkt: ${widget.initial!.displayName}', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), Text( 'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})', style: Theme.of(context).textTheme.bodyMedium, @@ -673,11 +711,73 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { }); }, ), - const SizedBox(height: 12), - TextFormField( - controller: _locationController, + if (_ownerUserId != null) ...[ + const SizedBox(height: 8), + SegmentedButton( + segments: const [ + ButtonSegment( + value: ProductScopeFilter.all, + label: Text('Alla'), + ), + ButtonSegment( + value: ProductScopeFilter.globalOnly, + label: Text('Globala'), + ), + ButtonSegment( + value: ProductScopeFilter.privateOnly, + label: Text('Privata'), + ), + ], + selected: {_productScopeFilter}, + onSelectionChanged: (selection) { + if (selection.isEmpty) return; + setState(() => _productScopeFilter = selection.first); + }, + ), + const SizedBox(height: 12), + ] else ...[ + const SizedBox(height: 12), + ], + DropdownButtonFormField( + initialValue: _locationDropdownValue(), decoration: const InputDecoration(labelText: 'Plats (valfritt)'), + items: [ + ...widget.locationOptions.map( + (option) => DropdownMenuItem( + value: option, + child: Text(option), + ), + ), + const DropdownMenuItem( + value: _manualLocationValue, + child: Text('Ange manuellt...'), + ), + ], + onChanged: (value) { + if (value == null) { + setState(() { + _useManualLocation = false; + _locationController.clear(); + }); + return; + } + if (value == _manualLocationValue) { + setState(() => _useManualLocation = true); + return; + } + setState(() { + _useManualLocation = false; + _locationController.text = value; + }); + }, ), + if (_useManualLocation) ...[ + const SizedBox(height: 12), + TextFormField( + controller: _locationController, + decoration: const InputDecoration(labelText: 'Plats (manuell)'), + ), + ], ], ), ), @@ -714,4 +814,4 @@ class _PantryFormDialogState extends State<_PantryFormDialog> { ], ); } -} \ No newline at end of file +} diff --git a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart index f3c183ef..a443f439 100644 --- a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; +import 'admin_form_shared.dart'; import '../data/admin_repository.dart'; import '../domain/pending_product.dart'; @@ -109,13 +110,17 @@ class _AdminPendingProductsPanelState final isProcessing = _processingId == product.id; return Card( child: ListTile( - title: Text(product.displayName), + title: Row( + children: [ + Expanded(child: Text(product.displayName)), + buildCategoryPathChip(product.categoryPath), + ], + ), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (product.displayName != product.name) Text(product.name, style: theme.textTheme.bodySmall), - Text('${context.l10n.adminCategoryPrefix}${product.categoryPath ?? '—'}'), Text('${context.l10n.adminSuggestedByPrefix}${product.ownerUsername ?? '—'}'), Text( '${context.l10n.adminDatePrefix}${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}', diff --git a/flutter/lib/features/admin/presentation/admin_private_products_panel.dart b/flutter/lib/features/admin/presentation/admin_private_products_panel.dart index fd99cd1c..48269d6e 100644 --- a/flutter/lib/features/admin/presentation/admin_private_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_private_products_panel.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; +import 'admin_form_shared.dart'; import '../data/admin_repository.dart'; import '../domain/pending_product.dart'; @@ -109,13 +110,16 @@ class _AdminPrivateProductsPanelState extends ConsumerState