feat: Implement admin pantry item management with create and update functionality, including category selection and validation
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-11 11:18:13 +02:00
parent 573c12cdc3
commit f132983b75
11 changed files with 683 additions and 38 deletions
@@ -0,0 +1,9 @@
import { IsInt, IsOptional, Min } from 'class-validator';
import { CreatePantryItemDto } from './create-pantry-item.dto';
export class CreateAdminPantryItemDto extends CreatePantryItemDto {
@IsOptional()
@IsInt()
@Min(1)
userId?: number;
}
@@ -0,0 +1,13 @@
import { IsInt, IsOptional, IsPositive, IsString, MaxLength } from 'class-validator';
export class UpdatePantryItemDto {
@IsOptional()
@IsInt()
@IsPositive()
productId?: number;
@IsOptional()
@IsString()
@MaxLength(50)
location?: string;
}
+21 -1
View File
@@ -1,9 +1,11 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Query } from '@nestjs/common';
import { PantryService } from './pantry.service'; import { PantryService } from './pantry.service';
import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { CurrentUser } from '../auth/decorators/current-user.decorator';
import { Roles } from '../auth/decorators/roles.decorator'; import { Roles } from '../auth/decorators/roles.decorator';
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto'; import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
import { CreateAdminPantryItemDto } from './dto/create-admin-pantry-item.dto';
import { UpdatePantryItemDto } from './dto/update-pantry-item.dto';
@Controller('pantry') @Controller('pantry')
export class PantryController { export class PantryController {
@@ -31,6 +33,24 @@ export class PantryController {
}); });
} }
@Roles('admin')
@Post('admin')
createAdmin(
@CurrentUser() user: { userId: number },
@Body() body: CreateAdminPantryItemDto,
) {
return this.pantryService.createAdmin(user.userId, body, body.userId);
}
@Roles('admin')
@Patch('admin/:id')
updateAdmin(
@Param('id', ParseIntPipe) id: number,
@Body() body: UpdatePantryItemDto,
) {
return this.pantryService.updateAdmin(id, body);
}
@Delete(':id') @Delete(':id')
remove( remove(
@CurrentUser() user: { userId: number }, @CurrentUser() user: { userId: number },
+117 -14
View File
@@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; import { CreatePantryItemDto } from './dto/create-pantry-item.dto';
import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto'; import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { UpdatePantryItemDto } from './dto/update-pantry-item.dto';
type PantryQuery = { type PantryQuery = {
userId?: number; userId?: number;
@@ -12,11 +13,43 @@ type PantryQuery = {
export class PantryService { export class PantryService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
private readonly productWithCategoryInclude = {
include: {
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
};
private async ensureProductExistsAny(productId: number) {
const product = await this.prisma.product.findUnique({ where: { id: productId } });
if (!product) {
throw new NotFoundException('Product not found');
}
return product;
}
private async ensureUserExists(userId: number) {
const user = await this.prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!user) {
throw new NotFoundException(`User with id ${userId} not found`);
}
}
findAll(userId: number) { findAll(userId: number) {
return this.prisma.pantryItem.findMany({ return this.prisma.pantryItem.findMany({
where: { userId }, where: { userId },
include: { include: {
product: true, product: this.productWithCategoryInclude,
}, },
orderBy: { orderBy: {
product: { name: 'asc' }, product: { name: 'asc' },
@@ -36,17 +69,7 @@ export class PantryService {
}, },
}, },
product: { product: {
include: { ...this.productWithCategoryInclude,
categoryRef: {
include: {
parent: {
include: {
parent: true,
},
},
},
},
},
}, },
}, },
orderBy: [ orderBy: [
@@ -76,7 +99,87 @@ export class PantryService {
productId: data.productId, productId: data.productId,
location: data.location?.trim() || null, location: data.location?.trim() || null,
}, },
include: { product: true }, include: { product: this.productWithCategoryInclude },
});
}
async createAdmin(adminUserId: number, data: CreatePantryItemDto, targetUserId?: number) {
const effectiveUserId = typeof targetUserId === 'number' ? targetUserId : adminUserId;
await this.ensureUserExists(effectiveUserId);
await this.ensureProductExistsAny(data.productId);
const existing = await this.prisma.pantryItem.findUnique({
where: {
userId_productId: {
userId: effectiveUserId,
productId: data.productId,
},
},
});
if (existing) {
throw new ConflictException('Produkten finns redan i baslagret');
}
return this.prisma.pantryItem.create({
data: {
userId: effectiveUserId,
productId: data.productId,
location: data.location?.trim() || null,
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
});
}
async updateAdmin(id: number, data: UpdatePantryItemDto) {
const existing = await this.prisma.pantryItem.findUnique({ where: { id } });
if (!existing) {
throw new NotFoundException(`PantryItem med id ${id} hittades inte`);
}
if (typeof data.productId === 'number') {
await this.ensureProductExistsAny(data.productId);
const duplicate = await this.prisma.pantryItem.findUnique({
where: {
userId_productId: {
userId: existing.userId,
productId: data.productId,
},
},
});
if (duplicate && duplicate.id !== id) {
throw new ConflictException('Produkten finns redan i baslagret');
}
}
return this.prisma.pantryItem.update({
where: { id },
data: {
...(typeof data.productId === 'number'
? { product: { connect: { id: data.productId } } }
: {}),
...(typeof data.location === 'string'
? { location: data.location.trim() || null }
: {}),
},
include: {
user: {
select: {
id: true,
username: true,
email: true,
},
},
product: this.productWithCategoryInclude,
},
}); });
} }
@@ -123,7 +226,7 @@ export class PantryService {
suitableFor: data.suitableFor?.trim() || undefined, suitableFor: data.suitableFor?.trim() || undefined,
comment: data.comment?.trim() || undefined, comment: data.comment?.trim() || undefined,
}, },
include: { product: true }, include: { product: this.productWithCategoryInclude },
}); });
await tx.pantryItem.delete({ where: { id: item.id } }); await tx.pantryItem.delete({ where: { id: item.id } });
+8
View File
@@ -544,6 +544,14 @@ INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Marmelad', c2.id FROM `Category` c1 SELECT 'Marmelad', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Sylt, mos & marmelad' JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Sylt, mos & marmelad'
WHERE c1.name = 'Skafferi' AND c1.parentId IS NULL; WHERE c1.name = 'Skafferi' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Sylt', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Sylt, mos & marmelad'
WHERE c1.name = 'Skafferi' AND c1.parentId IS NULL;
INSERT INTO `Category` (`name`, `parentId`)
SELECT 'Mos', c2.id FROM `Category` c1
JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Sylt, mos & marmelad'
WHERE c1.name = 'Skafferi' AND c1.parentId IS NULL;
-- ── NIVÅ 3: under Fisk & Skaldjur > Fisk ──────────────────── -- ── NIVÅ 3: under Fisk & Skaldjur > Fisk ────────────────────
INSERT INTO `Category` (`name`, `parentId`) INSERT INTO `Category` (`name`, `parentId`)
+2
View File
@@ -95,6 +95,8 @@ class PantryApiPaths {
static String moveToInventory(int id) => '/pantry/$id/move-to-inventory'; static String moveToInventory(int id) => '/pantry/$id/move-to-inventory';
static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory'; static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory';
static const adminList = '/pantry/admin'; static const adminList = '/pantry/admin';
static const adminCreate = '/pantry/admin';
static String adminUpdate(int id) => '/pantry/admin/$id';
static String adminRemove(int id) => '/pantry/admin/$id'; static String adminRemove(int id) => '/pantry/admin/$id';
} }
@@ -436,6 +436,38 @@ class AdminRepository {
Future<void> removeAdminPantryItem(int pantryItemId) => Future<void> removeAdminPantryItem(int pantryItemId) =>
_deleteVoid(PantryApiPaths.adminRemove(pantryItemId)); _deleteVoid(PantryApiPaths.adminRemove(pantryItemId));
Future<AdminPantryItem> createAdminPantry({
int? userId,
required int productId,
String? location,
}) {
return _post<AdminPantryItem>(
PantryApiPaths.adminCreate,
body: {
if (userId != null) 'userId': userId,
'productId': productId,
if (location != null && location.trim().isNotEmpty)
'location': location.trim(),
},
parse: (d) => AdminPantryItem.fromJson(Map<String, dynamic>.from(d as Map)),
);
}
Future<AdminPantryItem> updateAdminPantry(
int pantryItemId, {
int? productId,
String? location,
}) {
return _patch(
PantryApiPaths.adminUpdate(pantryItemId),
body: {
if (productId != null) 'productId': productId,
if (location != null) 'location': location,
},
parse: AdminPantryItem.fromJson,
);
}
Future<void> moveAdminPantryToInventory( Future<void> moveAdminPantryToInventory(
int pantryItemId, int pantryItemId,
Map<String, dynamic> body, Map<String, dynamic> body,
@@ -6,6 +6,8 @@ class AdminInventoryItem {
final int productId; final int productId;
final String productName; final String productName;
final String? productCanonicalName; final String? productCanonicalName;
final int? categoryId;
final String? categoryPath;
final double quantity; final double quantity;
final String unit; final String unit;
final String? location; final String? location;
@@ -22,6 +24,8 @@ class AdminInventoryItem {
required this.productId, required this.productId,
required this.productName, required this.productName,
this.productCanonicalName, this.productCanonicalName,
this.categoryId,
this.categoryPath,
required this.quantity, required this.quantity,
required this.unit, required this.unit,
this.location, this.location,
@@ -40,6 +44,15 @@ class AdminInventoryItem {
factory AdminInventoryItem.fromJson(Map<String, dynamic> json) { factory AdminInventoryItem.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? const {}; final user = (json['user'] as Map<String, dynamic>?) ?? const {};
final product = (json['product'] as Map<String, dynamic>?) ?? const {}; final product = (json['product'] as Map<String, dynamic>?) ?? const {};
final names = <String>[];
dynamic current = product['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 AdminInventoryItem( return AdminInventoryItem(
id: json['id'] as int, id: json['id'] as int,
userId: json['userId'] as int, userId: json['userId'] as int,
@@ -48,6 +61,8 @@ class AdminInventoryItem {
productId: json['productId'] as int, productId: json['productId'] as int,
productName: product['name'] as String? ?? '', productName: product['name'] as String? ?? '',
productCanonicalName: product['canonicalName'] as String?, productCanonicalName: product['canonicalName'] as String?,
categoryId: (product['categoryId'] as num?)?.toInt(),
categoryPath: names.isEmpty ? null : names.join(' > '),
quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0, quantity: double.tryParse(json['quantity']?.toString() ?? '0') ?? 0,
unit: json['unit'] as String? ?? '', unit: json['unit'] as String? ?? '',
location: json['location'] as String?, location: json['location'] as String?,
@@ -6,6 +6,8 @@ class AdminPantryItem {
final int productId; final int productId;
final String productName; final String productName;
final String? productCanonicalName; final String? productCanonicalName;
final int? categoryId;
final String? categoryPath;
final String? location; final String? location;
const AdminPantryItem({ const AdminPantryItem({
@@ -16,6 +18,8 @@ class AdminPantryItem {
required this.productId, required this.productId,
required this.productName, required this.productName,
this.productCanonicalName, this.productCanonicalName,
this.categoryId,
this.categoryPath,
this.location, this.location,
}); });
@@ -28,6 +32,15 @@ class AdminPantryItem {
factory AdminPantryItem.fromJson(Map<String, dynamic> json) { factory AdminPantryItem.fromJson(Map<String, dynamic> json) {
final user = (json['user'] as Map<String, dynamic>?) ?? const {}; final user = (json['user'] as Map<String, dynamic>?) ?? const {};
final product = (json['product'] as Map<String, dynamic>?) ?? const {}; final product = (json['product'] as Map<String, dynamic>?) ?? const {};
final names = <String>[];
dynamic current = product['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 AdminPantryItem( return AdminPantryItem(
id: (json['id'] as num).toInt(), id: (json['id'] as num).toInt(),
userId: (json['userId'] as num).toInt(), userId: (json['userId'] as num).toInt(),
@@ -36,6 +49,8 @@ class AdminPantryItem {
productId: (json['productId'] as num).toInt(), productId: (json['productId'] as num).toInt(),
productName: product['name'] as String? ?? '', productName: product['name'] as String? ?? '',
productCanonicalName: product['canonicalName'] as String?, productCanonicalName: product['canonicalName'] as String?,
categoryId: (product['categoryId'] as num?)?.toInt(),
categoryPath: names.isEmpty ? null : names.join(' > '),
location: json['location'] as String?, location: json['location'] as String?,
); );
} }
@@ -3,7 +3,10 @@ import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart';
import '../domain/admin_inventory_item.dart'; import '../domain/admin_inventory_item.dart';
import '../domain/admin_product.dart'; import '../domain/admin_product.dart';
import '../domain/user_admin.dart'; import '../domain/user_admin.dart';
@@ -34,6 +37,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
_InventorySort _sort = _InventorySort.newest; _InventorySort _sort = _InventorySort.newest;
List<AdminInventoryItem> _items = []; List<AdminInventoryItem> _items = [];
List<AdminProduct> _products = []; List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<UserAdmin> _users = []; List<UserAdmin> _users = [];
@override @override
@@ -55,13 +59,15 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
sort: _sortParam, sort: _sortParam,
), ),
ref.read(adminRepositoryProvider).listGlobalProducts(), ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(), ref.read(adminRepositoryProvider).listUsers(),
]); ]);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items = results[0] as List<AdminInventoryItem>; _items = results[0] as List<AdminInventoryItem>;
_products = results[1] as List<AdminProduct>; _products = results[1] as List<AdminProduct>;
_users = results[2] as List<UserAdmin>; _categories = results[2] as List<AdminCategoryNode>;
_users = results[3] as List<UserAdmin>;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -94,7 +100,8 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
return item.displayName.toLowerCase().contains(q) || return item.displayName.toLowerCase().contains(q) ||
item.username.toLowerCase().contains(q) || item.username.toLowerCase().contains(q) ||
item.userEmail.toLowerCase().contains(q) || item.userEmail.toLowerCase().contains(q) ||
(item.location ?? '').toLowerCase().contains(q); (item.location ?? '').toLowerCase().contains(q) ||
(item.categoryPath ?? '').toLowerCase().contains(q);
}).toList(); }).toList();
} }
@@ -453,6 +460,7 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
builder: (context) => _InventoryFormDialog( builder: (context) => _InventoryFormDialog(
users: _users, users: _users,
products: _products, products: _products,
categories: _categories,
initial: initial, initial: initial,
initialOwnerUserId: initialOwnerUserId, initialOwnerUserId: initialOwnerUserId,
), ),
@@ -656,7 +664,8 @@ class _AdminInventoryPanelState extends ConsumerState<AdminInventoryPanel> {
title: Text(item.displayName), title: Text(item.displayName),
subtitle: Text( subtitle: Text(
'${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})' '${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})'
'${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}', '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.isEmpty ? '' : ' · ${item.categoryPath}'}',
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -730,12 +739,14 @@ class _InventoryFormValues {
class _InventoryFormDialog extends StatefulWidget { class _InventoryFormDialog extends StatefulWidget {
final List<UserAdmin> users; final List<UserAdmin> users;
final List<AdminProduct> products; final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final AdminInventoryItem? initial; final AdminInventoryItem? initial;
final int? initialOwnerUserId; final int? initialOwnerUserId;
const _InventoryFormDialog({ const _InventoryFormDialog({
required this.users, required this.users,
required this.products, required this.products,
required this.categories,
this.initial, this.initial,
this.initialOwnerUserId, this.initialOwnerUserId,
}); });
@@ -756,6 +767,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
int? _ownerUserId; int? _ownerUserId;
int? _productId; int? _productId;
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
@override @override
void initState() { void initState() {
@@ -763,6 +777,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
final initial = widget.initial; final initial = widget.initial;
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId; _ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
_productId = initial?.productId; _productId = initial?.productId;
final initialProduct = _productById(_productId);
_categoryId = initialProduct?.categoryId;
_categoryPath = initialProduct?.categoryPath;
_quantityController = TextEditingController( _quantityController = TextEditingController(
text: initial == null ? '' : initial.quantity.toString(), text: initial == null ? '' : initial.quantity.toString(),
); );
@@ -786,6 +803,44 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
super.dispose(); super.dispose();
} }
AdminProduct? _productById(int? id) {
if (id == null) return null;
for (final product in widget.products) {
if (product.id == id) return product;
}
return null;
}
List<ProductOption> _productOptions() {
final source = _categoryId == null
? widget.products
: widget.products.where((p) => p.categoryId == _categoryId).toList();
final sorted = [...source]
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
return sorted
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
.toList();
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categories,
preselectedCategoryId: _categoryId,
);
if (selected == null || !mounted) return;
setState(() {
_categoryId = selected.id;
_categoryPath = selected.path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AlertDialog( return AlertDialog(
@@ -833,20 +888,53 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
DropdownButtonFormField<int>( GestureDetector(
initialValue: _productId, onTap: _pickCategory,
items: widget.products child: InputDecorator(
.map((p) => DropdownMenuItem<int>( decoration: const InputDecoration(
value: p.id, labelText: 'Kategori',
child: Text( border: OutlineInputBorder(),
p.displayName, ),
overflow: TextOverflow.ellipsis, child: Text(
), _categoryPath == null || _categoryPath!.trim().isEmpty
)) ? 'Tryck för att välja kategori'
.toList(), : _categoryPath!,
onChanged: (value) => setState(() => _productId = value), ),
decoration: const InputDecoration(labelText: 'Produkt'), ),
validator: (value) => value == null ? 'Välj en produkt' : null, ),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _pickCategory,
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
setState(() {
_categoryId = null;
_categoryPath = null;
});
},
icon: const Icon(Icons.clear),
label: const Text('Rensa kategori'),
),
],
),
const SizedBox(height: 12),
ProductPickerField(
products: _productOptions(),
value: _productId,
label: 'Produkt',
errorText: _productErrorText,
onChanged: (value) {
setState(() {
_productId = value;
_productErrorText = null;
});
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
TextFormField( TextFormField(
@@ -905,6 +993,10 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> {
FilledButton( FilledButton(
onPressed: () { onPressed: () {
if (!_formKey.currentState!.validate()) return; if (!_formKey.currentState!.validate()) return;
if (_productId == null) {
setState(() => _productErrorText = 'Välj en produkt');
return;
}
final quantity = final quantity =
double.parse(_quantityController.text.trim().replaceAll(',', '.')); double.parse(_quantityController.text.trim().replaceAll(',', '.'));
@@ -4,8 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/api/api_error_mapper.dart'; import '../../../core/api/api_error_mapper.dart';
import '../../../core/forms/form_options.dart'; import '../../../core/forms/form_options.dart';
import '../../../core/l10n/l10n.dart'; import '../../../core/l10n/l10n.dart';
import '../../../core/ui/category_then_product_picker.dart';
import '../../../core/ui/product_picker_field.dart';
import '../data/admin_repository.dart'; import '../data/admin_repository.dart';
import '../domain/admin_category_node.dart';
import '../domain/admin_pantry_item.dart'; import '../domain/admin_pantry_item.dart';
import '../domain/admin_product.dart';
import '../domain/user_admin.dart'; import '../domain/user_admin.dart';
class AdminPantryPanel extends ConsumerStatefulWidget { class AdminPantryPanel extends ConsumerStatefulWidget {
@@ -20,8 +24,11 @@ class AdminPantryPanel extends ConsumerStatefulWidget {
class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> { class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
String _search = '';
int? _selectedUserId; int? _selectedUserId;
List<AdminPantryItem> _items = []; List<AdminPantryItem> _items = [];
List<AdminProduct> _products = [];
List<AdminCategoryNode> _categories = [];
List<UserAdmin> _users = []; List<UserAdmin> _users = [];
@override @override
@@ -39,12 +46,16 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
try { try {
final results = await Future.wait<dynamic>([ final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId), ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId),
ref.read(adminRepositoryProvider).listGlobalProducts(),
ref.read(adminRepositoryProvider).listCategoryTree(),
ref.read(adminRepositoryProvider).listUsers(), ref.read(adminRepositoryProvider).listUsers(),
]); ]);
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_items = results[0] as List<AdminPantryItem>; _items = results[0] as List<AdminPantryItem>;
_users = results[1] as List<UserAdmin>; _products = results[1] as List<AdminProduct>;
_categories = results[2] as List<AdminCategoryNode>;
_users = results[3] as List<UserAdmin>;
}); });
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -54,6 +65,82 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
} }
} }
List<AdminPantryItem> get _filtered {
final q = _search.trim().toLowerCase();
if (q.isEmpty) return _items;
return _items.where((item) {
return item.displayName.toLowerCase().contains(q) ||
item.username.toLowerCase().contains(q) ||
item.userEmail.toLowerCase().contains(q) ||
(item.location ?? '').toLowerCase().contains(q) ||
(item.categoryPath ?? '').toLowerCase().contains(q);
}).toList();
}
Future<void> _addItem() async {
final values = await _showPantryFormDialog(initialOwnerUserId: _selectedUserId);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).createAdminPantry(
userId: values.ownerUserId,
productId: values.productId,
location: values.location,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Baslager-post skapad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<void> _editItem(AdminPantryItem item) async {
final values = await _showPantryFormDialog(initial: item);
if (values == null) return;
try {
await ref.read(adminRepositoryProvider).updateAdminPantry(
item.id,
productId: values.productId,
location: values.location,
);
if (!mounted) return;
await _load();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Baslager-post uppdaterad.')),
);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)),
);
}
}
Future<_PantryFormValues?> _showPantryFormDialog({
AdminPantryItem? initial,
int? initialOwnerUserId,
}) {
return showDialog<_PantryFormValues>(
context: context,
builder: (context) => _PantryFormDialog(
users: _users,
products: _products,
categories: _categories,
initial: initial,
initialOwnerUserId: initialOwnerUserId,
),
);
}
Future<void> _moveToInventory(AdminPantryItem item) async { Future<void> _moveToInventory(AdminPantryItem item) async {
final quantityController = TextEditingController(text: '1'); final quantityController = TextEditingController(text: '1');
String selectedUnit = 'st'; String selectedUnit = 'st';
@@ -204,6 +291,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
); );
} }
final filtered = _filtered;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -216,7 +305,7 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
Text('Baslager', style: theme.textTheme.titleMedium), Text('Baslager', style: theme.textTheme.titleMedium),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'Här ser du användarnas pantryposter. Flytta dem tillbaka till inventarie eller ta bort poster som inte längre ska ligga kvar.', 'Här ser du användarnas pantryposter. Du kan lägga till, ändra, flytta till inventarie och sätta/ändra kategori via produktval.',
style: theme.textTheme.bodyMedium, style: theme.textTheme.bodyMedium,
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -225,6 +314,8 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
runSpacing: 8, runSpacing: 8,
children: [ children: [
Chip(label: Text('User-scope')), Chip(label: Text('User-scope')),
Chip(label: Text('Kategorier')),
Chip(label: Text('Ändra/Lägg till')),
Chip(label: Text('Flytta till inventarie')), Chip(label: Text('Flytta till inventarie')),
Chip(label: Text('Ta bort')), Chip(label: Text('Ta bort')),
], ],
@@ -262,18 +353,36 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(
child: TextField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.search),
hintText: 'Sök produkt, kategori, användare eller plats',
),
onChanged: (value) => setState(() => _search = value),
),
),
const SizedBox(width: 8),
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _load, onPressed: _load,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('Uppdatera'), label: const Text('Uppdatera'),
), ),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add),
label: const Text('Lägg till'),
),
], ],
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Visar ${filtered.length} av ${_items.length} baslager-poster'),
const SizedBox(height: 8),
Expanded( Expanded(
child: _items.isEmpty child: filtered.isEmpty
? Card( ? Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
@@ -296,18 +405,25 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
), ),
) )
: ListView.separated( : ListView.separated(
itemCount: _items.length, itemCount: filtered.length,
separatorBuilder: (_, __) => const Divider(height: 1), separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = _items[index]; final item = filtered[index];
return ListTile( return ListTile(
title: Text(item.displayName), title: Text(item.displayName),
subtitle: Text( subtitle: Text(
'${item.username} (${item.userEmail})${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}', '${item.username} (${item.userEmail})'
'${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}'
'${item.categoryPath == null || item.categoryPath!.trim().isEmpty ? '' : ' · ${item.categoryPath}'}',
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
IconButton(
tooltip: 'Ändra',
icon: const Icon(Icons.edit_outlined),
onPressed: () => _editItem(item),
),
IconButton( IconButton(
tooltip: 'Flytta till inventarie', tooltip: 'Flytta till inventarie',
icon: const Icon(Icons.inventory_2_outlined), icon: const Icon(Icons.inventory_2_outlined),
@@ -338,4 +454,224 @@ class _AdminPantryPanelState extends ConsumerState<AdminPantryPanel> {
], ],
); );
} }
}
class _PantryFormValues {
final int? ownerUserId;
final int productId;
final String? location;
const _PantryFormValues({
this.ownerUserId,
required this.productId,
this.location,
});
}
class _PantryFormDialog extends StatefulWidget {
final List<UserAdmin> users;
final List<AdminProduct> products;
final List<AdminCategoryNode> categories;
final AdminPantryItem? initial;
final int? initialOwnerUserId;
const _PantryFormDialog({
required this.users,
required this.products,
required this.categories,
this.initial,
this.initialOwnerUserId,
});
@override
State<_PantryFormDialog> createState() => _PantryFormDialogState();
}
class _PantryFormDialogState extends State<_PantryFormDialog> {
late final TextEditingController _locationController;
int? _ownerUserId;
int? _productId;
int? _categoryId;
String? _categoryPath;
String? _productErrorText;
@override
void initState() {
super.initState();
final initial = widget.initial;
_ownerUserId = initial?.userId ?? widget.initialOwnerUserId;
_productId = initial?.productId;
final initialProduct = _productById(_productId);
_categoryId = initialProduct?.categoryId;
_categoryPath = initialProduct?.categoryPath;
_locationController = TextEditingController(text: initial?.location ?? '');
}
@override
void dispose() {
_locationController.dispose();
super.dispose();
}
AdminProduct? _productById(int? id) {
if (id == null) return null;
for (final product in widget.products) {
if (product.id == id) return product;
}
return null;
}
List<ProductOption> _productOptions() {
final source = _categoryId == null
? widget.products
: widget.products.where((p) => p.categoryId == _categoryId).toList();
final sorted = [...source]
..sort((a, b) => a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()));
return sorted
.map((p) => (id: p.id, name: p.displayName, categoryId: p.categoryId))
.toList();
}
Future<void> _pickCategory() async {
final selected = await CategoryThenProductPicker.showCategorySheet(
context,
categoryTree: widget.categories,
preselectedCategoryId: _categoryId,
);
if (selected == null || !mounted) return;
setState(() {
_categoryId = selected.id;
_categoryPath = selected.path;
if (_productId != null) {
final current = _productById(_productId);
if (current?.categoryId != _categoryId) {
_productId = null;
}
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.initial == null ? 'Lägg till baslager-post' : 'Ändra baslager-post'),
content: SizedBox(
width: 460,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.initial == null) ...[
DropdownButtonFormField<int>(
initialValue: _ownerUserId,
items: widget.users
.map((u) => DropdownMenuItem<int>(
value: u.id,
child: Text(
'${u.displayName} (${u.username})',
overflow: TextOverflow.ellipsis,
),
))
.toList(),
onChanged: (value) => setState(() => _ownerUserId = value),
decoration: const InputDecoration(labelText: 'Ägare (användare)'),
),
const SizedBox(height: 12),
] else ...[
Text(
'Ägare: ${widget.initial!.username} (${widget.initial!.userEmail})',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 12),
],
GestureDetector(
onTap: _pickCategory,
child: InputDecorator(
decoration: const InputDecoration(
labelText: 'Kategori',
border: OutlineInputBorder(),
),
child: Text(
_categoryPath == null || _categoryPath!.trim().isEmpty
? 'Tryck för att välja kategori'
: _categoryPath!,
),
),
),
const SizedBox(height: 12),
Row(
children: [
OutlinedButton.icon(
onPressed: _pickCategory,
icon: const Icon(Icons.category_outlined),
label: const Text('Välj kategori'),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed: () {
setState(() {
_categoryId = null;
_categoryPath = null;
});
},
icon: const Icon(Icons.clear),
label: const Text('Rensa kategori'),
),
],
),
const SizedBox(height: 12),
ProductPickerField(
products: _productOptions(),
value: _productId,
label: 'Produkt',
errorText: _productErrorText,
onChanged: (value) {
setState(() {
_productId = value;
_productErrorText = null;
});
},
),
const SizedBox(height: 12),
TextFormField(
controller: _locationController,
decoration: const InputDecoration(labelText: 'Plats (valfritt)'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Avbryt'),
),
FilledButton(
onPressed: () {
if (_productId == null) {
setState(() => _productErrorText = 'Välj en produkt');
return;
}
if (widget.initial == null && _ownerUserId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Välj användare')),
);
return;
}
Navigator.of(context).pop(
_PantryFormValues(
ownerUserId: _ownerUserId,
productId: _productId!,
location: _locationController.text.trim().isEmpty
? null
: _locationController.text.trim(),
),
);
},
child: const Text('Spara'),
),
],
);
}
} }