diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index 23902fd4..f04a9cc8 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -104,7 +104,7 @@ class _AdminDatabasePanelState extends ConsumerState { ), ), const SizedBox(height: 12), - child, + Expanded(child: child), ], ); } @@ -124,6 +124,18 @@ class _AdminDatabasePanelState extends ConsumerState { }; } + 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 { 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 { ), ), 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 { .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, + ), + ), ], ), ); diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index e3b2e221..32577c8b 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -34,13 +34,22 @@ class _AdminScreenState extends ConsumerState { 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), + ), ], ), ], diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index f1c4eadc..416af950 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -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 { 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 _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 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 _load() async { setState(() { _isLoading = true; @@ -292,6 +345,22 @@ class _AdminUsersPanelState extends ConsumerState { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final activeFilters = [ + 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 { 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 { 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 { return list; } - return SingleChildScrollView( + return Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -396,9 +467,79 @@ class _AdminUsersPanelState extends ConsumerState { ), ), 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 { ], ), 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), ], ), );