feat: implement admin product management panel with bulk categorization and premium user toggle
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/api/api_error_mapper.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/admin_category_node.dart';
|
||||
import '../domain/admin_product.dart';
|
||||
|
||||
class AdminProductsPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
const AdminProductsPanel({super.key, this.embedded = false});
|
||||
|
||||
@override
|
||||
ConsumerState<AdminProductsPanel> createState() =>
|
||||
_AdminProductsPanelState();
|
||||
}
|
||||
|
||||
class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
bool _isLoading = true;
|
||||
bool _isApplying = false;
|
||||
String? _error;
|
||||
String _search = '';
|
||||
bool _showUncategorizedOnly = false;
|
||||
String? _bulkCategoryValue;
|
||||
List<AdminProduct> _products = [];
|
||||
List<AdminCategoryNode> _categories = [];
|
||||
final Set<int> _selectedIds = <int>{};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final results = await Future.wait<dynamic>([
|
||||
ref.read(adminRepositoryProvider).listProducts(),
|
||||
ref.read(adminRepositoryProvider).listCategoryTree(),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_products = results[0] as List<AdminProduct>;
|
||||
_categories = results[1] as List<AdminCategoryNode>;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = mapErrorToUserMessage(e, context));
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
}
|
||||
}
|
||||
|
||||
List<({String value, String label})> _flattenCategories(
|
||||
List<AdminCategoryNode> nodes, [
|
||||
int depth = 0,
|
||||
]) {
|
||||
final result = <({String value, String label})>[];
|
||||
final sorted = [...nodes]
|
||||
..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
for (final node in sorted) {
|
||||
final prefix = depth == 0 ? '' : '${' ' * depth}↳ ';
|
||||
result.add((value: node.id.toString(), label: '$prefix${node.name}'));
|
||||
result.addAll(_flattenCategories(node.children, depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _applyBulkCategory() async {
|
||||
if (_selectedIds.isEmpty || _isApplying) return;
|
||||
setState(() => _isApplying = true);
|
||||
try {
|
||||
await ref.read(adminRepositoryProvider).bulkSetCategory(
|
||||
_selectedIds.toList(),
|
||||
categoryId: _bulkCategoryValue == null ||
|
||||
_bulkCategoryValue == '__remove__'
|
||||
? null
|
||||
: int.parse(_bulkCategoryValue!),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedIds.clear();
|
||||
_bulkCategoryValue = null;
|
||||
});
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Produkter uppdaterade.')),
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(mapErrorToUserMessage(e, context))),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isApplying = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final categoryOptions = _flattenCategories(_categories);
|
||||
final filtered = _products.where((product) {
|
||||
if (_showUncategorizedOnly && product.categoryId != null) {
|
||||
return false;
|
||||
}
|
||||
final query = _search.trim().toLowerCase();
|
||||
if (query.isEmpty) return true;
|
||||
return product.name.toLowerCase().contains(query) ||
|
||||
(product.canonicalName ?? '').toLowerCase().contains(query) ||
|
||||
(product.normalizedName ?? '').toLowerCase().contains(query);
|
||||
}).toList()
|
||||
..sort((a, b) => b.id.compareTo(a.id));
|
||||
|
||||
if (_isLoading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_error!, style: TextStyle(color: theme.colorScheme.error)),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(onPressed: _load, child: const Text('Försök igen')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final content = Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sök produkt',
|
||||
prefixIcon: Icon(Icons.search),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
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
|
||||
? null
|
||||
: _applyBulkCategory,
|
||||
child: _isApplying
|
||||
? const SizedBox(
|
||||
height: 18,
|
||||
width: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text('Uppdatera valda (${_selectedIds.length})'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (filtered.isEmpty)
|
||||
const Text('Inga produkter matchar filtret.')
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
if (!widget.embedded) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [content],
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,26 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _togglePremium(UserAdmin user) async {
|
||||
final newValue = !user.isPremium;
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
newValue ? 'Ge Premium' : 'Ta bort Premium',
|
||||
'${newValue ? 'Ge' : 'Ta bort'} Premium för ${user.username}?',
|
||||
);
|
||||
if (!confirmed || !mounted) return;
|
||||
try {
|
||||
await ref
|
||||
.read(adminRepositoryProvider)
|
||||
.setPremium(user.id, isPremium: newValue);
|
||||
if (!mounted) return;
|
||||
_load();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
_showError(e);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _resetPassword(UserAdmin user) async {
|
||||
final confirmed = await _confirm(
|
||||
context,
|
||||
@@ -252,6 +272,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
itemBuilder: (ctx, i) => _UserTile(
|
||||
user: _users[i],
|
||||
onChangeRole: () => _changeRole(_users[i]),
|
||||
onTogglePremium: () => _togglePremium(_users[i]),
|
||||
onResetPassword: () => _resetPassword(_users[i]),
|
||||
onDelete: () => _deleteUser(_users[i]),
|
||||
),
|
||||
@@ -295,12 +316,14 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
class _UserTile extends StatelessWidget {
|
||||
final UserAdmin user;
|
||||
final VoidCallback onChangeRole;
|
||||
final VoidCallback onTogglePremium;
|
||||
final VoidCallback onResetPassword;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _UserTile({
|
||||
required this.user,
|
||||
required this.onChangeRole,
|
||||
required this.onTogglePremium,
|
||||
required this.onResetPassword,
|
||||
required this.onDelete,
|
||||
});
|
||||
@@ -356,10 +379,16 @@ class _UserTile extends StatelessWidget {
|
||||
switch (action) {
|
||||
case 'role':
|
||||
onChangeRole();
|
||||
break;
|
||||
case 'premium':
|
||||
onTogglePremium();
|
||||
break;
|
||||
case 'reset':
|
||||
onResetPassword();
|
||||
break;
|
||||
case 'delete':
|
||||
onDelete();
|
||||
break;
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
@@ -369,6 +398,10 @@ class _UserTile extends StatelessWidget {
|
||||
user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin',
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'premium',
|
||||
child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'reset',
|
||||
child: Text('Återställ lösenord'),
|
||||
|
||||
Reference in New Issue
Block a user