feat: implement two-step category and product picker for enhanced selection
This commit is contained in:
@@ -4,8 +4,11 @@ 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/category_then_product_picker.dart';
|
||||
import '../../../core/ui/product_picker_field.dart';
|
||||
import '../../../core/utils/global_error_handler.dart';
|
||||
import '../../admin/data/admin_repository.dart';
|
||||
import '../../admin/domain/admin_category_node.dart';
|
||||
import '../../auth/data/auth_providers.dart';
|
||||
import '../../inventory/data/inventory_providers.dart';
|
||||
import '../../inventory/domain/inventory_item.dart';
|
||||
@@ -40,11 +43,13 @@ class _EditDialog extends StatefulWidget {
|
||||
final ParsedReceiptItem item;
|
||||
final _ItemEdit current;
|
||||
final List<ProductOption> products;
|
||||
final List<AdminCategoryNode> categoryTree;
|
||||
|
||||
const _EditDialog({
|
||||
required this.item,
|
||||
required this.current,
|
||||
required this.products,
|
||||
required this.categoryTree,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -88,6 +93,26 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
// Visa hela sökvägen om det finns, annars bara kategorinamnet
|
||||
final aiLabel = aiPath != null && aiPath.isNotEmpty ? aiPath : aiCategory;
|
||||
|
||||
// Hjälpfunktion: välj produkt via tvåstegs-picker (kategori → produkt)
|
||||
Future<void> openCategoryPicker({int? preselectedCategoryId}) async {
|
||||
final id = await CategoryThenProductPicker.show(
|
||||
context,
|
||||
categoryTree: widget.categoryTree,
|
||||
products: widget.products,
|
||||
currentProductId: _productId,
|
||||
preselectedCategoryId: preselectedCategoryId,
|
||||
);
|
||||
if (id != null && mounted) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = widget.products
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Hjälpfunktion: acceptera AI-förslaget
|
||||
void acceptAiSuggestion() {
|
||||
final sugId = item.suggestedProductId;
|
||||
@@ -97,28 +122,9 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
_productId = sugId;
|
||||
_productName = item.suggestedProductName;
|
||||
});
|
||||
} else if (aiCategory != null) {
|
||||
// Öppna pickern filtrerad på AI-föreslagen kategori (categoryId).
|
||||
// Visar bara produkter i den kategorin (eller rawName-sökning om kategorin är tom).
|
||||
final catId = item.categorySuggestionId;
|
||||
ProductPickerField.showSheet(
|
||||
context,
|
||||
products: widget.products,
|
||||
value: _productId,
|
||||
label: 'Produkt',
|
||||
initialQuery: item.rawName,
|
||||
categoryFilter: catId != null ? {catId} : null,
|
||||
).then((id) {
|
||||
if (id != null && mounted) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = widget.products
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Öppna kategori → produkt med AI-föreslagen kategori förvald
|
||||
openCategoryPicker(preselectedCategoryId: item.categorySuggestionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,14 +147,14 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: item.suggestedProductId != null
|
||||
? 'Välj "${item.suggestedProductName}" automatiskt'
|
||||
: 'Sök produkter i kategorin "$aiCategory"',
|
||||
: 'Bläddra produkter i kategorin "$aiCategory"',
|
||||
onPressed: acceptAiSuggestion,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
// Destination
|
||||
// Destination
|
||||
SegmentedButton<_Destination>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
@@ -167,21 +173,40 @@ class _EditDialogState extends State<_EditDialog> {
|
||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ProductPickerField(
|
||||
products: widget.products,
|
||||
value: _productId,
|
||||
label: 'Produkt',
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = id == null
|
||||
? null
|
||||
: widget.products
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
});
|
||||
},
|
||||
// Produktval: sök direkt eller välj via kategoriträd
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ProductPickerField(
|
||||
products: widget.products,
|
||||
value: _productId,
|
||||
label: 'Produkt',
|
||||
onChanged: (id) {
|
||||
setState(() {
|
||||
_productId = id;
|
||||
_productName = id == null
|
||||
? null
|
||||
: widget.products
|
||||
.cast<ProductOption?>()
|
||||
.firstWhere((p) => p?.id == id, orElse: () => null)
|
||||
?.name;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Tooltip(
|
||||
message: 'Välj via kategori',
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size(44, 56),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: () => openCategoryPicker(),
|
||||
child: const Icon(Icons.account_tree_outlined, size: 20),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_destination == _Destination.inventory) ...[
|
||||
@@ -255,6 +280,9 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
List<ProductOption> _products = [];
|
||||
bool _loadingProducts = false;
|
||||
|
||||
// Kategoriträdet för tvåstegs-picker
|
||||
List<AdminCategoryNode> _categoryTree = [];
|
||||
|
||||
// Befintligt inventarie: productId → InventoryItem (för sammanslagning)
|
||||
Map<int, InventoryItem> _inventoryByProduct = {};
|
||||
|
||||
@@ -272,7 +300,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = await api.getJson(ProductApiPaths.list, token: token);
|
||||
final adminRepo = ref.read(adminRepositoryProvider);
|
||||
final results = await Future.wait([
|
||||
api.getJson(ProductApiPaths.list, token: token),
|
||||
adminRepo.listCategoryTree(),
|
||||
]);
|
||||
final data = results[0];
|
||||
final list = data is List ? data : ((data as Map<String, dynamic>?)?['items'] as List? ?? []);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -280,6 +313,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
.cast<Map<String, dynamic>>()
|
||||
.map((e) => (id: e['id'] as int, name: e['name'] as String, categoryId: (e['categoryId'] as num?)?.toInt()))
|
||||
.toList();
|
||||
_categoryTree = results[1] as List<AdminCategoryNode>;
|
||||
_loadingProducts = false;
|
||||
});
|
||||
}
|
||||
@@ -379,7 +413,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
|
||||
final result = await showDialog<_ItemEdit>(
|
||||
context: context,
|
||||
builder: (_) => _EditDialog(item: item, current: current, products: _products),
|
||||
builder: (_) => _EditDialog(item: item, current: current, products: _products, categoryTree: _categoryTree),
|
||||
);
|
||||
if (result != null && mounted) {
|
||||
setState(() {
|
||||
|
||||
Reference in New Issue
Block a user