feat: Enhance admin user management with search, filtering, and sorting capabilities
Test Suite / test (24.15.0) (push) Has been cancelled
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -104,7 +104,7 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
Expanded(child: child),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -124,6 +124,18 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
};
|
||||
}
|
||||
|
||||
String tabSummary(_DatabaseTab tab) {
|
||||
return switch (tab) {
|
||||
_DatabaseTab.inventory => 'Användarnas inventory med filtrering, redigering, merge och flytt till baslager.',
|
||||
_DatabaseTab.pantry => 'Användarnas baslager med flytt till inventarie och borttagning av poster.',
|
||||
_DatabaseTab.products => 'Global produktkatalog med kategorisering, merge, återställning och AI-stöd.',
|
||||
_DatabaseTab.privateProducts => 'Privata produkter som kan promoveras till global produktkatalog.',
|
||||
_DatabaseTab.pending => 'Nya produkter som väntar på godkännande eller avslag.',
|
||||
_DatabaseTab.aliases => 'Globala receipt-alias för fallback-matchning i importen.',
|
||||
_DatabaseTab.ai => 'Översikt av AI-modeller, accessnivåer och triggers i systemet.',
|
||||
};
|
||||
}
|
||||
|
||||
Widget activeSection;
|
||||
switch (_activeTab) {
|
||||
case _DatabaseTab.inventory:
|
||||
@@ -187,14 +199,26 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Wrap(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('User-scope')),
|
||||
Chip(label: Text('Global scope')),
|
||||
Chip(label: Text('Private products')),
|
||||
Chip(label: Text('Alias')),
|
||||
ActionChip(
|
||||
label: const Text('User-scope'),
|
||||
onPressed: () => setState(() => _activeTab = _DatabaseTab.inventory),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Global scope'),
|
||||
onPressed: () => setState(() => _activeTab = _DatabaseTab.products),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Private products'),
|
||||
onPressed: () => setState(() => _activeTab = _DatabaseTab.privateProducts),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Alias'),
|
||||
onPressed: () => setState(() => _activeTab = _DatabaseTab.aliases),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -231,6 +255,8 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text('Välj område att arbeta med', style: theme.textTheme.labelLarge),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
@@ -248,6 +274,15 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: Text('Aktiv panel: ${tabLabel(_activeTab)}'),
|
||||
subtitle: Text(tabSummary(_activeTab)),
|
||||
dense: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -34,13 +34,22 @@ class _AdminScreenState extends ConsumerState<AdminScreen> {
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Wrap(
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
Chip(label: Text('Konton')),
|
||||
Chip(label: Text('Databas')),
|
||||
Chip(label: Text('Privat + globalt')),
|
||||
ActionChip(
|
||||
label: const Text('Konton'),
|
||||
onPressed: () => DefaultTabController.of(context).animateTo(0),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Databas'),
|
||||
onPressed: () => DefaultTabController.of(context).animateTo(1),
|
||||
),
|
||||
ActionChip(
|
||||
label: const Text('Privat + globalt'),
|
||||
onPressed: () => DefaultTabController.of(context).animateTo(1),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
@@ -7,6 +7,8 @@ import '../../../core/l10n/l10n.dart';
|
||||
import '../data/admin_repository.dart';
|
||||
import '../domain/user_admin.dart';
|
||||
|
||||
enum _UserSort { newest, usernameAsc, usernameDesc, roleAdminFirst }
|
||||
|
||||
class AdminUsersPanel extends ConsumerStatefulWidget {
|
||||
final bool embedded;
|
||||
|
||||
@@ -19,14 +21,65 @@ class AdminUsersPanel extends ConsumerStatefulWidget {
|
||||
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
bool _isLoading = true;
|
||||
String? _error;
|
||||
String _search = '';
|
||||
late final TextEditingController _searchCtrl;
|
||||
_UserSort _sort = _UserSort.newest;
|
||||
bool _filterAdminOnly = false;
|
||||
bool _filterPremiumOnly = false;
|
||||
bool _filterSharingOffOnly = false;
|
||||
List<UserAdmin> _users = [];
|
||||
|
||||
String _sortLabel(_UserSort sort) => switch (sort) {
|
||||
_UserSort.newest => 'Nyast',
|
||||
_UserSort.usernameAsc => 'Användarnamn A-Ö',
|
||||
_UserSort.usernameDesc => 'Användarnamn Ö-A',
|
||||
_UserSort.roleAdminFirst => 'Admin först',
|
||||
};
|
||||
|
||||
List<UserAdmin> get _visibleUsers {
|
||||
final query = _search.trim().toLowerCase();
|
||||
final filtered = _users.where((user) {
|
||||
if (_filterAdminOnly && !user.isAdmin) return false;
|
||||
if (_filterPremiumOnly && !user.isPremium) return false;
|
||||
if (_filterSharingOffOnly && user.canShareRecipes) return false;
|
||||
if (query.isEmpty) return true;
|
||||
return user.username.toLowerCase().contains(query) ||
|
||||
user.displayName.toLowerCase().contains(query) ||
|
||||
user.email.toLowerCase().contains(query) ||
|
||||
user.id.toString().contains(query);
|
||||
}).toList();
|
||||
|
||||
filtered.sort((a, b) {
|
||||
switch (_sort) {
|
||||
case _UserSort.newest:
|
||||
return b.id.compareTo(a.id);
|
||||
case _UserSort.usernameAsc:
|
||||
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
|
||||
case _UserSort.usernameDesc:
|
||||
return b.username.toLowerCase().compareTo(a.username.toLowerCase());
|
||||
case _UserSort.roleAdminFirst:
|
||||
if (a.isAdmin == b.isAdmin) {
|
||||
return a.username.toLowerCase().compareTo(b.username.toLowerCase());
|
||||
}
|
||||
return a.isAdmin ? -1 : 1;
|
||||
}
|
||||
});
|
||||
return filtered;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_searchCtrl = TextEditingController();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
@@ -292,6 +345,22 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final activeFilters = <String>[
|
||||
if (_filterAdminOnly) 'Endast admin',
|
||||
if (_filterPremiumOnly) 'Endast premium',
|
||||
if (_filterSharingOffOnly) 'Delning avstängd',
|
||||
];
|
||||
|
||||
final searchSummary = _search.trim().isEmpty
|
||||
? 'Ingen aktiv sökning'
|
||||
: 'Sökning: "${_search.trim()}"';
|
||||
|
||||
final filterSummary = activeFilters.isEmpty
|
||||
? 'Inga aktiva snabbfilter'
|
||||
: 'Aktiva filter: ${activeFilters.join(', ')}';
|
||||
|
||||
final viewSummary = 'Sortering: ${_sortLabel(_sort)} • $searchSummary • $filterSummary';
|
||||
|
||||
if (_isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -303,6 +372,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
title: 'Kunde inte läsa användare',
|
||||
);
|
||||
}
|
||||
final visibleUsers = _visibleUsers;
|
||||
|
||||
if (_users.isEmpty) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -344,19 +415,19 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
|
||||
final list = ListView.builder(
|
||||
shrinkWrap: false,
|
||||
physics: null,
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: widget.embedded
|
||||
? EdgeInsets.zero
|
||||
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
|
||||
itemCount: _users.length,
|
||||
itemCount: visibleUsers.length,
|
||||
itemBuilder: (ctx, i) => _UserTile(
|
||||
user: _users[i],
|
||||
onChangeRole: () => _changeRole(_users[i]),
|
||||
onTogglePremium: () => _togglePremium(_users[i]),
|
||||
onToggleRecipeSharing: () => _toggleRecipeSharing(_users[i]),
|
||||
onEditEmail: () => _editEmail(_users[i]),
|
||||
onResetPassword: () => _resetPassword(_users[i]),
|
||||
onDelete: () => _deleteUser(_users[i]),
|
||||
user: visibleUsers[i],
|
||||
onChangeRole: () => _changeRole(visibleUsers[i]),
|
||||
onTogglePremium: () => _togglePremium(visibleUsers[i]),
|
||||
onToggleRecipeSharing: () => _toggleRecipeSharing(visibleUsers[i]),
|
||||
onEditEmail: () => _editEmail(visibleUsers[i]),
|
||||
onResetPassword: () => _resetPassword(visibleUsers[i]),
|
||||
onDelete: () => _deleteUser(visibleUsers[i]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -364,7 +435,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
return list;
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -396,9 +467,79 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
FilterChip(
|
||||
label: const Text('Endast admin'),
|
||||
selected: _filterAdminOnly,
|
||||
onSelected: (value) => setState(() => _filterAdminOnly = value),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Endast premium'),
|
||||
selected: _filterPremiumOnly,
|
||||
onSelected: (value) => setState(() => _filterPremiumOnly = value),
|
||||
),
|
||||
FilterChip(
|
||||
label: const Text('Delning avstängd'),
|
||||
selected: _filterSharingOffOnly,
|
||||
onSelected: (value) => setState(() => _filterSharingOffOnly = value),
|
||||
),
|
||||
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly)
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_filterAdminOnly = false;
|
||||
_filterPremiumOnly = false;
|
||||
_filterSharingOffOnly = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.clear_all),
|
||||
label: const Text('Rensa filter'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
SizedBox(
|
||||
width: 260,
|
||||
child: DropdownButtonFormField<_UserSort>(
|
||||
initialValue: _sort,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Sortering',
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
),
|
||||
items: _UserSort.values
|
||||
.map(
|
||||
(value) => DropdownMenuItem<_UserSort>(
|
||||
value: value,
|
||||
child: Text(_sortLabel(value)),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _sort = value);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
isDense: true,
|
||||
prefixIcon: Icon(Icons.search),
|
||||
hintText: 'Sök användare, e-post eller id',
|
||||
),
|
||||
onChanged: (value) => setState(() => _search = value),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Uppdatera',
|
||||
@@ -407,13 +548,84 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('Aktiv vy: Användare'),
|
||||
subtitle: Text(viewSummary),
|
||||
dense: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (_search.trim().isNotEmpty)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
_searchCtrl.clear();
|
||||
setState(() => _search = '');
|
||||
},
|
||||
icon: const Icon(Icons.search_off),
|
||||
label: const Text('Rensa sök'),
|
||||
),
|
||||
if (_filterAdminOnly || _filterPremiumOnly || _filterSharingOffOnly)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_filterAdminOnly = false;
|
||||
_filterPremiumOnly = false;
|
||||
_filterSharingOffOnly = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.filter_alt_off),
|
||||
label: const Text('Rensa filter'),
|
||||
),
|
||||
if (_sort != _UserSort.newest)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => setState(() => _sort = _UserSort.newest),
|
||||
icon: const Icon(Icons.sort),
|
||||
label: const Text('Återställ sortering'),
|
||||
),
|
||||
TextButton.icon(
|
||||
onPressed: () {
|
||||
_searchCtrl.clear();
|
||||
setState(() {
|
||||
_search = '';
|
||||
_sort = _UserSort.newest;
|
||||
_filterAdminOnly = false;
|
||||
_filterPremiumOnly = false;
|
||||
_filterSharingOffOnly = false;
|
||||
});
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Återställ vy'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Visar ${visibleUsers.length} av ${_users.length} användare',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
onPressed: _createUser,
|
||||
icon: const Icon(Icons.person_add_outlined),
|
||||
label: Text(context.l10n.adminNewUser),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
list,
|
||||
Expanded(child: list),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user