feat: add canonical name endpoint and update product renaming functionality 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:
@@ -14,6 +14,7 @@ class ProductApiPaths {
|
||||
'/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId';
|
||||
static String setStatus(int id) => '/products/$id/status';
|
||||
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 restore(int id) => '/products/$id/restore';
|
||||
static const bulkUpdate = '/products/bulk-update';
|
||||
|
||||
@@ -191,6 +191,12 @@ class AdminRepository {
|
||||
Future<void> restoreProduct(int 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?}`.
|
||||
Future<Map<String, dynamic>> createProduct(String name, {int? categoryId}) =>
|
||||
_post<Map<String, dynamic>>(
|
||||
|
||||
@@ -42,6 +42,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
final Set<int> _selectedIds = <int>{};
|
||||
final Map<int, String> _rowCategoryValue = <int, String>{};
|
||||
final Set<int> _rowCategorySaving = <int>{};
|
||||
List<({String value, String label})> _cachedCategoryOptions = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -65,6 +66,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
setState(() {
|
||||
_products = results[0] as List<AdminProduct>;
|
||||
_categories = results[1] as List<AdminCategoryNode>;
|
||||
_cachedCategoryOptions = _flattenCategories(_categories);
|
||||
_rowCategoryValue.clear();
|
||||
_rowCategorySaving.clear();
|
||||
});
|
||||
@@ -76,22 +78,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
String _sortLabel(_ProductSort sort) {
|
||||
switch (sort) {
|
||||
case _ProductSort.newest:
|
||||
return context.l10n.adminSortNewest;
|
||||
case _ProductSort.oldest:
|
||||
return context.l10n.adminSortOldest;
|
||||
case _ProductSort.nameAsc:
|
||||
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;
|
||||
}
|
||||
}
|
||||
String _sortLabel(_ProductSort sort) => switch (sort) {
|
||||
_ProductSort.newest => context.l10n.adminSortNewest,
|
||||
_ProductSort.oldest => context.l10n.adminSortOldest,
|
||||
_ProductSort.nameAsc => context.l10n.adminSortNameAsc,
|
||||
_ProductSort.nameDesc => context.l10n.adminSortNameDesc,
|
||||
_ProductSort.categoryAsc => context.l10n.adminSortCategoryAsc,
|
||||
_ProductSort.categoryDesc => context.l10n.adminSortCategoryDesc,
|
||||
};
|
||||
|
||||
List<({String value, String label})> _flattenCategories(
|
||||
List<AdminCategoryNode> nodes, [
|
||||
@@ -132,7 +126,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
_showError(e);
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isApplying = false);
|
||||
@@ -181,7 +175,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
_showError(e);
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isAiRunning = false);
|
||||
@@ -321,7 +315,70 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
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) {
|
||||
if (!mounted) return;
|
||||
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;
|
||||
setState(() => _isApplying = true);
|
||||
try {
|
||||
for (final id in _selectedIds.toList()) {
|
||||
await ref.read(adminRepositoryProvider).restoreProduct(id);
|
||||
}
|
||||
final repo = ref.read(adminRepositoryProvider);
|
||||
await Future.wait(_selectedIds.map(repo.restoreProduct));
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedIds.clear());
|
||||
await _load();
|
||||
@@ -380,7 +436,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
_showError(e);
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isApplying = false);
|
||||
@@ -399,7 +455,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
_showError(e);
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -435,22 +491,30 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
|
||||
_showError(e);
|
||||
);
|
||||
setState(() => _rowCategorySaving.remove(product.id));
|
||||
}
|
||||
}
|
||||
|
||||
String _nameForId(int id) {
|
||||
final match = _products.where((p) => p.id == id).toList();
|
||||
if (match.isEmpty) return '#$id';
|
||||
return match.first.displayName;
|
||||
for (final p in _products) {
|
||||
if (p.id == id) return p.displayName;
|
||||
}
|
||||
return '#$id';
|
||||
}
|
||||
|
||||
void _showError(Object e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
_showError(e);
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final categoryOptions = _flattenCategories(_categories);
|
||||
final categoryOptions = _cachedCategoryOptions;
|
||||
final filtered = _products.where((product) {
|
||||
if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) {
|
||||
return false;
|
||||
@@ -709,6 +773,13 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
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)
|
||||
TextButton.icon(
|
||||
onPressed: () => _restoreProduct(product),
|
||||
|
||||
@@ -677,9 +677,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
final selectedFileName = _pickedFile?.name ?? session?.fileName;
|
||||
final selectedFileSizeBytes =
|
||||
_pickedFile?.size ?? session?.fileBytes?.length;
|
||||
final resultListHeight = items == null
|
||||
? 0.0
|
||||
: (items.length * 128.0).clamp(220.0, 620.0).toDouble();
|
||||
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -755,12 +753,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
SizedBox(
|
||||
height: resultListHeight,
|
||||
child: ListView.builder(
|
||||
key: const PageStorageKey<String>('receipt-import-result-list'),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
ListView.builder(
|
||||
key: const PageStorageKey<String>('receipt-import-result-list'),
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (context, i) {
|
||||
return _ReceiptImportResultRow(
|
||||
index: i,
|
||||
item: items[i],
|
||||
@@ -782,7 +780,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
|
||||
Reference in New Issue
Block a user