From 6abe69e12db4b55fc1319f7e15a1f9d7ab0ee010 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 25 Apr 2026 08:46:54 +0200 Subject: [PATCH] feat: enhance admin product management with AI categorization, product status updates, and email editing for users --- flutter/lib/core/api/api_paths.dart | 7 + .../features/admin/data/admin_repository.dart | 96 ++- .../domain/admin_ai_categorize_result.dart | 29 + .../features/admin/domain/admin_product.dart | 11 + .../presentation/admin_products_panel.dart | 647 ++++++++++++++++-- .../admin/presentation/admin_users_panel.dart | 56 ++ 6 files changed, 781 insertions(+), 65 deletions(-) create mode 100644 flutter/lib/features/admin/domain/admin_ai_categorize_result.dart diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 6ebc1573..c900cb73 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -5,7 +5,13 @@ class AuthApiPaths { class ProductApiPaths { static const list = '/products'; static const pending = '/products/pending'; + static const aiCategorizeBulk = '/products/ai-categorize-bulk'; + static const deleted = '/products/deleted'; + static const merge = '/products/merge'; static String setStatus(int id) => '/products/$id/status'; + static String update(int id) => '/products/$id'; + static String remove(int id) => '/products/$id'; + static String restore(int id) => '/products/$id/restore'; static const bulkUpdate = '/products/bulk-update'; } @@ -44,6 +50,7 @@ class UserApiPaths { static const list = '/users'; static String setRole(int id) => '/users/$id/role'; static String setPremium(int id) => '/users/$id/premium'; + static String updateEmail(int id) => '/users/$id/email'; static String delete(int id) => '/users/$id'; static String resetPassword(int id) => '/users/$id/reset-password'; } diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index 5dcd95de..020a9d76 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -3,6 +3,7 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; +import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; @@ -48,6 +49,18 @@ class AdminRepository { return UserAdmin.fromJson(data); } + Future updateEmail(int userId, String email) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.patchJson( + UserApiPaths.updateEmail(userId), + body: {'email': email}, + token: token, + ), + ); + } + Future createUser({ required String username, required String email, @@ -119,9 +132,21 @@ class AdminRepository { } Future> listProducts() async { + final token = await _token(); final data = await guardedApiCall( _ref, - () => _apiClient.getJson(ProductApiPaths.list), + () => _apiClient.getJson(ProductApiPaths.list, token: token), + ); + return (data as List) + .map((e) => AdminProduct.fromJson(e as Map)) + .toList(); + } + + Future> listDeletedProducts() async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(ProductApiPaths.deleted, token: token), ); return (data as List) .map((e) => AdminProduct.fromJson(e as Map)) @@ -129,9 +154,10 @@ class AdminRepository { } Future> listCategoryTree() async { + final token = await _token(); final data = await guardedApiCall( _ref, - () => _apiClient.getJson(CategoryApiPaths.tree), + () => _apiClient.getJson(CategoryApiPaths.tree, token: token), ); return (data as List) .map((e) => AdminCategoryNode.fromJson(e as Map)) @@ -149,4 +175,70 @@ class AdminRepository { ), ); } + + Future setProductCategory(int productId, {required int? categoryId}) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.patchJson( + ProductApiPaths.update(productId), + body: {'categoryId': categoryId}, + token: token, + ), + ); + } + + Future removeProduct(int productId) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.deleteJson(ProductApiPaths.remove(productId), token: token), + ); + } + + Future restoreProduct(int productId) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.postJson(ProductApiPaths.restore(productId), token: token), + ); + } + + Future mergeProducts({ + required int sourceProductId, + required int targetProductId, + }) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.postJson( + ProductApiPaths.merge, + body: { + 'sourceProductId': sourceProductId, + 'targetProductId': targetProductId, + }, + token: token, + ), + ); + } + + Future> aiCategorizeBulk({ + List? productIds, + }) async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.postJson( + ProductApiPaths.aiCategorizeBulk, + body: productIds == null || productIds.isEmpty + ? null + : {'productIds': productIds}, + token: token, + ), + ); + return (data as List) + .map((e) => AdminAiCategorizeResult.fromJson(e as Map)) + .where((e) => e.productId > 0 && e.categoryId > 0) + .toList(); + } } diff --git a/flutter/lib/features/admin/domain/admin_ai_categorize_result.dart b/flutter/lib/features/admin/domain/admin_ai_categorize_result.dart new file mode 100644 index 00000000..834f8f3e --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_ai_categorize_result.dart @@ -0,0 +1,29 @@ +class AdminAiCategorizeResult { + final int productId; + final String productName; + final int categoryId; + final String categoryPath; + final String confidence; + final bool usedFallback; + + const AdminAiCategorizeResult({ + required this.productId, + required this.productName, + required this.categoryId, + required this.categoryPath, + required this.confidence, + required this.usedFallback, + }); + + factory AdminAiCategorizeResult.fromJson(Map json) { + final suggestion = (json['suggestion'] as Map? ?? const {}); + return AdminAiCategorizeResult( + productId: (json['productId'] as num?)?.toInt() ?? 0, + productName: (json['productName'] ?? '').toString(), + categoryId: (suggestion['categoryId'] as num?)?.toInt() ?? 0, + categoryPath: (suggestion['path'] ?? '').toString(), + confidence: (suggestion['confidence'] ?? '').toString(), + usedFallback: suggestion['usedFallback'] == true, + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/domain/admin_product.dart b/flutter/lib/features/admin/domain/admin_product.dart index 46bbb222..a6d296e8 100644 --- a/flutter/lib/features/admin/domain/admin_product.dart +++ b/flutter/lib/features/admin/domain/admin_product.dart @@ -5,6 +5,9 @@ class AdminProduct { final String? normalizedName; final int? categoryId; final String? categoryPath; + final bool? isActive; + final String? status; + final DateTime? deletedAt; const AdminProduct({ required this.id, @@ -13,6 +16,9 @@ class AdminProduct { this.normalizedName, this.categoryId, this.categoryPath, + this.isActive, + this.status, + this.deletedAt, }); String get displayName => @@ -39,6 +45,11 @@ class AdminProduct { normalizedName: json['normalizedName']?.toString(), categoryId: (json['categoryId'] as num?)?.toInt(), categoryPath: names.isEmpty ? null : names.join(' > '), + isActive: json['isActive'] as bool?, + status: json['status']?.toString(), + deletedAt: json['deletedAt'] == null + ? null + : DateTime.tryParse(json['deletedAt'].toString()), ); } } \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index c25c7667..761d6088 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -3,9 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../data/admin_repository.dart'; +import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; import '../domain/admin_product.dart'; +enum _ProductSort { + newest, + oldest, + nameAsc, + nameDesc, + categoryAsc, + categoryDesc, +} + class AdminProductsPanel extends ConsumerStatefulWidget { final bool embedded; @@ -19,13 +29,18 @@ class AdminProductsPanel extends ConsumerStatefulWidget { class _AdminProductsPanelState extends ConsumerState { bool _isLoading = true; bool _isApplying = false; + bool _isAiRunning = false; String? _error; String _search = ''; + _ProductSort _sort = _ProductSort.newest; + bool _showDeletedOnly = false; bool _showUncategorizedOnly = false; String? _bulkCategoryValue; List _products = []; List _categories = []; final Set _selectedIds = {}; + final Map _rowCategoryValue = {}; + final Set _rowCategorySaving = {}; @override void initState() { @@ -40,13 +55,17 @@ class _AdminProductsPanelState extends ConsumerState { }); try { final results = await Future.wait([ - ref.read(adminRepositoryProvider).listProducts(), + _showDeletedOnly + ? ref.read(adminRepositoryProvider).listDeletedProducts() + : ref.read(adminRepositoryProvider).listProducts(), ref.read(adminRepositoryProvider).listCategoryTree(), ]); if (!mounted) return; setState(() { _products = results[0] as List; _categories = results[1] as List; + _rowCategoryValue.clear(); + _rowCategorySaving.clear(); }); } catch (e) { if (!mounted) return; @@ -56,6 +75,23 @@ class _AdminProductsPanelState extends ConsumerState { } } + String _sortLabel(_ProductSort sort) { + switch (sort) { + case _ProductSort.newest: + return 'Sortera: Nyast'; + case _ProductSort.oldest: + return 'Sortera: Äldst'; + case _ProductSort.nameAsc: + return 'Sortera: Namn A-Ö'; + case _ProductSort.nameDesc: + return 'Sortera: Namn Ö-A'; + case _ProductSort.categoryAsc: + return 'Sortera: Kategori A-Ö'; + case _ProductSort.categoryDesc: + return 'Sortera: Kategori Ö-A'; + } + } + List<({String value, String label})> _flattenCategories( List nodes, [ int depth = 0, @@ -102,21 +138,291 @@ class _AdminProductsPanelState extends ConsumerState { } } + Future _runAiCategorize() async { + if (_isAiRunning || _showDeletedOnly) return; + setState(() => _isAiRunning = true); + try { + final suggestions = await ref.read(adminRepositoryProvider).aiCategorizeBulk( + productIds: _selectedIds.isEmpty ? null : _selectedIds.toList(), + ); + if (!mounted) return; + if (suggestions.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Inga AI-förslag att visa.')), + ); + return; + } + + final selectedProductIds = await showDialog>( + context: context, + builder: (_) => _AiApplyDialog(suggestions: suggestions), + ); + if (selectedProductIds == null || selectedProductIds.isEmpty) return; + + final grouped = >{}; + for (final row in suggestions) { + if (!selectedProductIds.contains(row.productId)) continue; + grouped.putIfAbsent(row.categoryId, () => []).add(row.productId); + } + for (final entry in grouped.entries) { + await ref.read(adminRepositoryProvider).bulkSetCategory( + entry.value, + categoryId: entry.key, + ); + } + if (!mounted) return; + setState(() => _selectedIds.clear()); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('AI-förslag tillämpade på ${selectedProductIds.length} produkter.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _isAiRunning = false); + } + } + + Future _mergeSelected() async { + if (_selectedIds.length != 2 || _showDeletedOnly) return; + final ids = _selectedIds.toList(); + final first = ids[0]; + final second = ids[1]; + var sourceId = first; + final optionToTarget = { + first: second, + second: first, + }; + + final confirmed = await showDialog( + context: context, + builder: (dialogContext) { + return StatefulBuilder( + builder: (dialogContext, setDialogState) => AlertDialog( + title: const Text('Slå ihop produkter'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Välj vilken produkt som ska flyttas in i den andra:'), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + ButtonSegment( + value: first, + label: Text(_nameForId(first)), + ), + ButtonSegment( + value: second, + label: Text(_nameForId(second)), + ), + ], + selected: {sourceId}, + onSelectionChanged: (selection) { + if (selection.isEmpty) return; + setDialogState(() => sourceId = selection.first); + }, + ), + const SizedBox(height: 12), + Text( + 'Källa: ${_nameForId(sourceId)}\nMål: ${_nameForId(optionToTarget[sourceId]!)}', + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Slå ihop'), + ), + ], + ), + ); + }, + ); + + if (confirmed != true || !mounted) return; + final targetId = sourceId == first ? second : first; + try { + await ref.read(adminRepositoryProvider).mergeProducts( + sourceProductId: sourceId, + targetProductId: targetId, + ); + if (!mounted) return; + setState(() => _selectedIds.clear()); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Produkter sammanslagna.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } + } + + Future _removeProduct(AdminProduct product) async { + if (_showDeletedOnly) return; + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Ta bort produkt'), + content: Text('Ta bort ${product.displayName}? Produkten kan återställas senare.'), + 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).removeProduct(product.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Produkt borttagen.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } + } + + Future _restoreSelected() async { + if (_selectedIds.isEmpty || !_showDeletedOnly || _isApplying) return; + setState(() => _isApplying = true); + try { + for (final id in _selectedIds.toList()) { + await ref.read(adminRepositoryProvider).restoreProduct(id); + } + if (!mounted) return; + setState(() => _selectedIds.clear()); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Valda produkter återställda.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _isApplying = false); + } + } + + Future _restoreProduct(AdminProduct product) async { + try { + await ref.read(adminRepositoryProvider).restoreProduct(product.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Produkt återställd.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } + } + + String _rowCategoryFor(AdminProduct product) { + return _rowCategoryValue[product.id] ?? (product.categoryId?.toString() ?? '__remove__'); + } + + bool _rowCategoryChanged(AdminProduct product) { + final selected = _rowCategoryFor(product); + final categoryId = selected == '__remove__' ? null : int.tryParse(selected); + return categoryId != product.categoryId; + } + + Future _saveRowCategory(AdminProduct product) async { + if (_showDeletedOnly || _rowCategorySaving.contains(product.id)) return; + final selected = _rowCategoryFor(product); + final categoryId = selected == '__remove__' ? null : int.tryParse(selected); + if (categoryId == product.categoryId) return; + + setState(() => _rowCategorySaving.add(product.id)); + try { + await ref.read(adminRepositoryProvider).setProductCategory( + product.id, + categoryId: categoryId, + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Kategori uppdaterad för ${product.displayName}.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + 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; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); final categoryOptions = _flattenCategories(_categories); final filtered = _products.where((product) { - if (_showUncategorizedOnly && product.categoryId != null) { + if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) { return false; } final query = _search.trim().toLowerCase(); if (query.isEmpty) return true; - return product.name.toLowerCase().contains(query) || + return product.id.toString().contains(query) || + product.name.toLowerCase().contains(query) || (product.canonicalName ?? '').toLowerCase().contains(query) || - (product.normalizedName ?? '').toLowerCase().contains(query); + (product.normalizedName ?? '').toLowerCase().contains(query) || + (product.categoryPath ?? '').toLowerCase().contains(query); }).toList() - ..sort((a, b) => b.id.compareTo(a.id)); + ..sort((a, b) { + switch (_sort) { + case _ProductSort.newest: + return b.id.compareTo(a.id); + case _ProductSort.oldest: + return a.id.compareTo(b.id); + case _ProductSort.nameAsc: + return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); + case _ProductSort.nameDesc: + return b.displayName.toLowerCase().compareTo(a.displayName.toLowerCase()); + case _ProductSort.categoryAsc: + return (a.categoryPath ?? '').toLowerCase().compareTo((b.categoryPath ?? '').toLowerCase()); + case _ProductSort.categoryDesc: + return (b.categoryPath ?? '').toLowerCase().compareTo((a.categoryPath ?? '').toLowerCase()); + } + }); if (_isLoading) return const Center(child: CircularProgressIndicator()); if (_error != null) { @@ -148,47 +454,109 @@ class _AdminProductsPanelState extends ConsumerState { spacing: 8, runSpacing: 8, children: [ + DropdownButton<_ProductSort>( + value: _sort, + items: _ProductSort.values + .map( + (value) => DropdownMenuItem<_ProductSort>( + value: value, + child: Text(_sortLabel(value)), + ), + ) + .toList(), + onChanged: (value) { + if (value == null) return; + setState(() => _sort = value); + }, + ), + FilterChip( + label: const Text('Visa raderade'), + selected: _showDeletedOnly, + onSelected: (value) { + setState(() { + _showDeletedOnly = value; + _showUncategorizedOnly = false; + _selectedIds.clear(); + _bulkCategoryValue = null; + }); + _load(); + }, + ), FilterChip( label: const Text('Endast okategoriserade'), selected: _showUncategorizedOnly, - onSelected: (value) => - setState(() => _showUncategorizedOnly = value), - ), - SizedBox( - width: 260, - child: DropdownButtonFormField( - initialValue: _bulkCategoryValue, - decoration: const InputDecoration( - labelText: 'Bulk: sätt kategori', - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: '__remove__', - child: Text('Ta bort kategori'), - ), - ...categoryOptions.map( - (option) => DropdownMenuItem( - value: option.value, - child: Text(option.label), - ), - ), - ], - onChanged: (value) => setState(() => _bulkCategoryValue = value), - ), - ), - FilledButton( - onPressed: _selectedIds.isEmpty || _isApplying + onSelected: _showDeletedOnly ? null - : _applyBulkCategory, - child: _isApplying - ? const SizedBox( - height: 18, - width: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Text('Uppdatera valda (${_selectedIds.length})'), + : (value) => setState(() => _showUncategorizedOnly = value), ), + if (!_showDeletedOnly) + SizedBox( + width: 260, + child: DropdownButtonFormField( + initialValue: _bulkCategoryValue, + decoration: const InputDecoration( + labelText: 'Bulk: sätt kategori', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: '__remove__', + child: Text('Ta bort kategori'), + ), + ...categoryOptions.map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label), + ), + ), + ], + onChanged: (value) => setState(() => _bulkCategoryValue = value), + ), + ), + if (!_showDeletedOnly) + FilledButton( + onPressed: _selectedIds.isEmpty || _isApplying + ? null + : _applyBulkCategory, + child: _isApplying + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text('Uppdatera valda (${_selectedIds.length})'), + ), + if (!_showDeletedOnly) + FilledButton.tonal( + onPressed: _isAiRunning ? null : _runAiCategorize, + child: _isAiRunning + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text( + _selectedIds.isEmpty + ? 'AI-kategorisera okategoriserade' + : 'AI-kategorisera valda (${_selectedIds.length})', + ), + ), + if (!_showDeletedOnly) + FilledButton.tonal( + onPressed: _selectedIds.length == 2 ? _mergeSelected : null, + child: const Text('Slå ihop 2 valda'), + ), + if (_showDeletedOnly) + FilledButton.tonal( + onPressed: _selectedIds.isEmpty || _isApplying ? null : _restoreSelected, + child: _isApplying + ? const SizedBox( + height: 18, + width: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text('Återställ valda (${_selectedIds.length})'), + ), ], ), const SizedBox(height: 16), @@ -197,28 +565,110 @@ class _AdminProductsPanelState extends ConsumerState { else ...filtered.map( (product) => Card( - child: CheckboxListTile( - value: _selectedIds.contains(product.id), - onChanged: (checked) { - setState(() { - if (checked == true) { - _selectedIds.add(product.id); - } else { - _selectedIds.remove(product.id); - } - }); - }, - title: Text(product.displayName), - subtitle: Text( - [ - if (product.displayName != product.name) - 'Original: ${product.name}', - 'Kategori: ${product.categoryPath ?? 'Saknas'}', - 'ID: ${product.id}', - ].join('\n'), - ), - isThreeLine: true, - controlAffinity: ListTileControlAffinity.leading, + child: Column( + children: [ + CheckboxListTile( + value: _selectedIds.contains(product.id), + onChanged: (checked) { + setState(() { + if (checked == true) { + _selectedIds.add(product.id); + } else { + _selectedIds.remove(product.id); + } + }); + }, + title: Text(product.displayName), + subtitle: Text( + [ + if (product.displayName != product.name) + 'Original: ${product.name}', + 'Kategori: ${product.categoryPath ?? 'Saknas'}', + if (product.status != null) 'Status: ${product.status}', + if (_showDeletedOnly && product.deletedAt != null) + 'Raderad: ${product.deletedAt}', + 'ID: ${product.id}', + ].join('\n'), + ), + isThreeLine: true, + controlAffinity: ListTileControlAffinity.leading, + ), + Padding( + padding: const EdgeInsets.only(right: 12, bottom: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (!_showDeletedOnly) + Expanded( + child: Row( + children: [ + SizedBox( + width: 280, + child: DropdownButtonFormField( + key: ValueKey( + 'row-category-${product.id}-${_rowCategoryFor(product)}', + ), + initialValue: _rowCategoryFor(product), + decoration: const InputDecoration( + labelText: 'Kategori (inline)', + border: OutlineInputBorder(), + isDense: true, + ), + items: [ + const DropdownMenuItem( + value: '__remove__', + child: Text('Ingen kategori'), + ), + ...categoryOptions.map( + (option) => DropdownMenuItem( + value: option.value, + child: Text(option.label), + ), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _rowCategoryValue[product.id] = value); + }, + ), + ), + const SizedBox(width: 8), + FilledButton.tonalIcon( + onPressed: _rowCategoryChanged(product) && + !_rowCategorySaving.contains(product.id) + ? () => _saveRowCategory(product) + : null, + icon: _rowCategorySaving.contains(product.id) + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Spara'), + ), + ], + ), + ) + else + const SizedBox.shrink(), + const SizedBox(width: 8), + if (_showDeletedOnly) + TextButton.icon( + onPressed: () => _restoreProduct(product), + icon: const Icon(Icons.restore), + label: const Text('Återställ'), + ) + else + TextButton.icon( + onPressed: () => _removeProduct(product), + icon: const Icon(Icons.delete_outline), + label: const Text('Ta bort'), + ), + ], + ), + ), + ], ), ), ), @@ -234,4 +684,75 @@ class _AdminProductsPanelState extends ConsumerState { return content; } +} + +class _AiApplyDialog extends StatefulWidget { + final List suggestions; + + const _AiApplyDialog({required this.suggestions}); + + @override + State<_AiApplyDialog> createState() => _AiApplyDialogState(); +} + +class _AiApplyDialogState extends State<_AiApplyDialog> { + late final Set _selected; + + @override + void initState() { + super.initState(); + _selected = widget.suggestions.map((e) => e.productId).toSet(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('AI-förslag'), + content: SizedBox( + width: 700, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...widget.suggestions.map( + (row) => CheckboxListTile( + value: _selected.contains(row.productId), + onChanged: (checked) { + setState(() { + if (checked == true) { + _selected.add(row.productId); + } else { + _selected.remove(row.productId); + } + }); + }, + title: Text(row.productName), + subtitle: Text( + [ + 'Kategori: ${row.categoryPath}', + 'Confidence: ${row.confidence}', + if (row.usedFallback) 'Fallback använd', + 'Produkt-ID: ${row.productId}', + ].join('\n'), + ), + ), + ), + ], + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: _selected.isEmpty + ? null + : () => Navigator.pop(context, _selected), + child: Text('Tillämpa (${_selected.length})'), + ), + ], + ); + } } \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 09ecbb63..0ebb82d7 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -143,6 +143,52 @@ class _AdminUsersPanelState extends ConsumerState { } } + Future _editEmail(UserAdmin user) async { + final controller = TextEditingController(text: user.email); + try { + final newEmail = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('Ändra e-post för ${user.username}'), + content: TextField( + controller: controller, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration( + labelText: 'E-post', + border: OutlineInputBorder(), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, controller.text.trim()), + child: const Text('Spara'), + ), + ], + ), + ); + if (newEmail == null || newEmail.isEmpty || !mounted) return; + if (!RegExp(r'^[^@]+@[^@]+\.[^@]+').hasMatch(newEmail)) { + _showError('Ogiltig e-postadress.'); + return; + } + await ref.read(adminRepositoryProvider).updateEmail(user.id, newEmail); + if (!mounted) return; + _load(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('E-post uppdaterad.')), + ); + } catch (e) { + if (!mounted) return; + _showError(e); + } finally { + controller.dispose(); + } + } + Future _deleteUser(UserAdmin user) async { final confirmed = await _confirm( context, @@ -273,6 +319,7 @@ class _AdminUsersPanelState extends ConsumerState { user: _users[i], onChangeRole: () => _changeRole(_users[i]), onTogglePremium: () => _togglePremium(_users[i]), + onEditEmail: () => _editEmail(_users[i]), onResetPassword: () => _resetPassword(_users[i]), onDelete: () => _deleteUser(_users[i]), ), @@ -317,6 +364,7 @@ class _UserTile extends StatelessWidget { final UserAdmin user; final VoidCallback onChangeRole; final VoidCallback onTogglePremium; + final VoidCallback onEditEmail; final VoidCallback onResetPassword; final VoidCallback onDelete; @@ -324,6 +372,7 @@ class _UserTile extends StatelessWidget { required this.user, required this.onChangeRole, required this.onTogglePremium, + required this.onEditEmail, required this.onResetPassword, required this.onDelete, }); @@ -383,6 +432,9 @@ class _UserTile extends StatelessWidget { case 'premium': onTogglePremium(); break; + case 'email': + onEditEmail(); + break; case 'reset': onResetPassword(); break; @@ -402,6 +454,10 @@ class _UserTile extends StatelessWidget { value: 'premium', child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'), ), + const PopupMenuItem( + value: 'email', + child: Text('Ändra e-post'), + ), const PopupMenuItem( value: 'reset', child: Text('Återställ lösenord'),