feat: enhance admin product management with AI categorization, product status updates, and email editing for users
This commit is contained in:
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user