feat: enhance admin product management with AI categorization, product status updates, and email editing for users
This commit is contained in:
@@ -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<AdminProductsPanel> {
|
||||
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<AdminProduct> _products = [];
|
||||
List<AdminCategoryNode> _categories = [];
|
||||
final Set<int> _selectedIds = <int>{};
|
||||
final Map<int, String> _rowCategoryValue = <int, String>{};
|
||||
final Set<int> _rowCategorySaving = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -40,13 +55,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
});
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
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<AdminProduct>;
|
||||
_categories = results[1] as List<AdminCategoryNode>;
|
||||
_rowCategoryValue.clear();
|
||||
_rowCategorySaving.clear();
|
||||
});
|
||||
} catch (e) {
|
||||
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<AdminCategoryNode> nodes, [
|
||||
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
|
||||
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<AdminProductsPanel> {
|
||||
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<String>(
|
||||
initialValue: _bulkCategoryValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bulk: sätt kategori',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: '__remove__',
|
||||
child: Text('Ta bort kategori'),
|
||||
),
|
||||
...categoryOptions.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
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<String>(
|
||||
initialValue: _bulkCategoryValue,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Bulk: sätt kategori',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: [
|
||||
const DropdownMenuItem<String>(
|
||||
value: '__remove__',
|
||||
child: Text('Ta bort kategori'),
|
||||
),
|
||||
...categoryOptions.map(
|
||||
(option) => DropdownMenuItem<String>(
|
||||
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<AdminProductsPanel> {
|
||||
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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -234,4 +684,75 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
|
||||
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 {
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
@@ -273,6 +319,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
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'),
|
||||
|
||||
Reference in New Issue
Block a user