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';
|
'/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,12 +753,12 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
SizedBox(
|
ListView.builder(
|
||||||
height: resultListHeight,
|
key: const PageStorageKey<String>('receipt-import-result-list'),
|
||||||
child: ListView.builder(
|
shrinkWrap: true,
|
||||||
key: const PageStorageKey<String>('receipt-import-result-list'),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
return _ReceiptImportResultRow(
|
return _ReceiptImportResultRow(
|
||||||
index: i,
|
index: i,
|
||||||
item: items[i],
|
item: items[i],
|
||||||
@@ -782,7 +780,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
matchedViaBadgeBuilder: _buildMatchedViaBadge,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user