feat: enhance admin product management with AI categorization, product status updates, and email editing for users

This commit is contained in:
Nils-Johan Gynther
2026-04-25 08:46:54 +02:00
parent a02950c97a
commit 6abe69e12d
6 changed files with 781 additions and 65 deletions
+7
View File
@@ -5,7 +5,13 @@ class AuthApiPaths {
class ProductApiPaths { class ProductApiPaths {
static const list = '/products'; static const list = '/products';
static const pending = '/products/pending'; 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 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'; static const bulkUpdate = '/products/bulk-update';
} }
@@ -44,6 +50,7 @@ class UserApiPaths {
static const list = '/users'; static const list = '/users';
static String setRole(int id) => '/users/$id/role'; static String setRole(int id) => '/users/$id/role';
static String setPremium(int id) => '/users/$id/premium'; 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 delete(int id) => '/users/$id';
static String resetPassword(int id) => '/users/$id/reset-password'; static String resetPassword(int id) => '/users/$id/reset-password';
} }
@@ -3,6 +3,7 @@ import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart'; import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart'; import '../../../core/api/guarded_api_call.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../domain/admin_ai_categorize_result.dart';
import '../domain/admin_category_node.dart'; import '../domain/admin_category_node.dart';
import '../domain/admin_product.dart'; import '../domain/admin_product.dart';
import '../domain/ai_model_info.dart'; import '../domain/ai_model_info.dart';
@@ -48,6 +49,18 @@ class AdminRepository {
return UserAdmin.fromJson(data); return UserAdmin.fromJson(data);
} }
Future<void> updateEmail(int userId, String email) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.patchJson(
UserApiPaths.updateEmail(userId),
body: {'email': email},
token: token,
),
);
}
Future<UserAdmin> createUser({ Future<UserAdmin> createUser({
required String username, required String username,
required String email, required String email,
@@ -119,9 +132,21 @@ class AdminRepository {
} }
Future<List<AdminProduct>> listProducts() async { Future<List<AdminProduct>> listProducts() async {
final token = await _token();
final data = await guardedApiCall( final data = await guardedApiCall(
_ref, _ref,
() => _apiClient.getJson(ProductApiPaths.list), () => _apiClient.getJson(ProductApiPaths.list, token: token),
);
return (data as List<dynamic>)
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<AdminProduct>> listDeletedProducts() async {
final token = await _token();
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.deleted, token: token),
); );
return (data as List<dynamic>) return (data as List<dynamic>)
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>)) .map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
@@ -129,9 +154,10 @@ class AdminRepository {
} }
Future<List<AdminCategoryNode>> listCategoryTree() async { Future<List<AdminCategoryNode>> listCategoryTree() async {
final token = await _token();
final data = await guardedApiCall( final data = await guardedApiCall(
_ref, _ref,
() => _apiClient.getJson(CategoryApiPaths.tree), () => _apiClient.getJson(CategoryApiPaths.tree, token: token),
); );
return (data as List<dynamic>) return (data as List<dynamic>)
.map((e) => AdminCategoryNode.fromJson(e as Map<String, dynamic>)) .map((e) => AdminCategoryNode.fromJson(e as Map<String, dynamic>))
@@ -149,4 +175,70 @@ class AdminRepository {
), ),
); );
} }
Future<void> 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<void> removeProduct(int productId) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.deleteJson(ProductApiPaths.remove(productId), token: token),
);
}
Future<void> restoreProduct(int productId) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(ProductApiPaths.restore(productId), token: token),
);
}
Future<void> 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<List<AdminAiCategorizeResult>> aiCategorizeBulk({
List<int>? 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<dynamic>)
.map((e) => AdminAiCategorizeResult.fromJson(e as Map<String, dynamic>))
.where((e) => e.productId > 0 && e.categoryId > 0)
.toList();
}
} }
@@ -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<String, dynamic> json) {
final suggestion = (json['suggestion'] as Map<String, dynamic>? ?? 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,
);
}
}
@@ -5,6 +5,9 @@ class AdminProduct {
final String? normalizedName; final String? normalizedName;
final int? categoryId; final int? categoryId;
final String? categoryPath; final String? categoryPath;
final bool? isActive;
final String? status;
final DateTime? deletedAt;
const AdminProduct({ const AdminProduct({
required this.id, required this.id,
@@ -13,6 +16,9 @@ class AdminProduct {
this.normalizedName, this.normalizedName,
this.categoryId, this.categoryId,
this.categoryPath, this.categoryPath,
this.isActive,
this.status,
this.deletedAt,
}); });
String get displayName => String get displayName =>
@@ -39,6 +45,11 @@ class AdminProduct {
normalizedName: json['normalizedName']?.toString(), normalizedName: json['normalizedName']?.toString(),
categoryId: (json['categoryId'] as num?)?.toInt(), categoryId: (json['categoryId'] as num?)?.toInt(),
categoryPath: names.isEmpty ? null : names.join(' > '), 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()),
); );
} }
} }
@@ -3,9 +3,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_ai_categorize_result.dart';
import '../domain/admin_category_node.dart'; import '../domain/admin_category_node.dart';
import '../domain/admin_product.dart'; import '../domain/admin_product.dart';
enum _ProductSort {
newest,
oldest,
nameAsc,
nameDesc,
categoryAsc,
categoryDesc,
}
class AdminProductsPanel extends ConsumerStatefulWidget { class AdminProductsPanel extends ConsumerStatefulWidget {
final bool embedded; final bool embedded;
@@ -19,13 +29,18 @@ class AdminProductsPanel extends ConsumerStatefulWidget {
class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> { class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
bool _isLoading = true; bool _isLoading = true;
bool _isApplying = false; bool _isApplying = false;
bool _isAiRunning = false;
String? _error; String? _error;
String _search = ''; String _search = '';
_ProductSort _sort = _ProductSort.newest;
bool _showDeletedOnly = false;
bool _showUncategorizedOnly = false; bool _showUncategorizedOnly = false;
String? _bulkCategoryValue; String? _bulkCategoryValue;
List<AdminProduct> _products = []; List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = []; List<AdminCategoryNode> _categories = [];
final Set<int> _selectedIds = <int>{}; final Set<int> _selectedIds = <int>{};
final Map<int, String> _rowCategoryValue = <int, String>{};
final Set<int> _rowCategorySaving = <int>{};
@override @override
void initState() { void initState() {
@@ -40,13 +55,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
}); });
try { try {
final results = await Future.wait<dynamic>([ final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listProducts(), _showDeletedOnly
? ref.read(adminRepositoryProvider).listDeletedProducts()
: ref.read(adminRepositoryProvider).listProducts(),
ref.read(adminRepositoryProvider).listCategoryTree(), ref.read(adminRepositoryProvider).listCategoryTree(),
]); ]);
if (!mounted) return; if (!mounted) return;
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>;
_rowCategoryValue.clear();
_rowCategorySaving.clear();
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -56,6 +75,23 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} }
} }
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<({String value, String label})> _flattenCategories(
List<AdminCategoryNode> nodes, [ List<AdminCategoryNode> nodes, [
int depth = 0, int depth = 0,
@@ -102,21 +138,291 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
} }
} }
Future<void> _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<Set<int>>(
context: context,
builder: (_) => _AiApplyDialog(suggestions: suggestions),
);
if (selectedProductIds == null || selectedProductIds.isEmpty) return;
final grouped = <int, List<int>>{};
for (final row in suggestions) {
if (!selectedProductIds.contains(row.productId)) continue;
grouped.putIfAbsent(row.categoryId, () => <int>[]).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<void> _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 = <int, int>{
first: second,
second: first,
};
final confirmed = await showDialog<bool>(
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<int>(
segments: [
ButtonSegment<int>(
value: first,
label: Text(_nameForId(first)),
),
ButtonSegment<int>(
value: second,
label: Text(_nameForId(second)),
),
],
selected: <int>{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<void> _removeProduct(AdminProduct product) async {
if (_showDeletedOnly) return;
final confirmed = await showDialog<bool>(
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<void> _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<void> _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<void> _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 @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 = _flattenCategories(_categories);
final filtered = _products.where((product) { final filtered = _products.where((product) {
if (_showUncategorizedOnly && product.categoryId != null) { if (!_showDeletedOnly && _showUncategorizedOnly && product.categoryId != null) {
return false; return false;
} }
final query = _search.trim().toLowerCase(); final query = _search.trim().toLowerCase();
if (query.isEmpty) return true; 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.canonicalName ?? '').toLowerCase().contains(query) ||
(product.normalizedName ?? '').toLowerCase().contains(query); (product.normalizedName ?? '').toLowerCase().contains(query) ||
(product.categoryPath ?? '').toLowerCase().contains(query);
}).toList() }).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 (_isLoading) return const Center(child: CircularProgressIndicator());
if (_error != null) { if (_error != null) {
@@ -148,12 +454,42 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ 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( FilterChip(
label: const Text('Endast okategoriserade'), label: const Text('Endast okategoriserade'),
selected: _showUncategorizedOnly, selected: _showUncategorizedOnly,
onSelected: (value) => onSelected: _showDeletedOnly
setState(() => _showUncategorizedOnly = value), ? null
: (value) => setState(() => _showUncategorizedOnly = value),
), ),
if (!_showDeletedOnly)
SizedBox( SizedBox(
width: 260, width: 260,
child: DropdownButtonFormField<String>( child: DropdownButtonFormField<String>(
@@ -177,6 +513,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
onChanged: (value) => setState(() => _bulkCategoryValue = value), onChanged: (value) => setState(() => _bulkCategoryValue = value),
), ),
), ),
if (!_showDeletedOnly)
FilledButton( FilledButton(
onPressed: _selectedIds.isEmpty || _isApplying onPressed: _selectedIds.isEmpty || _isApplying
? null ? null
@@ -189,6 +526,37 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
) )
: Text('Uppdatera valda (${_selectedIds.length})'), : 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), const SizedBox(height: 16),
@@ -197,7 +565,9 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
else else
...filtered.map( ...filtered.map(
(product) => Card( (product) => Card(
child: CheckboxListTile( child: Column(
children: [
CheckboxListTile(
value: _selectedIds.contains(product.id), value: _selectedIds.contains(product.id),
onChanged: (checked) { onChanged: (checked) {
setState(() { setState(() {
@@ -214,12 +584,92 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
if (product.displayName != product.name) if (product.displayName != product.name)
'Original: ${product.name}', 'Original: ${product.name}',
'Kategori: ${product.categoryPath ?? 'Saknas'}', 'Kategori: ${product.categoryPath ?? 'Saknas'}',
if (product.status != null) 'Status: ${product.status}',
if (_showDeletedOnly && product.deletedAt != null)
'Raderad: ${product.deletedAt}',
'ID: ${product.id}', 'ID: ${product.id}',
].join('\n'), ].join('\n'),
), ),
isThreeLine: true, isThreeLine: true,
controlAffinity: ListTileControlAffinity.leading, 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<String>(
key: ValueKey(
'row-category-${product.id}-${_rowCategoryFor(product)}',
),
initialValue: _rowCategoryFor(product),
decoration: const InputDecoration(
labelText: 'Kategori (inline)',
border: OutlineInputBorder(),
isDense: true,
),
items: [
const DropdownMenuItem<String>(
value: '__remove__',
child: Text('Ingen kategori'),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
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'),
),
],
),
),
],
),
), ),
), ),
], ],
@@ -235,3 +685,74 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
return content; return content;
} }
} }
class _AiApplyDialog extends StatefulWidget {
final List<AdminAiCategorizeResult> suggestions;
const _AiApplyDialog({required this.suggestions});
@override
State<_AiApplyDialog> createState() => _AiApplyDialogState();
}
class _AiApplyDialogState extends State<_AiApplyDialog> {
late final Set<int> _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})'),
),
],
);
}
}
@@ -143,6 +143,52 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
} }
} }
Future<void> _editEmail(UserAdmin user) async {
final controller = TextEditingController(text: user.email);
try {
final newEmail = await showDialog<String>(
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<void> _deleteUser(UserAdmin user) async { Future<void> _deleteUser(UserAdmin user) async {
final confirmed = await _confirm( final confirmed = await _confirm(
context, context,
@@ -273,6 +319,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
user: _users[i], user: _users[i],
onChangeRole: () => _changeRole(_users[i]), onChangeRole: () => _changeRole(_users[i]),
onTogglePremium: () => _togglePremium(_users[i]), onTogglePremium: () => _togglePremium(_users[i]),
onEditEmail: () => _editEmail(_users[i]),
onResetPassword: () => _resetPassword(_users[i]), onResetPassword: () => _resetPassword(_users[i]),
onDelete: () => _deleteUser(_users[i]), onDelete: () => _deleteUser(_users[i]),
), ),
@@ -317,6 +364,7 @@ class _UserTile extends StatelessWidget {
final UserAdmin user; final UserAdmin user;
final VoidCallback onChangeRole; final VoidCallback onChangeRole;
final VoidCallback onTogglePremium; final VoidCallback onTogglePremium;
final VoidCallback onEditEmail;
final VoidCallback onResetPassword; final VoidCallback onResetPassword;
final VoidCallback onDelete; final VoidCallback onDelete;
@@ -324,6 +372,7 @@ class _UserTile extends StatelessWidget {
required this.user, required this.user,
required this.onChangeRole, required this.onChangeRole,
required this.onTogglePremium, required this.onTogglePremium,
required this.onEditEmail,
required this.onResetPassword, required this.onResetPassword,
required this.onDelete, required this.onDelete,
}); });
@@ -383,6 +432,9 @@ class _UserTile extends StatelessWidget {
case 'premium': case 'premium':
onTogglePremium(); onTogglePremium();
break; break;
case 'email':
onEditEmail();
break;
case 'reset': case 'reset':
onResetPassword(); onResetPassword();
break; break;
@@ -402,6 +454,10 @@ class _UserTile extends StatelessWidget {
value: 'premium', value: 'premium',
child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'), child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'),
), ),
const PopupMenuItem(
value: 'email',
child: Text('Ändra e-post'),
),
const PopupMenuItem( const PopupMenuItem(
value: 'reset', value: 'reset',
child: Text('Återställ lösenord'), child: Text('Återställ lösenord'),