diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index f04a9cc8..4624c9fa 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -15,6 +15,20 @@ import '../../profile/data/profile_repository.dart'; enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai } +class _DatabaseTabConfig { + final _DatabaseTab tab; + final String title; + final String summary; + final Widget Function(BuildContext context) buildPanel; + + const _DatabaseTabConfig({ + required this.tab, + required this.title, + required this.summary, + required this.buildPanel, + }); +} + class AdminDatabasePanel extends ConsumerStatefulWidget { final bool embedded; @@ -28,6 +42,51 @@ class _AdminDatabasePanelState extends ConsumerState { _DatabaseTab _activeTab = _DatabaseTab.inventory; bool _isRefreshingCategories = false; + List<_DatabaseTabConfig> get _tabConfigs => [ + _DatabaseTabConfig( + tab: _DatabaseTab.inventory, + title: context.l10n.profileInventoryTab, + summary: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.', + buildPanel: (_) => const AdminInventoryPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.pantry, + title: context.l10n.profilePantryTab, + summary: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.', + buildPanel: (_) => const AdminPantryPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.products, + title: context.l10n.profileProductsTab, + summary: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.', + buildPanel: (_) => const AdminProductsPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.privateProducts, + title: 'Privata produkter', + summary: 'Promotera privata produkter till den globala produkt-tabellen.', + buildPanel: (_) => const AdminPrivateProductsPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.pending, + title: context.l10n.profilePendingTab, + summary: 'Godkänn eller avslå nya produkter som föreslagits av användare.', + buildPanel: (_) => const AdminPendingProductsPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.aliases, + title: 'Alias', + summary: 'Hantera globala alias som används i receipt-importens första matchningssteg.', + buildPanel: (_) => const AdminAliasesPanel(embedded: true), + ), + _DatabaseTabConfig( + tab: _DatabaseTab.ai, + title: 'AI', + summary: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.', + buildPanel: (_) => const AdminAiPanel(embedded: true), + ), + ]; + Future _refreshCategories() async { setState(() => _isRefreshingCategories = true); try { @@ -46,42 +105,6 @@ class _AdminDatabasePanelState extends ConsumerState { } } - Widget _sectionCard({ - required IconData icon, - required String title, - required String description, - required VoidCallback onPressed, - required String buttonLabel, - }) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(icon), - const SizedBox(width: 8), - Expanded( - child: Text(title, style: Theme.of(context).textTheme.titleMedium), - ), - ], - ), - const SizedBox(height: 8), - Text(description), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: onPressed, - icon: Icon(icon), - label: Text(buttonLabel), - ), - ], - ), - ), - ); - } - Widget _panelShell({ required String title, required String description, @@ -111,179 +134,56 @@ class _AdminDatabasePanelState extends ConsumerState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - String tabLabel(_DatabaseTab tab) { - return switch (tab) { - _DatabaseTab.inventory => context.l10n.profileInventoryTab, - _DatabaseTab.pantry => context.l10n.profilePantryTab, - _DatabaseTab.products => context.l10n.profileProductsTab, - _DatabaseTab.privateProducts => 'Privata produkter', - _DatabaseTab.pending => context.l10n.profilePendingTab, - _DatabaseTab.aliases => 'Alias', - _DatabaseTab.ai => 'AI', - }; - } + final currentTab = _tabConfigs.firstWhere((config) => config.tab == _activeTab); - 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: - activeSection = _panelShell( - title: context.l10n.profileInventoryTab, - description: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.', - child: const AdminInventoryPanel(embedded: true), - ); - case _DatabaseTab.pantry: - activeSection = _panelShell( - title: context.l10n.profilePantryTab, - description: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.', - child: const AdminPantryPanel(embedded: true), - ); - case _DatabaseTab.products: - activeSection = _panelShell( - title: context.l10n.profileProductsTab, - description: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.', - child: const AdminProductsPanel(embedded: true), - ); - case _DatabaseTab.privateProducts: - activeSection = _panelShell( - title: 'Privata produkter', - description: 'Promotera privata produkter till den globala produkt-tabellen.', - child: const AdminPrivateProductsPanel(embedded: true), - ); - case _DatabaseTab.pending: - activeSection = _panelShell( - title: context.l10n.profilePendingTab, - description: 'Godkänn eller avslå nya produkter som föreslagits av användare.', - child: const AdminPendingProductsPanel(embedded: true), - ); - case _DatabaseTab.aliases: - activeSection = _panelShell( - title: 'Alias', - description: 'Hantera globala alias som används i receipt-importens första matchningssteg.', - child: const AdminAliasesPanel(embedded: true), - ); - case _DatabaseTab.ai: - activeSection = _panelShell( - title: 'AI', - description: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.', - child: const AdminAiPanel(embedded: true), - ); - } - - final header = SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Databas', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Arbetsyta för data med tydlig scope: inventory och baslager är användarspecifika, produkter är globala eller privata och alias styr importmatchning.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - 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), - ), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Adminverktyg', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text('Uppdatera kategorier manuellt i backend-cachen.', style: theme.textTheme.bodyMedium), - const SizedBox(height: 12), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _isRefreshingCategories ? null : _refreshCategories, - icon: _isRefreshingCategories - ? const SizedBox( - height: 16, - width: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.refresh), - label: const Text('Uppdatera kategorier'), + final header = Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _tabConfigs + .map( + (config) => Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(config.title), + selected: _activeTab == config.tab, + onSelected: (_) => setState(() => _activeTab = config.tab), + ), + ), + ) + .toList(), ), ), - ], - ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Uppdatera kategorier', + onPressed: _isRefreshingCategories ? null : _refreshCategories, + icon: _isRefreshingCategories + ? const SizedBox( + height: 16, + width: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.refresh), + ), + ], ), - ), - 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( - children: _DatabaseTab.values - .map( - (tab) => Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(tabLabel(tab)), - selected: _activeTab == tab, - onSelected: (_) => setState(() => _activeTab = tab), - ), - ), - ) - .toList(), + const SizedBox(height: 8), + Text( + currentTab.summary, + style: Theme.of(context).textTheme.bodySmall, ), - ), - const SizedBox(height: 8), - Card( - child: ListTile( - leading: const Icon(Icons.tune), - title: Text('Aktiv panel: ${tabLabel(_activeTab)}'), - subtitle: Text(tabSummary(_activeTab)), - dense: true, - ), - ), - ], + ], + ), ), ); @@ -292,9 +192,9 @@ class _AdminDatabasePanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded(flex: 2, child: header), - const SizedBox(height: 16), - Expanded(flex: 5, child: activeSection), + Expanded(flex: 1, child: header), + const SizedBox(height: 12), + Expanded(flex: 6, child: _panelShell(title: currentTab.title, description: currentTab.summary, child: currentTab.buildPanel(context))), ], ), ); diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index 32577c8b..a50db86d 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -14,53 +14,15 @@ class AdminScreen extends ConsumerStatefulWidget { class _AdminScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); return DefaultTabController( length: 2, child: Column( children: [ - Padding( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), - child: Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Admin', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - 'Användare är för konton och roller. Databas är arbetsytan för inventarie, baslager, produkter, alias och importflöden.', - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - 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), - ), - ], - ), - ], - ), - ), - ), - ), Material( color: Theme.of(context).colorScheme.surface, child: TabBar( isScrollable: true, + padding: const EdgeInsets.symmetric(horizontal: 12), tabs: [ Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)), const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)), @@ -71,11 +33,11 @@ class _AdminScreenState extends ConsumerState { child: TabBarView( children: [ Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), + padding: EdgeInsets.fromLTRB(12, 8, 12, 8), child: AdminUsersPanel(embedded: true), ), Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), + padding: EdgeInsets.fromLTRB(12, 8, 12, 8), child: AdminDatabasePanel(embedded: true), ), ], diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 416af950..79fb9db5 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -440,33 +440,6 @@ class _AdminUsersPanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Användarkonton', style: theme.textTheme.titleMedium), - const SizedBox(height: 8), - Text( - context.l10n.adminUsersDescription, - style: theme.textTheme.bodyMedium, - ), - const SizedBox(height: 8), - const Wrap( - spacing: 8, - runSpacing: 8, - children: [ - Chip(label: Text('Roller')), - Chip(label: Text('Premium')), - Chip(label: Text('Delning')), - ], - ), - ], - ), - ), - ), - const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, @@ -547,73 +520,6 @@ 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,