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

This commit is contained in:
Nils-Johan Gynther
2026-05-04 19:43:13 +02:00
parent d73ea5ef7c
commit 64b06435cf
15 changed files with 751 additions and 36 deletions
@@ -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),
),
],
),
),
],
),
);
}
}