diff --git a/backend/src/pantry/dto/create-admin-pantry-item.dto.ts b/backend/src/pantry/dto/create-admin-pantry-item.dto.ts new file mode 100644 index 00000000..a3f745c4 --- /dev/null +++ b/backend/src/pantry/dto/create-admin-pantry-item.dto.ts @@ -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; +} diff --git a/backend/src/pantry/dto/update-pantry-item.dto.ts b/backend/src/pantry/dto/update-pantry-item.dto.ts new file mode 100644 index 00000000..c1758f8d --- /dev/null +++ b/backend/src/pantry/dto/update-pantry-item.dto.ts @@ -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; +} diff --git a/backend/src/pantry/pantry.controller.ts b/backend/src/pantry/pantry.controller.ts index 3e451fb2..3c55d4c5 100644 --- a/backend/src/pantry/pantry.controller.ts +++ b/backend/src/pantry/pantry.controller.ts @@ -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 { CreatePantryItemDto } from './dto/create-pantry-item.dto'; import { CurrentUser } from '../auth/decorators/current-user.decorator'; import { Roles } from '../auth/decorators/roles.decorator'; 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') 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') remove( @CurrentUser() user: { userId: number }, diff --git a/backend/src/pantry/pantry.service.ts b/backend/src/pantry/pantry.service.ts index e3150b88..a7150e2b 100644 --- a/backend/src/pantry/pantry.service.ts +++ b/backend/src/pantry/pantry.service.ts @@ -3,6 +3,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { CreatePantryItemDto } from './dto/create-pantry-item.dto'; import { CreateInventoryDto } from '../inventory/dto/create-inventory.dto'; import { Prisma } from '@prisma/client'; +import { UpdatePantryItemDto } from './dto/update-pantry-item.dto'; type PantryQuery = { userId?: number; @@ -12,11 +13,43 @@ type PantryQuery = { export class PantryService { 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) { return this.prisma.pantryItem.findMany({ where: { userId }, include: { - product: true, + product: this.productWithCategoryInclude, }, orderBy: { product: { name: 'asc' }, @@ -36,17 +69,7 @@ export class PantryService { }, }, product: { - include: { - categoryRef: { - include: { - parent: { - include: { - parent: true, - }, - }, - }, - }, - }, + ...this.productWithCategoryInclude, }, }, orderBy: [ @@ -76,7 +99,87 @@ export class PantryService { productId: data.productId, 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, comment: data.comment?.trim() || undefined, }, - include: { product: true }, + include: { product: this.productWithCategoryInclude }, }); await tx.pantryItem.delete({ where: { id: item.id } }); diff --git a/db/seeds/seed_all.sql b/db/seeds/seed_all.sql index 687a3774..0257882e 100644 --- a/db/seeds/seed_all.sql +++ b/db/seeds/seed_all.sql @@ -544,6 +544,14 @@ INSERT INTO `Category` (`name`, `parentId`) SELECT 'Marmelad', 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 '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 ──────────────────── INSERT INTO `Category` (`name`, `parentId`) diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 01e99a1b..6328162f 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -95,6 +95,8 @@ class PantryApiPaths { static String moveToInventory(int id) => '/pantry/$id/move-to-inventory'; static String moveToInventoryAdmin(int id) => '/pantry/admin/$id/move-to-inventory'; 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'; } diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index e7340fd9..5b3e2646 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -436,6 +436,38 @@ class AdminRepository { Future removeAdminPantryItem(int pantryItemId) => _deleteVoid(PantryApiPaths.adminRemove(pantryItemId)); + Future createAdminPantry({ + int? userId, + required int productId, + String? location, + }) { + return _post( + PantryApiPaths.adminCreate, + body: { + if (userId != null) 'userId': userId, + 'productId': productId, + if (location != null && location.trim().isNotEmpty) + 'location': location.trim(), + }, + parse: (d) => AdminPantryItem.fromJson(Map.from(d as Map)), + ); + } + + Future 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 moveAdminPantryToInventory( int pantryItemId, Map body, diff --git a/flutter/lib/features/admin/domain/admin_inventory_item.dart b/flutter/lib/features/admin/domain/admin_inventory_item.dart index b1995b4b..5176d803 100644 --- a/flutter/lib/features/admin/domain/admin_inventory_item.dart +++ b/flutter/lib/features/admin/domain/admin_inventory_item.dart @@ -6,6 +6,8 @@ class AdminInventoryItem { final int productId; final String productName; final String? productCanonicalName; + final int? categoryId; + final String? categoryPath; final double quantity; final String unit; final String? location; @@ -22,6 +24,8 @@ class AdminInventoryItem { required this.productId, required this.productName, this.productCanonicalName, + this.categoryId, + this.categoryPath, required this.quantity, required this.unit, this.location, @@ -40,6 +44,15 @@ class AdminInventoryItem { factory AdminInventoryItem.fromJson(Map json) { final user = (json['user'] as Map?) ?? const {}; final product = (json['product'] as Map?) ?? const {}; + final names = []; + dynamic current = product['categoryRef']; + while (current is Map) { + final name = current['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + names.insert(0, name); + } + current = current['parent']; + } return AdminInventoryItem( id: json['id'] as int, userId: json['userId'] as int, @@ -48,6 +61,8 @@ class AdminInventoryItem { productId: json['productId'] as int, productName: product['name'] 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, unit: json['unit'] as String? ?? '', location: json['location'] as String?, diff --git a/flutter/lib/features/admin/domain/admin_pantry_item.dart b/flutter/lib/features/admin/domain/admin_pantry_item.dart index fd511846..00ee2bfb 100644 --- a/flutter/lib/features/admin/domain/admin_pantry_item.dart +++ b/flutter/lib/features/admin/domain/admin_pantry_item.dart @@ -6,6 +6,8 @@ class AdminPantryItem { final int productId; final String productName; final String? productCanonicalName; + final int? categoryId; + final String? categoryPath; final String? location; const AdminPantryItem({ @@ -16,6 +18,8 @@ class AdminPantryItem { required this.productId, required this.productName, this.productCanonicalName, + this.categoryId, + this.categoryPath, this.location, }); @@ -28,6 +32,15 @@ class AdminPantryItem { factory AdminPantryItem.fromJson(Map json) { final user = (json['user'] as Map?) ?? const {}; final product = (json['product'] as Map?) ?? const {}; + final names = []; + dynamic current = product['categoryRef']; + while (current is Map) { + final name = current['name']?.toString().trim(); + if (name != null && name.isNotEmpty) { + names.insert(0, name); + } + current = current['parent']; + } return AdminPantryItem( id: (json['id'] as num).toInt(), userId: (json['userId'] as num).toInt(), @@ -36,6 +49,8 @@ class AdminPantryItem { productId: (json['productId'] as num).toInt(), productName: product['name'] as String? ?? '', productCanonicalName: product['canonicalName'] as String?, + categoryId: (product['categoryId'] as num?)?.toInt(), + categoryPath: names.isEmpty ? null : names.join(' > '), location: json['location'] as String?, ); } diff --git a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart index 1ac81b99..6eb03b5b 100644 --- a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart @@ -3,7 +3,10 @@ import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.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 '../domain/admin_category_node.dart'; import '../domain/admin_inventory_item.dart'; import '../domain/admin_product.dart'; import '../domain/user_admin.dart'; @@ -34,6 +37,7 @@ class _AdminInventoryPanelState extends ConsumerState { _InventorySort _sort = _InventorySort.newest; List _items = []; List _products = []; + List _categories = []; List _users = []; @override @@ -55,13 +59,15 @@ class _AdminInventoryPanelState extends ConsumerState { sort: _sortParam, ), ref.read(adminRepositoryProvider).listGlobalProducts(), + ref.read(adminRepositoryProvider).listCategoryTree(), ref.read(adminRepositoryProvider).listUsers(), ]); if (!mounted) return; setState(() { _items = results[0] as List; _products = results[1] as List; - _users = results[2] as List; + _categories = results[2] as List; + _users = results[3] as List; }); } catch (e) { if (!mounted) return; @@ -94,7 +100,8 @@ class _AdminInventoryPanelState extends ConsumerState { return item.displayName.toLowerCase().contains(q) || item.username.toLowerCase().contains(q) || item.userEmail.toLowerCase().contains(q) || - (item.location ?? '').toLowerCase().contains(q); + (item.location ?? '').toLowerCase().contains(q) || + (item.categoryPath ?? '').toLowerCase().contains(q); }).toList(); } @@ -453,6 +460,7 @@ class _AdminInventoryPanelState extends ConsumerState { builder: (context) => _InventoryFormDialog( users: _users, products: _products, + categories: _categories, initial: initial, initialOwnerUserId: initialOwnerUserId, ), @@ -656,7 +664,8 @@ class _AdminInventoryPanelState extends ConsumerState { title: Text(item.displayName), subtitle: Text( '${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( mainAxisSize: MainAxisSize.min, @@ -730,12 +739,14 @@ class _InventoryFormValues { class _InventoryFormDialog extends StatefulWidget { final List users; final List products; + final List categories; final AdminInventoryItem? initial; final int? initialOwnerUserId; const _InventoryFormDialog({ required this.users, required this.products, + required this.categories, this.initial, this.initialOwnerUserId, }); @@ -756,6 +767,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { int? _ownerUserId; int? _productId; + int? _categoryId; + String? _categoryPath; + String? _productErrorText; @override void initState() { @@ -763,6 +777,9 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { final initial = widget.initial; _ownerUserId = initial?.userId ?? widget.initialOwnerUserId; _productId = initial?.productId; + final initialProduct = _productById(_productId); + _categoryId = initialProduct?.categoryId; + _categoryPath = initialProduct?.categoryPath; _quantityController = TextEditingController( text: initial == null ? '' : initial.quantity.toString(), ); @@ -786,6 +803,44 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { 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 _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 _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( @@ -833,20 +888,53 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { ), const SizedBox(height: 12), ], - DropdownButtonFormField( - initialValue: _productId, - items: widget.products - .map((p) => DropdownMenuItem( - value: p.id, - child: Text( - p.displayName, - overflow: TextOverflow.ellipsis, - ), - )) - .toList(), - onChanged: (value) => setState(() => _productId = value), - decoration: const InputDecoration(labelText: 'Produkt'), - validator: (value) => value == null ? 'Välj en produkt' : null, + 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( @@ -905,6 +993,10 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { FilledButton( onPressed: () { if (!_formKey.currentState!.validate()) return; + if (_productId == null) { + setState(() => _productErrorText = 'Välj en produkt'); + return; + } final quantity = double.parse(_quantityController.text.trim().replaceAll(',', '.')); diff --git a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart index c435bc7d..56fe7d37 100644 --- a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart @@ -4,8 +4,12 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/forms/form_options.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 '../domain/admin_category_node.dart'; import '../domain/admin_pantry_item.dart'; +import '../domain/admin_product.dart'; import '../domain/user_admin.dart'; class AdminPantryPanel extends ConsumerStatefulWidget { @@ -20,8 +24,11 @@ class AdminPantryPanel extends ConsumerStatefulWidget { class _AdminPantryPanelState extends ConsumerState { bool _isLoading = true; String? _error; + String _search = ''; int? _selectedUserId; List _items = []; + List _products = []; + List _categories = []; List _users = []; @override @@ -39,12 +46,16 @@ class _AdminPantryPanelState extends ConsumerState { try { final results = await Future.wait([ ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId), + ref.read(adminRepositoryProvider).listGlobalProducts(), + ref.read(adminRepositoryProvider).listCategoryTree(), ref.read(adminRepositoryProvider).listUsers(), ]); if (!mounted) return; setState(() { _items = results[0] as List; - _users = results[1] as List; + _products = results[1] as List; + _categories = results[2] as List; + _users = results[3] as List; }); } catch (e) { if (!mounted) return; @@ -54,6 +65,82 @@ class _AdminPantryPanelState extends ConsumerState { } } + List 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 _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 _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 _moveToInventory(AdminPantryItem item) async { final quantityController = TextEditingController(text: '1'); String selectedUnit = 'st'; @@ -204,6 +291,8 @@ class _AdminPantryPanelState extends ConsumerState { ); } + final filtered = _filtered; + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -216,7 +305,7 @@ class _AdminPantryPanelState extends ConsumerState { Text('Baslager', style: theme.textTheme.titleMedium), const SizedBox(height: 8), 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, ), const SizedBox(height: 8), @@ -225,6 +314,8 @@ class _AdminPantryPanelState extends ConsumerState { runSpacing: 8, children: [ 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('Ta bort')), ], @@ -262,18 +353,36 @@ class _AdminPantryPanelState extends ConsumerState { ), ), 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( onPressed: _load, icon: const Icon(Icons.refresh), 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), + Text('Visar ${filtered.length} av ${_items.length} baslager-poster'), + const SizedBox(height: 8), Expanded( - child: _items.isEmpty + child: filtered.isEmpty ? Card( child: Padding( padding: const EdgeInsets.all(16), @@ -296,18 +405,25 @@ class _AdminPantryPanelState extends ConsumerState { ), ) : ListView.separated( - itemCount: _items.length, + itemCount: filtered.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { - final item = _items[index]; + final item = filtered[index]; return ListTile( title: Text(item.displayName), 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( mainAxisSize: MainAxisSize.min, children: [ + IconButton( + tooltip: 'Ändra', + icon: const Icon(Icons.edit_outlined), + onPressed: () => _editItem(item), + ), IconButton( tooltip: 'Flytta till inventarie', icon: const Icon(Icons.inventory_2_outlined), @@ -338,4 +454,224 @@ class _AdminPantryPanelState extends ConsumerState { ], ); } +} + +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 users; + final List products; + final List 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 _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 _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( + initialValue: _ownerUserId, + items: widget.users + .map((u) => DropdownMenuItem( + 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'), + ), + ], + ); + } } \ No newline at end of file