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),
|
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;
|
Widget activeSection;
|
||||||
switch (_activeTab) {
|
switch (_activeTab) {
|
||||||
case _DatabaseTab.inventory:
|
case _DatabaseTab.inventory:
|
||||||
@@ -187,14 +199,26 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Chip(label: Text('User-scope')),
|
ActionChip(
|
||||||
Chip(label: Text('Global scope')),
|
label: const Text('User-scope'),
|
||||||
Chip(label: Text('Private products')),
|
onPressed: () => setState(() => _activeTab = _DatabaseTab.inventory),
|
||||||
Chip(label: Text('Alias')),
|
),
|
||||||
|
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),
|
const SizedBox(height: 12),
|
||||||
|
Text('Välj område att arbeta med', style: theme.textTheme.labelLarge),
|
||||||
|
const SizedBox(height: 8),
|
||||||
SingleChildScrollView(
|
SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -248,6 +274,15 @@ class _AdminDatabasePanelState extends ConsumerState<AdminDatabasePanel> {
|
|||||||
.toList(),
|
.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,
|
style: theme.textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Wrap(
|
Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
Chip(label: Text('Konton')),
|
ActionChip(
|
||||||
Chip(label: Text('Databas')),
|
label: const Text('Konton'),
|
||||||
Chip(label: Text('Privat + globalt')),
|
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 '../data/admin_repository.dart';
|
||||||
import '../domain/user_admin.dart';
|
import '../domain/user_admin.dart';
|
||||||
|
|
||||||
|
enum _UserSort { newest, usernameAsc, usernameDesc, roleAdminFirst }
|
||||||
|
|
||||||
class AdminUsersPanel extends ConsumerStatefulWidget {
|
class AdminUsersPanel extends ConsumerStatefulWidget {
|
||||||
final bool embedded;
|
final bool embedded;
|
||||||
|
|
||||||
@@ -19,14 +21,65 @@ class AdminUsersPanel extends ConsumerStatefulWidget {
|
|||||||
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
String _search = '';
|
||||||
|
late final TextEditingController _searchCtrl;
|
||||||
|
_UserSort _sort = _UserSort.newest;
|
||||||
|
bool _filterAdminOnly = false;
|
||||||
|
bool _filterPremiumOnly = false;
|
||||||
|
bool _filterSharingOffOnly = false;
|
||||||
List<UserAdmin> _users = [];
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_searchCtrl = TextEditingController();
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
@@ -292,6 +345,22 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(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) {
|
if (_isLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -303,6 +372,8 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
title: 'Kunde inte läsa användare',
|
title: 'Kunde inte läsa användare',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
final visibleUsers = _visibleUsers;
|
||||||
|
|
||||||
if (_users.isEmpty) {
|
if (_users.isEmpty) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -344,19 +415,19 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
|
|
||||||
final list = ListView.builder(
|
final list = ListView.builder(
|
||||||
shrinkWrap: false,
|
shrinkWrap: false,
|
||||||
physics: null,
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: widget.embedded
|
padding: widget.embedded
|
||||||
? EdgeInsets.zero
|
? EdgeInsets.zero
|
||||||
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
|
: const EdgeInsets.fromLTRB(16, 8, 16, 80),
|
||||||
itemCount: _users.length,
|
itemCount: visibleUsers.length,
|
||||||
itemBuilder: (ctx, i) => _UserTile(
|
itemBuilder: (ctx, i) => _UserTile(
|
||||||
user: _users[i],
|
user: visibleUsers[i],
|
||||||
onChangeRole: () => _changeRole(_users[i]),
|
onChangeRole: () => _changeRole(visibleUsers[i]),
|
||||||
onTogglePremium: () => _togglePremium(_users[i]),
|
onTogglePremium: () => _togglePremium(visibleUsers[i]),
|
||||||
onToggleRecipeSharing: () => _toggleRecipeSharing(_users[i]),
|
onToggleRecipeSharing: () => _toggleRecipeSharing(visibleUsers[i]),
|
||||||
onEditEmail: () => _editEmail(_users[i]),
|
onEditEmail: () => _editEmail(visibleUsers[i]),
|
||||||
onResetPassword: () => _resetPassword(_users[i]),
|
onResetPassword: () => _resetPassword(visibleUsers[i]),
|
||||||
onDelete: () => _deleteUser(_users[i]),
|
onDelete: () => _deleteUser(visibleUsers[i]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -364,7 +435,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -396,9 +467,79 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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(
|
Row(
|
||||||
children: [
|
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(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
tooltip: 'Uppdatera',
|
tooltip: 'Uppdatera',
|
||||||
@@ -407,13 +548,84 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
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(
|
FilledButton.icon(
|
||||||
onPressed: _createUser,
|
onPressed: _createUser,
|
||||||
icon: const Icon(Icons.person_add_outlined),
|
icon: const Icon(Icons.person_add_outlined),
|
||||||
label: Text(context.l10n.adminNewUser),
|
label: Text(context.l10n.adminNewUser),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
list,
|
Expanded(child: list),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user