From a02950c97a8f0054a56fff7247477403c9b3f89e Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 25 Apr 2026 08:36:40 +0200 Subject: [PATCH] feat: implement admin product management panel with bulk categorization and premium user toggle --- flutter/README.md | 2 + flutter/lib/core/api/api_paths.dart | 5 + .../features/admin/data/admin_repository.dart | 34 +++ .../admin/domain/admin_category_node.dart | 24 ++ .../features/admin/domain/admin_product.dart | 44 ++++ .../presentation/admin_products_panel.dart | 237 ++++++++++++++++++ .../admin/presentation/admin_users_panel.dart | 33 +++ .../profile/presentation/profile_screen.dart | 36 +-- flutter/next_steps_flutter.md | 2 + 9 files changed, 383 insertions(+), 34 deletions(-) create mode 100644 flutter/lib/features/admin/domain/admin_category_node.dart create mode 100644 flutter/lib/features/admin/domain/admin_product.dart create mode 100644 flutter/lib/features/admin/presentation/admin_products_panel.dart diff --git a/flutter/README.md b/flutter/README.md index 26a76f1d..52995c30 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -5,6 +5,8 @@ - RecipesScreen är nu body-only, ingen egen Scaffold/AppBar. - AppShell visar grid-ikon endast på /recipes. - Buggfix: Produktväljaren i pantry/inventarie (ProductPickerField) — bottenark implementeras. +- Sprint 2: Databas > Produkter har fått en inbäddad produktadminpanel med sök, okategoriserat-filter och bulk-kategorisering. +- Sprint 2: Admin kan nu slå Premium av/på för användare direkt i Användare-fliken. - Kodkvalitet: Inga absoluta Windows-sökvägar. - Dokumentation och next_steps uppdaterade. # Flutter Frontend - User Guide diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 8f9e872b..6ebc1573 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -6,12 +6,17 @@ class ProductApiPaths { static const list = '/products'; static const pending = '/products/pending'; static String setStatus(int id) => '/products/$id/status'; + static const bulkUpdate = '/products/bulk-update'; } class AiApiPaths { static const models = '/ai/models'; } +class CategoryApiPaths { + static const tree = '/categories/tree'; +} + class RecipeApiPaths { static const list = '/recipes'; static String detail(int id) => '/recipes/$id'; diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index fdb3c041..5dcd95de 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -3,6 +3,8 @@ import '../../../core/api/api_client.dart'; import '../../../core/api/api_paths.dart'; import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; +import '../domain/admin_category_node.dart'; +import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; import '../domain/pending_product.dart'; import '../domain/user_admin.dart'; @@ -115,4 +117,36 @@ class AdminRepository { .map((e) => AiModelInfo.fromJson(e as Map)) .toList(); } + + Future> listProducts() async { + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(ProductApiPaths.list), + ); + return (data as List) + .map((e) => AdminProduct.fromJson(e as Map)) + .toList(); + } + + Future> listCategoryTree() async { + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson(CategoryApiPaths.tree), + ); + return (data as List) + .map((e) => AdminCategoryNode.fromJson(e as Map)) + .toList(); + } + + Future bulkSetCategory(List ids, {required int? categoryId}) async { + final token = await _token(); + await guardedApiCall( + _ref, + () => _apiClient.postJson( + ProductApiPaths.bulkUpdate, + body: {'ids': ids, 'categoryId': categoryId}, + token: token, + ), + ); + } } diff --git a/flutter/lib/features/admin/domain/admin_category_node.dart b/flutter/lib/features/admin/domain/admin_category_node.dart new file mode 100644 index 00000000..f86f85fd --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_category_node.dart @@ -0,0 +1,24 @@ +class AdminCategoryNode { + final int id; + final String name; + final int? parentId; + final List children; + + const AdminCategoryNode({ + required this.id, + required this.name, + required this.parentId, + required this.children, + }); + + factory AdminCategoryNode.fromJson(Map json) => + AdminCategoryNode( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + parentId: (json['parentId'] as num?)?.toInt(), + children: ((json['children'] as List?) ?? const []) + .map((child) => + AdminCategoryNode.fromJson(child as Map)) + .toList(), + ); +} \ No newline at end of file diff --git a/flutter/lib/features/admin/domain/admin_product.dart b/flutter/lib/features/admin/domain/admin_product.dart new file mode 100644 index 00000000..46bbb222 --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_product.dart @@ -0,0 +1,44 @@ +class AdminProduct { + final int id; + final String name; + final String? canonicalName; + final String? normalizedName; + final int? categoryId; + final String? categoryPath; + + const AdminProduct({ + required this.id, + required this.name, + this.canonicalName, + this.normalizedName, + this.categoryId, + this.categoryPath, + }); + + String get displayName => + canonicalName != null && canonicalName!.trim().isNotEmpty + ? canonicalName! + : name; + + factory AdminProduct.fromJson(Map json) { + final categoryRef = json['categoryRef']; + final names = []; + dynamic current = categoryRef; + while (current is Map) { + final name = current['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + names.insert(0, name); + } + current = current['parent']; + } + + return AdminProduct( + id: (json['id'] as num).toInt(), + name: (json['name'] ?? '').toString(), + canonicalName: json['canonicalName']?.toString(), + normalizedName: json['normalizedName']?.toString(), + categoryId: (json['categoryId'] as num?)?.toInt(), + categoryPath: names.isEmpty ? null : names.join(' > '), + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart new file mode 100644 index 00000000..c25c7667 --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -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 createState() => + _AdminProductsPanelState(); +} + +class _AdminProductsPanelState extends ConsumerState { + bool _isLoading = true; + bool _isApplying = false; + String? _error; + String _search = ''; + bool _showUncategorizedOnly = false; + String? _bulkCategoryValue; + List _products = []; + List _categories = []; + final Set _selectedIds = {}; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final results = await Future.wait([ + ref.read(adminRepositoryProvider).listProducts(), + ref.read(adminRepositoryProvider).listCategoryTree(), + ]); + if (!mounted) return; + setState(() { + _products = results[0] as List; + _categories = results[1] as List; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + List<({String value, String label})> _flattenCategories( + List 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 _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( + initialValue: _bulkCategoryValue, + decoration: const InputDecoration( + labelText: 'Bulk: sätt kategori', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: '__remove__', + child: Text('Ta bort kategori'), + ), + ...categoryOptions.map( + (option) => DropdownMenuItem( + 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; + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index 9e20dec6..09ecbb63 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -61,6 +61,26 @@ class _AdminUsersPanelState extends ConsumerState { } } + Future _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 _resetPassword(UserAdmin user) async { final confirmed = await _confirm( context, @@ -252,6 +272,7 @@ class _AdminUsersPanelState extends ConsumerState { 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 { 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'), diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index d160b55d..6d2eedb9 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../admin/presentation/admin_ai_panel.dart'; import '../../admin/presentation/admin_pending_products_panel.dart'; +import '../../admin/presentation/admin_products_panel.dart'; import '../../admin/presentation/admin_users_panel.dart'; import '../../auth/data/auth_providers.dart'; import '../data/profile_repository.dart'; @@ -297,14 +298,7 @@ class _ProfileScreenState extends ConsumerState { buttonLabel: 'Öppna baslager', ); case _DatabaseTab.products: - activeSection = sectionCard( - icon: Icons.category_outlined, - title: 'Produkter', - description: - 'Adminhantering av produktkatalogen, inklusive standardisering och vidare produktadministration.', - onPressed: () => context.go('/admin'), - buttonLabel: 'Öppna Admin', - ); + activeSection = const AdminProductsPanel(embedded: true); } return Column( @@ -338,32 +332,6 @@ class _ProfileScreenState extends ConsumerState { ); } - Widget _buildAdminPlaceholder( - BuildContext context, { - required String title, - required String description, - }) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - Text(description), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: () => context.go('/admin'), - icon: const Icon(Icons.admin_panel_settings_outlined), - label: const Text('Öppna Admin'), - ), - ], - ), - ), - ); - } - Widget _buildActiveTabContent(BuildContext context, ThemeData theme) { switch (_activeTab) { case _ProfileTab.profile: diff --git a/flutter/next_steps_flutter.md b/flutter/next_steps_flutter.md index b1720b31..3ac93572 100644 --- a/flutter/next_steps_flutter.md +++ b/flutter/next_steps_flutter.md @@ -5,6 +5,8 @@ - RecipesScreen är nu body-only, ingen egen Scaffold/AppBar. - AppShell visar grid-ikon endast på /recipes. - Buggfix: Produktväljaren i pantry/inventarie (ProductPickerField) — bottenark implementeras. +- Sprint 2: Databas > Produkter visar nu en riktig adminpanel i profilflödet med sök, okategoriserat-filter och bulk-kategorisering. +- Sprint 2: Användare-fliken stödjer nu Premium av/på direkt från användarmenyn. - Kodkvalitet: Inga absoluta Windows-sökvägar. - Dokumentation och next_steps uppdaterade. # Next Steps: Flutter-migrering