feat: add canonical name endpoint and update product renaming functionality in admin panel
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-09 22:58:23 +02:00
parent 3ff27701fc
commit 9f3f5c1cef
4 changed files with 115 additions and 40 deletions
+1
View File
@@ -14,6 +14,7 @@ class ProductApiPaths {
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
static String setStatus(int id) => '/products/$id/status'; static String setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id'; static String update(int id) => '/products/$id';
static String canonicalName(int id) => '/products/$id/canonical-name';
static String remove(int id) => '/products/$id'; static String remove(int id) => '/products/$id';
static String restore(int id) => '/products/$id/restore'; static String restore(int id) => '/products/$id/restore';
static const bulkUpdate = '/products/bulk-update'; static const bulkUpdate = '/products/bulk-update';
@@ -191,6 +191,12 @@ class AdminRepository {
Future<void> restoreProduct(int productId) => Future<void> restoreProduct(int productId) =>
_postVoid(ProductApiPaths.restore(productId)); _postVoid(ProductApiPaths.restore(productId));
Future<void> updateCanonicalName(int productId, String canonicalName) =>
_patchVoid(
ProductApiPaths.canonicalName(productId),
{'canonicalName': canonicalName.trim()},
);
/// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`. /// Skapar en ny aktiv produkt (kräver admin). Returnerar `{id, name, categoryId?}`.
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) => Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
_post<Map<String, dynamic>>( _post<Map<String, dynamic>>(
@@ -42,6 +42,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
final Set<int> _selectedIds = <int>{}; final Set<int> _selectedIds = <int>{};
final Map<int, String> _rowCategoryValue = <int, String>{}; final Map<int, String> _rowCategoryValue = <int, String>{};
final Set<int> _rowCategorySaving = <int>{}; final Set<int> _rowCategorySaving = <int>{};
List<({String value, String label})> _cachedCategoryOptions = [];
@override @override
void initState() { void initState() {
@@ -65,6 +66,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
setState(() { setState(() {
_products = results[0] as List<AdminProduct>; _products = results[0] as List<AdminProduct>;
_categories = results[1] as List<AdminCategoryNode>; _categories = results[1] as List<AdminCategoryNode>;
_cachedCategoryOptions = _flattenCategories(_categories);
_rowCategoryValue.clear(); _rowCategoryValue.clear();
_rowCategorySaving.clear(); _rowCategorySaving.clear();
}); });
@@ -76,22 +78,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} }
} }
String _sortLabel(_ProductSort sort) { String _sortLabel(_ProductSort sort) => switch (sort) {
switch (sort) { _ProductSort.newest => context.l10n.adminSortNewest,
case _ProductSort.newest: _ProductSort.oldest => context.l10n.adminSortOldest,
return context.l10n.adminSortNewest; _ProductSort.nameAsc => context.l10n.adminSortNameAsc,
case _ProductSort.oldest: _ProductSort.nameDesc => context.l10n.adminSortNameDesc,
return context.l10n.adminSortOldest; _ProductSort.categoryAsc => context.l10n.adminSortCategoryAsc,
case _ProductSort.nameAsc: _ProductSort.categoryDesc => context.l10n.adminSortCategoryDesc,
return context.l10n.adminSortNameAsc; };
case _ProductSort.nameDesc:
return context.l10n.adminSortNameDesc;
case _ProductSort.categoryAsc:
return context.l10n.adminSortCategoryAsc;
case _ProductSort.categoryDesc:
return context.l10n.adminSortCategoryDesc;
}
}
List<({String value, String label})> _flattenCategories( List<({String value, String label})> _flattenCategories(
List<AdminCategoryNode> nodes, [ List<AdminCategoryNode> nodes, [
@@ -132,7 +126,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
} finally { } finally {
if (mounted) setState(() => _isApplying = false); if (mounted) setState(() => _isApplying = false);
@@ -181,7 +175,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
} finally { } finally {
if (mounted) setState(() => _isAiRunning = false); if (mounted) setState(() => _isAiRunning = false);
@@ -321,7 +315,70 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
);
}
}
Future<void> _renameProduct(AdminProduct product) async {
final controller = TextEditingController(
text: product.canonicalName?.isNotEmpty == true
? product.canonicalName
: product.name,
);
final newName = await showDialog<String>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Byt namn på produkt'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Produkt-ID: ${product.id}'),
const SizedBox(height: 12),
TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Kanoniskt namn',
border: OutlineInputBorder(),
),
onSubmitted: (value) {
if (value.trim().isNotEmpty) Navigator.pop(dialogContext, value.trim());
},
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction),
),
FilledButton(
onPressed: () {
final value = controller.text.trim();
if (value.isNotEmpty) Navigator.pop(dialogContext, value);
},
child: const Text('Spara'),
),
],
),
);
controller.dispose();
if (newName == null || !mounted) return;
try {
await ref.read(adminRepositoryProvider).updateCanonicalName(product.id, newName);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Namn uppdaterat till "$newName"')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
_showError(e);
); );
} }
} }
@@ -358,7 +415,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
} }
} }
@@ -367,9 +424,8 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
if (_selectedIds.isEmpty || !_showDeletedOnly || _isApplying) return; if (_selectedIds.isEmpty || !_showDeletedOnly || _isApplying) return;
setState(() => _isApplying = true); setState(() => _isApplying = true);
try { try {
for (final id in _selectedIds.toList()) { final repo = ref.read(adminRepositoryProvider);
await ref.read(adminRepositoryProvider).restoreProduct(id); await Future.wait(_selectedIds.map(repo.restoreProduct));
}
if (!mounted) return; if (!mounted) return;
setState(() => _selectedIds.clear()); setState(() => _selectedIds.clear());
await _load(); await _load();
@@ -380,7 +436,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
} finally { } finally {
if (mounted) setState(() => _isApplying = false); if (mounted) setState(() => _isApplying = false);
@@ -399,7 +455,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
} }
} }
@@ -435,22 +491,30 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), _showError(e);
); );
setState(() => _rowCategorySaving.remove(product.id)); setState(() => _rowCategorySaving.remove(product.id));
} }
} }
String _nameForId(int id) { String _nameForId(int id) {
final match = _products.where((p) => p.id == id).toList(); for (final p in _products) {
if (match.isEmpty) return '#$id'; if (p.id == id) return p.displayName;
return match.first.displayName; }
return '#$id';
}
void _showError(Object e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
_showError(e);
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final categoryOptions = _flattenCategories(_categories); final categoryOptions = _cachedCategoryOptions;
final filtered = _products.where((product) { final filtered = _products.where((product) {
if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) { if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) {
return false; return false;
@@ -709,6 +773,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
else else
const SizedBox.shrink(), const SizedBox.shrink(),
const SizedBox(width: 8), const SizedBox(width: 8),
if (!_showDeletedOnly)
TextButton.icon(
onPressed: () => _renameProduct(product),
icon: const Icon(Icons.drive_file_rename_outline),
label: const Text('Byt namn'),
),
const SizedBox(width: 4),
if (_showDeletedOnly) if (_showDeletedOnly)
TextButton.icon( TextButton.icon(
onPressed: () => _restoreProduct(product), onPressed: () => _restoreProduct(product),
@@ -677,9 +677,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
final selectedFileName = _pickedFile?.name ?? session?.fileName; final selectedFileName = _pickedFile?.name ?? session?.fileName;
final selectedFileSizeBytes = final selectedFileSizeBytes =
_pickedFile?.size ?? session?.fileBytes?.length; _pickedFile?.size ?? session?.fileBytes?.length;
final resultListHeight = items == null
? 0.0
: (items.length * 128.0).clamp(220.0, 620.0).toDouble();
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -755,10 +753,10 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
SizedBox( ListView.builder(
height: resultListHeight,
child: ListView.builder(
key: const PageStorageKey<String>('receipt-import-result-list'), key: const PageStorageKey<String>('receipt-import-result-list'),
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: items.length, itemCount: items.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
return _ReceiptImportResultRow( return _ReceiptImportResultRow(
@@ -783,7 +781,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
); );
}, },
), ),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,