feat: implement admin product management panel with bulk categorization and premium user toggle
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user