feat: Implement caching for selectable products and enhance product filtering in admin panels
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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<AdminInventoryPanel> {
|
||||
List<AdminInventoryItem> _items = [];
|
||||
List<AdminProduct> _products = [];
|
||||
List<AdminCategoryNode> _categories = [];
|
||||
List<CategorySelectOption> _categoryOptions = [];
|
||||
List<UserAdmin> _users = [];
|
||||
|
||||
@override
|
||||
@@ -60,7 +60,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
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<AdminInventoryPanel> {
|
||||
_items = results[0] as List<AdminInventoryItem>;
|
||||
_products = results[1] as List<AdminProduct>;
|
||||
_categories = results[2] as List<AdminCategoryNode>;
|
||||
_categoryOptions = _flattenCategoryOptions(_categories);
|
||||
_users = results[3] as List<UserAdmin>;
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -96,20 +95,6 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
_InventorySort.quantityDesc => 'Mängd fallande',
|
||||
};
|
||||
|
||||
List<CategorySelectOption> _flattenCategoryOptions(
|
||||
List<AdminCategoryNode> nodes, [
|
||||
List<String> parents = const [],
|
||||
]) {
|
||||
final result = <CategorySelectOption>[];
|
||||
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<AdminInventoryItem> get _filtered {
|
||||
final q = _search.trim().toLowerCase();
|
||||
if (q.isEmpty) return _items;
|
||||
@@ -122,6 +107,10 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
List<String> get _locationOptions {
|
||||
return buildLocationOptionsFromValues(_items.map((item) => item.location));
|
||||
}
|
||||
|
||||
Future<void> _addItem() async {
|
||||
final values = await _showInventoryFormDialog(initialOwnerUserId: _selectedUserId);
|
||||
if (values == null) return;
|
||||
@@ -478,6 +467,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
users: _users,
|
||||
products: _products,
|
||||
categories: _categories,
|
||||
locationOptions: _locationOptions,
|
||||
initial: initial,
|
||||
initialOwnerUserId: initialOwnerUserId,
|
||||
),
|
||||
@@ -678,11 +668,15 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
|
||||
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<UserAdmin> users;
|
||||
final List<AdminProduct> products;
|
||||
final List<AdminCategoryNode> categories;
|
||||
final List<String> 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<FormState>();
|
||||
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<ProductOption> _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<CategorySelectOption> _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<ProductScopeFilter>(
|
||||
segments: const [
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.all,
|
||||
label: Text('Alla'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
value: ProductScopeFilter.globalOnly,
|
||||
label: Text('Globala'),
|
||||
),
|
||||
ButtonSegment<ProductScopeFilter>(
|
||||
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<String>(
|
||||
initialValue: _locationDropdownValue(),
|
||||
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
|
||||
items: [
|
||||
...widget.locationOptions.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
value: option,
|
||||
child: Text(option),
|
||||
),
|
||||
),
|
||||
const DropdownMenuItem<String>(
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user