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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user