feat: implement user-scoped receipt aliases with global fallback; enhance alias management in admin panel
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:
@@ -10,6 +10,8 @@ class ProductApiPaths {
|
||||
static const aiCategorizeBulk = '/products/ai-categorize-bulk';
|
||||
static const deleted = '/products/deleted';
|
||||
static const merge = '/products/merge';
|
||||
static String mergePreview(int sourceProductId, int targetProductId) =>
|
||||
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
|
||||
static String setStatus(int id) => '/products/$id/status';
|
||||
static String update(int id) => '/products/$id';
|
||||
static String remove(int id) => '/products/$id';
|
||||
@@ -29,6 +31,11 @@ class ReceiptImportApiPaths {
|
||||
static const refreshCategories = '/receipt-import/refresh-categories';
|
||||
}
|
||||
|
||||
class ReceiptAliasApiPaths {
|
||||
static const list = '/receipt-aliases';
|
||||
static String remove(int id) => '/receipt-aliases/$id';
|
||||
}
|
||||
|
||||
class RecipeApiPaths {
|
||||
static const list = '/recipes';
|
||||
static String detail(int id) => '/recipes/$id';
|
||||
|
||||
@@ -62,7 +62,9 @@ class AppShell extends ConsumerWidget {
|
||||
),
|
||||
];
|
||||
|
||||
List<_AppDestination> _destinations(bool isAdmin) => _baseDestinations;
|
||||
List<_AppDestination> _destinations(bool isAdmin) => isAdmin
|
||||
? [..._baseDestinations, _adminHeaderDestination]
|
||||
: _baseDestinations;
|
||||
|
||||
int? _selectedIndex(List<_AppDestination> destinations) {
|
||||
final index = destinations.indexWhere(
|
||||
|
||||
@@ -9,6 +9,7 @@ import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/ai_model_info.dart';
|
||||
import '../domain/pending_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
import '../domain/user_admin.dart';
|
||||
|
||||
final adminRepositoryProvider = Provider<AdminRepository>((ref) {
|
||||
@@ -213,6 +214,21 @@ class AdminRepository {
|
||||
'targetProductId': targetProductId,
|
||||
});
|
||||
|
||||
Future<Map<String, dynamic>> previewMerge({
|
||||
required int sourceProductId,
|
||||
required int targetProductId,
|
||||
}) async {
|
||||
final token = await _token();
|
||||
final data = await guardedApiCall(
|
||||
_ref,
|
||||
() => _apiClient.getJson(
|
||||
ProductApiPaths.mergePreview(sourceProductId, targetProductId),
|
||||
token: token,
|
||||
),
|
||||
);
|
||||
return Map<String, dynamic>.from(data as Map);
|
||||
}
|
||||
|
||||
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
|
||||
List<int>? productIds,
|
||||
}) async {
|
||||
@@ -246,4 +262,23 @@ class AdminRepository {
|
||||
/// OBS: endpointen /ai/models kräver autentisering.
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_getList(AiApiPaths.models, AiModelInfo.fromJson);
|
||||
|
||||
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
|
||||
|
||||
Future<List<ReceiptAlias>> listReceiptAliases() =>
|
||||
_getList(ReceiptAliasApiPaths.list, ReceiptAlias.fromJson);
|
||||
|
||||
Future<void> upsertReceiptAlias({
|
||||
required String receiptName,
|
||||
required int productId,
|
||||
bool isGlobal = false,
|
||||
}) =>
|
||||
_postVoid(ReceiptAliasApiPaths.list, {
|
||||
'receiptName': receiptName,
|
||||
'productId': productId,
|
||||
'isGlobal': isGlobal,
|
||||
});
|
||||
|
||||
Future<void> removeReceiptAlias(int id) =>
|
||||
_deleteVoid(ReceiptAliasApiPaths.remove(id));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
class ReceiptAlias {
|
||||
final int id;
|
||||
final String receiptName;
|
||||
final int productId;
|
||||
final String? productName;
|
||||
final String? productCanonicalName;
|
||||
|
||||
const ReceiptAlias({
|
||||
required this.id,
|
||||
required this.receiptName,
|
||||
required this.productId,
|
||||
this.productName,
|
||||
this.productCanonicalName,
|
||||
});
|
||||
|
||||
String get displayProductName {
|
||||
final canonical = productCanonicalName?.trim();
|
||||
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||
final fallback = productName?.trim();
|
||||
if (fallback != null && fallback.isNotEmpty) return fallback;
|
||||
return '#$productId';
|
||||
}
|
||||
|
||||
factory ReceiptAlias.fromJson(Map<String, dynamic> json) {
|
||||
final product = json['product'];
|
||||
final productMap = product is Map<String, dynamic>
|
||||
? product
|
||||
: const <String, dynamic>{};
|
||||
|
||||
return ReceiptAlias(
|
||||
id: (json['id'] as num).toInt(),
|
||||
receiptName: (json['receiptName'] ?? '').toString(),
|
||||
productId: (json['productId'] as num?)?.toInt() ??
|
||||
(productMap['id'] as num?)?.toInt() ??
|
||||
0,
|
||||
productName: productMap['name']?.toString(),
|
||||
productCanonicalName: productMap['canonicalName']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
import '../domain/receipt_alias.dart';
|
||||
|
||||
class AdminAliasesPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AdminAliasesPanel({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AdminAliasesPanel> createState() => _AdminAliasesPanelState();
|
||||
}
|
||||
|
||||
class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
||||
bool _isLoading = true;
|
||||
bool _isSaving = false;
|
||||
String? _error;
|
||||
String _search = '';
|
||||
List<ReceiptAlias> _aliases = [];
|
||||
List<AdminProduct> _products = [];
|
||||
|
||||
final TextEditingController _aliasController = TextEditingController();
|
||||
int? _selectedProductId;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_aliasController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
ref.read(adminRepositoryProvider).listReceiptAliases(),
|
||||
ref.read(adminRepositoryProvider).listProducts(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_aliases = (results[0] as List<ReceiptAlias>)
|
||||
..sort((a, b) => a.receiptName.compareTo(b.receiptName));
|
||||
_products = (results[1] as List<AdminProduct>)
|
||||
..sort(
|
||||
(a, b) =>
|
||||
a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()),
|
||||
);
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _upsertAlias() async {
|
||||
final rawAlias = _aliasController.text.trim().toLowerCase();
|
||||
final productId = _selectedProductId;
|
||||
if (rawAlias.isEmpty || productId == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Ange alias och välj produkt.')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).upsertReceiptAlias(
|
||||
receiptName: rawAlias,
|
||||
productId: productId,
|
||||
isGlobal: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_aliasController.clear();
|
||||
setState(() => _selectedProductId = null);
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Alias sparad.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _removeAlias(ReceiptAlias alias) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Ta bort alias'),
|
||||
content: Text('Ta bort alias "${alias.receiptName}"?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Avbryt'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Ta bort'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id);
|
||||
if (!mounted) return;
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Alias borttaget.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: const Text('Försök igen')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredAliases = _aliases.where((alias) {
|
||||
final query = _search.trim().toLowerCase();
|
||||
if (query.isEmpty) return true;
|
||||
return alias.receiptName.contains(query) ||
|
||||
alias.displayProductName.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _aliasController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Kvittonamn (alias)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DropdownButtonFormField<int>(
|
||||
value: _selectedProductId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Produkt',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: _products
|
||||
.map(
|
||||
(product) => DropdownMenuItem<int>(
|
||||
value: product.id,
|
||||
child: Text(product.displayName),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: _isSaving
|
||||
? null
|
||||
: (value) => setState(() => _selectedProductId = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _isSaving ? null : _upsertAlias,
|
||||
icon: _isSaving
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.save_outlined),
|
||||
label: const Text('Spara'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sök alias',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (filteredAliases.isEmpty)
|
||||
const Text('Inga alias hittades.')
|
||||
else
|
||||
...filteredAliases.map(
|
||||
(alias) => Card(
|
||||
child: ListTile(
|
||||
title: Text(alias.receiptName),
|
||||
subtitle: Text('Produkt: ${alias.displayProductName}'),
|
||||
trailing: IconButton(
|
||||
onPressed: () => _removeAlias(alias),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
tooltip: 'Ta bort alias',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!widget.embedded) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [content],
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -199,7 +199,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
second: first,
|
||||
};
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
final selectedPair = await showDialog<({int sourceId, int targetId})>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
@@ -236,11 +236,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: Text(context.l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
onPressed: () => Navigator.pop(
|
||||
dialogContext,
|
||||
(
|
||||
sourceId: sourceId,
|
||||
targetId: optionToTarget[sourceId]!,
|
||||
),
|
||||
),
|
||||
child: Text(context.l10n.adminMergeAction),
|
||||
),
|
||||
],
|
||||
@@ -249,12 +255,61 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
},
|
||||
);
|
||||
|
||||
if (confirmed != true || !mounted) return;
|
||||
final targetId = sourceId == first ? second : first;
|
||||
if (selectedPair == null || !mounted) return;
|
||||
|
||||
try {
|
||||
final preview = await ref.read(adminRepositoryProvider).previewMerge(
|
||||
sourceProductId: selectedPair.sourceId,
|
||||
targetProductId: selectedPair.targetId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
|
||||
final source = Map<String, dynamic>.from(preview['source'] as Map);
|
||||
final target = Map<String, dynamic>.from(preview['target'] as Map);
|
||||
final outcome = Map<String, dynamic>.from(preview['outcome'] as Map);
|
||||
|
||||
final sourceName = (source['name'] as String?)?.trim();
|
||||
final targetName = (target['name'] as String?)?.trim();
|
||||
final sourceCount = (source['inventoryCount'] as num?)?.toInt() ?? 0;
|
||||
final targetCount = (target['inventoryCount'] as num?)?.toInt() ?? 0;
|
||||
final moveCount = (outcome['inventoryItemsToMove'] as num?)?.toInt() ?? 0;
|
||||
|
||||
final approved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (previewContext) => AlertDialog(
|
||||
title: const Text('Merge preview'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Källa: ${sourceName ?? selectedPair.sourceId}'),
|
||||
Text('Mål: ${targetName ?? selectedPair.targetId}'),
|
||||
const SizedBox(height: 8),
|
||||
Text('Inventarieposter i källa: $sourceCount'),
|
||||
Text('Inventarieposter i mål: $targetCount'),
|
||||
Text('Kommer flyttas: $moveCount'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Källprodukten soft-raderas efter merge.'),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(previewContext, false),
|
||||
child: Text(context.l10n.cancelAction),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(previewContext, true),
|
||||
child: Text(context.l10n.adminMergeAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (approved != true || !mounted) return;
|
||||
|
||||
await ref.read(adminRepositoryProvider).mergeProducts(
|
||||
sourceProductId: sourceId,
|
||||
targetProductId: targetId,
|
||||
sourceProductId: selectedPair.sourceId,
|
||||
targetProductId: selectedPair.targetId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedIds.clear());
|
||||
@@ -320,7 +375,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Valda produkter återställda.')),
|
||||
SnackBar(content: Text(context.l10n.adminProductsRestored)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -339,7 +394,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Produkt återställd.')),
|
||||
SnackBar(content: Text(context.l10n.adminProductRestored)),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -375,7 +430,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Kategori uppdaterad för ${product.displayName}.')),
|
||||
SnackBar(content: Text(context.l10n.adminCategoryUpdated(product.displayName))),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
@@ -525,7 +580,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text('Uppdatera valda (${_selectedIds.length})'),
|
||||
: Text(context.l10n.adminUpdateSelected(_selectedIds.length)),
|
||||
),
|
||||
if (!_showDeletedOnly)
|
||||
FilledButton.tonal(
|
||||
@@ -538,14 +593,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
)
|
||||
: Text(
|
||||
_selectedIds.isEmpty
|
||||
? 'AI-kategorisera okategoriserade'
|
||||
: 'AI-kategorisera valda (${_selectedIds.length})',
|
||||
? context.l10n.adminAiCategorizeAll
|
||||
: context.l10n.adminAiCategorizeSelected(_selectedIds.length),
|
||||
),
|
||||
),
|
||||
if (!_showDeletedOnly)
|
||||
FilledButton.tonal(
|
||||
onPressed: _selectedIds.length == 2 ? _mergeSelected : null,
|
||||
child: const Text('Slå ihop 2 valda'),
|
||||
child: Text(context.l10n.adminMerge2Selected),
|
||||
),
|
||||
if (_showDeletedOnly)
|
||||
FilledButton.tonal(
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../core/l10n/l10n.dart';
|
||||
import 'admin_ai_panel.dart';
|
||||
import 'admin_aliases_panel.dart';
|
||||
import 'admin_pending_products_panel.dart';
|
||||
import 'admin_products_panel.dart';
|
||||
import 'admin_users_panel.dart';
|
||||
|
||||
class AdminScreen extends ConsumerStatefulWidget {
|
||||
@@ -12,7 +17,52 @@ class AdminScreen extends ConsumerStatefulWidget {
|
||||
class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const AdminUsersPanel();
|
||||
return DefaultTabController(
|
||||
length: 5,
|
||||
child: Column(
|
||||
children: [
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
tabs: [
|
||||
Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)),
|
||||
const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)),
|
||||
Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)),
|
||||
const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)),
|
||||
const Tab(text: 'AI', icon: Icon(Icons.auto_awesome_outlined)),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Expanded(
|
||||
child: TabBarView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminUsersPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminProductsPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminPendingProductsPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminAliasesPanel(embedded: true),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||||
child: AdminAiPanel(embedded: true),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -463,10 +463,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
int merged = 0;
|
||||
int pantryAdded = 0;
|
||||
int pantrySkipped = 0;
|
||||
int aliasesLearned = 0;
|
||||
try {
|
||||
final token = await ref.read(authStateProvider.future);
|
||||
final invRepo = ref.read(inventoryRepositoryProvider);
|
||||
final pantryRepo = ref.read(pantryRepositoryProvider);
|
||||
final adminRepo = ref.read(adminRepositoryProvider);
|
||||
final canManageAliases = ref.read(isAdminProvider);
|
||||
|
||||
for (final i in toAdd) {
|
||||
final edit = _edits[i]!;
|
||||
@@ -514,6 +517,24 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
created++;
|
||||
}
|
||||
}
|
||||
|
||||
final normalizedReceiptName = item.rawName.trim().toLowerCase();
|
||||
final shouldLearnAlias =
|
||||
canManageAliases &&
|
||||
normalizedReceiptName.isNotEmpty &&
|
||||
item.matchedProductId != pid;
|
||||
if (shouldLearnAlias) {
|
||||
try {
|
||||
await adminRepo.upsertReceiptAlias(
|
||||
receiptName: normalizedReceiptName,
|
||||
productId: pid,
|
||||
);
|
||||
aliasesLearned++;
|
||||
} catch (e, st) {
|
||||
debugPrint('ReceiptImportTab alias upsert failed: $e');
|
||||
debugPrintStack(stackTrace: st);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
@@ -522,6 +543,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
|
||||
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
|
||||
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
|
||||
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
|
||||
];
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(parts.join(', ') + '.')),
|
||||
|
||||
Reference in New Issue
Block a user