feat: implement admin product management panel with bulk categorization and premium user toggle

This commit is contained in:
Nils-Johan Gynther
2026-04-25 08:36:40 +02:00
parent e2b7b884aa
commit a02950c97a
9 changed files with 383 additions and 34 deletions
+2
View File
@@ -5,6 +5,8 @@
- RecipesScreen är nu body-only, ingen egen Scaffold/AppBar. - RecipesScreen är nu body-only, ingen egen Scaffold/AppBar.
- AppShell visar grid-ikon endast på /recipes. - AppShell visar grid-ikon endast på /recipes.
- Buggfix: Produktväljaren i pantry/inventarie (ProductPickerField) — bottenark implementeras. - 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. - Kodkvalitet: Inga absoluta Windows-sökvägar.
- Dokumentation och next_steps uppdaterade. - Dokumentation och next_steps uppdaterade.
# Flutter Frontend - User Guide # Flutter Frontend - User Guide
+5
View File
@@ -6,12 +6,17 @@ class ProductApiPaths {
static const list = '/products'; static const list = '/products';
static const pending = '/products/pending'; static const pending = '/products/pending';
static String setStatus(int id) => '/products/$id/status'; static String setStatus(int id) => '/products/$id/status';
static const bulkUpdate = '/products/bulk-update';
} }
class AiApiPaths { class AiApiPaths {
static const models = '/ai/models'; static const models = '/ai/models';
} }
class CategoryApiPaths {
static const tree = '/categories/tree';
}
class RecipeApiPaths { class RecipeApiPaths {
static const list = '/recipes'; static const list = '/recipes';
static String detail(int id) => '/recipes/$id'; static String detail(int id) => '/recipes/$id';
@@ -3,6 +3,8 @@ import '../../../core/api/api_client.dart';
import '../../../core/api/api_paths.dart'; import '../../../core/api/api_paths.dart';
import '../../../core/api/guarded_api_call.dart'; import '../../../core/api/guarded_api_call.dart';
import '../../auth/data/auth_providers.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/ai_model_info.dart';
import '../domain/pending_product.dart'; import '../domain/pending_product.dart';
import '../domain/user_admin.dart'; import '../domain/user_admin.dart';
@@ -115,4 +117,36 @@ class AdminRepository {
.map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>)) .map((e) => AiModelInfo.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
} }
Future<List<AdminProduct>> listProducts() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(ProductApiPaths.list),
);
return (data as List<dynamic>)
.map((e) => AdminProduct.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<List<AdminCategoryNode>> listCategoryTree() async {
final data = await guardedApiCall(
_ref,
() => _apiClient.getJson(CategoryApiPaths.tree),
);
return (data as List<dynamic>)
.map((e) => AdminCategoryNode.fromJson(e as Map<String, dynamic>))
.toList();
}
Future<void> bulkSetCategory(List<int> ids, {required int? categoryId}) async {
final token = await _token();
await guardedApiCall(
_ref,
() => _apiClient.postJson(
ProductApiPaths.bulkUpdate,
body: {'ids': ids, 'categoryId': categoryId},
token: token,
),
);
}
} }
@@ -0,0 +1,24 @@
class AdminCategoryNode {
final int id;
final String name;
final int? parentId;
final List<AdminCategoryNode> children;
const AdminCategoryNode({
required this.id,
required this.name,
required this.parentId,
required this.children,
});
factory AdminCategoryNode.fromJson(Map<String, dynamic> json) =>
AdminCategoryNode(
id: (json['id'] as num).toInt(),
name: (json['name'] ?? '').toString(),
parentId: (json['parentId'] as num?)?.toInt(),
children: ((json['children'] as List<dynamic>?) ?? const [])
.map((child) =>
AdminCategoryNode.fromJson(child as Map<String, dynamic>))
.toList(),
);
}
@@ -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<String, dynamic> json) {
final categoryRef = json['categoryRef'];
final names = <String>[];
dynamic current = categoryRef;
while (current is Map<String, dynamic>) {
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(' > '),
);
}
}
@@ -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<AdminProductsPanel> createState() =>
_AdminProductsPanelState();
}
class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
bool _isLoading = true;
bool _isApplying = false;
String? _error;
String _search = '';
bool _showUncategorizedOnly = false;
String? _bulkCategoryValue;
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
final Set<int> _selectedIds = <int>{};
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listProducts(),
ref.read(adminRepositoryProvider).listCategoryTree(),
]);
if (!mounted) return;
setState(() {
_products = results[0] as List<AdminProduct>;
_categories = results[1] as List<AdminCategoryNode>;
});
} catch (e) {
if (!mounted) return;
setState(() => _error = mapErrorToUserMessage(e, context));
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
List<({String value, String label})> _flattenCategories(
List<AdminCategoryNode> 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<void> _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<String>(
initialValue: _bulkCategoryValue,
decoration: const InputDecoration(
labelText: 'Bulk: sätt kategori',
border: OutlineInputBorder(),
),
items: [
const DropdownMenuItem<String>(
value: '__remove__',
child: Text('Ta bort kategori'),
),
...categoryOptions.map(
(option) => DropdownMenuItem<String>(
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;
}
}
@@ -61,6 +61,26 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
} }
} }
Future<void> _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<void> _resetPassword(UserAdmin user) async { Future<void> _resetPassword(UserAdmin user) async {
final confirmed = await _confirm( final confirmed = await _confirm(
context, context,
@@ -252,6 +272,7 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
itemBuilder: (ctx, i) => _UserTile( itemBuilder: (ctx, i) => _UserTile(
user: _users[i], user: _users[i],
onChangeRole: () => _changeRole(_users[i]), onChangeRole: () => _changeRole(_users[i]),
onTogglePremium: () => _togglePremium(_users[i]),
onResetPassword: () => _resetPassword(_users[i]), onResetPassword: () => _resetPassword(_users[i]),
onDelete: () => _deleteUser(_users[i]), onDelete: () => _deleteUser(_users[i]),
), ),
@@ -295,12 +316,14 @@ class _AdminUsersPanelState extends ConsumerState<AdminUsersPanel> {
class _UserTile extends StatelessWidget { class _UserTile extends StatelessWidget {
final UserAdmin user; final UserAdmin user;
final VoidCallback onChangeRole; final VoidCallback onChangeRole;
final VoidCallback onTogglePremium;
final VoidCallback onResetPassword; final VoidCallback onResetPassword;
final VoidCallback onDelete; final VoidCallback onDelete;
const _UserTile({ const _UserTile({
required this.user, required this.user,
required this.onChangeRole, required this.onChangeRole,
required this.onTogglePremium,
required this.onResetPassword, required this.onResetPassword,
required this.onDelete, required this.onDelete,
}); });
@@ -356,10 +379,16 @@ class _UserTile extends StatelessWidget {
switch (action) { switch (action) {
case 'role': case 'role':
onChangeRole(); onChangeRole();
break;
case 'premium':
onTogglePremium();
break;
case 'reset': case 'reset':
onResetPassword(); onResetPassword();
break;
case 'delete': case 'delete':
onDelete(); onDelete();
break;
} }
}, },
itemBuilder: (_) => [ itemBuilder: (_) => [
@@ -369,6 +398,10 @@ class _UserTile extends StatelessWidget {
user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin', user.isAdmin ? 'Nedgradera till user' : 'Uppgradera till admin',
), ),
), ),
PopupMenuItem(
value: 'premium',
child: Text(user.isPremium ? 'Ta bort Premium' : 'Ge Premium'),
),
const PopupMenuItem( const PopupMenuItem(
value: 'reset', value: 'reset',
child: Text('Återställ lösenord'), child: Text('Återställ lösenord'),
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../admin/presentation/admin_ai_panel.dart'; import '../../admin/presentation/admin_ai_panel.dart';
import '../../admin/presentation/admin_pending_products_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 '../../admin/presentation/admin_users_panel.dart';
import '../../auth/data/auth_providers.dart'; import '../../auth/data/auth_providers.dart';
import '../data/profile_repository.dart'; import '../data/profile_repository.dart';
@@ -297,14 +298,7 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
buttonLabel: 'Öppna baslager', buttonLabel: 'Öppna baslager',
); );
case _DatabaseTab.products: case _DatabaseTab.products:
activeSection = sectionCard( activeSection = const AdminProductsPanel(embedded: true);
icon: Icons.category_outlined,
title: 'Produkter',
description:
'Adminhantering av produktkatalogen, inklusive standardisering och vidare produktadministration.',
onPressed: () => context.go('/admin'),
buttonLabel: 'Öppna Admin',
);
} }
return Column( return Column(
@@ -338,32 +332,6 @@ class _ProfileScreenState extends ConsumerState<ProfileScreen> {
); );
} }
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) { Widget _buildActiveTabContent(BuildContext context, ThemeData theme) {
switch (_activeTab) { switch (_activeTab) {
case _ProfileTab.profile: case _ProfileTab.profile:
+2
View File
@@ -5,6 +5,8 @@
- RecipesScreen är nu body-only, ingen egen Scaffold/AppBar. - RecipesScreen är nu body-only, ingen egen Scaffold/AppBar.
- AppShell visar grid-ikon endast på /recipes. - AppShell visar grid-ikon endast på /recipes.
- Buggfix: Produktväljaren i pantry/inventarie (ProductPickerField) — bottenark implementeras. - 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. - Kodkvalitet: Inga absoluta Windows-sökvägar.
- Dokumentation och next_steps uppdaterade. - Dokumentation och next_steps uppdaterade.
# Next Steps: Flutter-migrering # Next Steps: Flutter-migrering