From 84ccabe2fea390cbd5c369b77587dce8294e231c Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Mon, 11 May 2026 09:06:30 +0200 Subject: [PATCH] feat: Add functionality to move inventory items to pantry and enhance pantry management - Implemented moveInventoryItemToPantry method in InventoryRepository to facilitate moving items from inventory to pantry. - Enhanced InventoryScreen with a new header section providing context about the inventory. - Added a button in SwipeableInventoryTile to move items to pantry with appropriate error handling. - Introduced movePantryItemToInventory method in PantryRepository to support moving items back to inventory. - Refactored PantryScreen to rename _addToInventory to _moveToInventory for clarity and updated UI to reflect changes. - Added AdminPantryItem model to represent pantry items in the admin panel. - Created AdminPantryPanel for managing pantry items, including moving items to inventory and listing users. - Developed AdminPrivateProductsPanel for managing private products, allowing promotion to global products. --- backend/src/inventory/inventory.controller.ts | 35 +- backend/src/inventory/inventory.service.ts | 45 +++ backend/src/pantry/pantry.controller.ts | 37 +- backend/src/pantry/pantry.service.ts | 104 +++++- backend/src/products/products.controller.ts | 15 + backend/src/products/products.service.ts | 72 ++++ flutter/lib/core/api/api_error_mapper.dart | 53 +++ flutter/lib/core/api/api_paths.dart | 9 + .../features/admin/data/admin_repository.dart | 34 ++ .../admin/domain/admin_pantry_item.dart | 42 +++ .../admin/presentation/admin_ai_panel.dart | 52 ++- .../presentation/admin_aliases_panel.dart | 53 ++- .../presentation/admin_database_panel.dart | 155 ++++++-- .../presentation/admin_inventory_panel.dart | 182 ++++++++-- .../presentation/admin_pantry_panel.dart | 341 ++++++++++++++++++ .../admin_pending_products_panel.dart | 60 ++- .../admin_private_products_panel.dart | 156 ++++++++ .../presentation/admin_products_panel.dart | 64 +++- .../admin/presentation/admin_screen.dart | 62 ++-- .../admin/presentation/admin_users_panel.dart | 91 +++-- .../inventory/data/inventory_repository.dart | 4 + .../presentation/inventory_screen.dart | 36 +- .../swipeable_inventory_tile.dart | 20 + .../pantry/data/pantry_repository.dart | 14 + .../pantry/presentation/pantry_screen.dart | 54 ++- .../profile/presentation/profile_screen.dart | 227 ++++++------ .../presentation/user_aliases_screen.dart | 210 +++++++---- 27 files changed, 1851 insertions(+), 376 deletions(-) create mode 100644 flutter/lib/features/admin/domain/admin_pantry_item.dart create mode 100644 flutter/lib/features/admin/presentation/admin_pantry_panel.dart create mode 100644 flutter/lib/features/admin/presentation/admin_private_products_panel.dart diff --git a/backend/src/inventory/inventory.controller.ts b/backend/src/inventory/inventory.controller.ts index fc19dcf1..6d278274 100644 --- a/backend/src/inventory/inventory.controller.ts +++ b/backend/src/inventory/inventory.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -25,15 +26,33 @@ export class InventoryController { @Roles('admin') @Get('admin') findAllAdmin( - @Query('userId', new ParseIntPipe({ optional: true })) userId?: number, + @Query('userId') userIdRaw?: string, @Query('sort') sort?: string, ) { + const userId = this.parseOptionalIntQuery(userIdRaw); return this.inventoryService.findAllAdmin({ userId, sort, }); } + private parseOptionalIntQuery(value: string | undefined): number | undefined { + if (value === undefined || value === null) { + return undefined; + } + + const trimmed = value.trim(); + if (trimmed.length === 0) { + return undefined; + } + + if (!/^\d+$/.test(trimmed)) { + throw new BadRequestException('Validation failed (numeric string is expected)'); + } + + return Number(trimmed); + } + @Roles('admin') @Post('admin') createAdmin( @@ -134,4 +153,18 @@ findConsumptionHistory( ) { return this.inventoryService.remove(id, user.userId); } + + @Post(':id/move-to-pantry') + moveToPantry( + @CurrentUser() user: { userId: number }, + @Param('id', ParseIntPipe) id: number, + ) { + return this.inventoryService.moveToPantry(id, user.userId); + } + + @Roles('admin') + @Post('admin/:id/move-to-pantry') + moveToPantryAdmin(@Param('id', ParseIntPipe) id: number) { + return this.inventoryService.moveToPantryAdmin(id); + } } \ No newline at end of file diff --git a/backend/src/inventory/inventory.service.ts b/backend/src/inventory/inventory.service.ts index 223c1bee..c86048cb 100644 --- a/backend/src/inventory/inventory.service.ts +++ b/backend/src/inventory/inventory.service.ts @@ -348,6 +348,51 @@ export class InventoryService { return this.prisma.inventoryItem.delete({ where: { id } }); } + private async moveInventoryItemToPantryCore(item: { + id: number; + userId: number; + productId: number; + location: string | null; + }) { + const existingPantryItem = await this.prisma.pantryItem.findUnique({ + where: { + userId_productId: { + userId: item.userId, + productId: item.productId, + }, + }, + }); + + if (existingPantryItem) { + throw new BadRequestException('Produkten finns redan i baslagret'); + } + + return this.prisma.$transaction(async (tx) => { + const pantryItem = await tx.pantryItem.create({ + data: { + userId: item.userId, + productId: item.productId, + location: item.location?.trim() || null, + }, + include: { product: true }, + }); + + await tx.inventoryItem.delete({ where: { id: item.id } }); + + return pantryItem; + }); + } + + async moveToPantry(id: number, userId: number) { + const item = await this.findInventoryItemByIdOrThrow(id, userId); + return this.moveInventoryItemToPantryCore(item); + } + + async moveToPantryAdmin(id: number) { + const item = await this.findInventoryItemAnyByIdOrThrow(id); + return this.moveInventoryItemToPantryCore(item); + } + async updateAdmin(id: number, data: UpdateInventoryDto) { await this.findInventoryItemAnyByIdOrThrow(id); diff --git a/backend/src/pantry/pantry.controller.ts b/backend/src/pantry/pantry.controller.ts index 73506ebf..3e451fb2 100644 --- a/backend/src/pantry/pantry.controller.ts +++ b/backend/src/pantry/pantry.controller.ts @@ -1,7 +1,9 @@ -import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; +import { Body, Controller, Delete, Get, Param, ParseIntPipe, 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'; @Controller('pantry') export class PantryController { @@ -20,6 +22,15 @@ export class PantryController { return this.pantryService.create(user.userId, body); } + @Roles('admin') + @Get('admin') + findAllAdmin(@Query('userId') userIdRaw?: string) { + const userId = userIdRaw == null || userIdRaw.trim() === '' ? undefined : Number(userIdRaw); + return this.pantryService.findAllAdmin({ + userId: Number.isFinite(userId as number) ? (userId as number) : undefined, + }); + } + @Delete(':id') remove( @CurrentUser() user: { userId: number }, @@ -27,4 +38,28 @@ export class PantryController { ) { return this.pantryService.remove(user.userId, id); } + + @Roles('admin') + @Delete('admin/:id') + removeAdmin(@Param('id', ParseIntPipe) id: number) { + return this.pantryService.removeAdmin(id); + } + + @Post(':id/move-to-inventory') + moveToInventory( + @CurrentUser() user: { userId: number }, + @Param('id', ParseIntPipe) id: number, + @Body() body: CreateInventoryDto, + ) { + return this.pantryService.moveToInventory(user.userId, id, body); + } + + @Roles('admin') + @Post('admin/:id/move-to-inventory') + moveToInventoryAdmin( + @Param('id', ParseIntPipe) id: number, + @Body() body: CreateInventoryDto, + ) { + return this.pantryService.moveToInventoryAdmin(id, body); + } } diff --git a/backend/src/pantry/pantry.service.ts b/backend/src/pantry/pantry.service.ts index d1bb5102..e3150b88 100644 --- a/backend/src/pantry/pantry.service.ts +++ b/backend/src/pantry/pantry.service.ts @@ -1,6 +1,12 @@ -import { Injectable, ConflictException, NotFoundException } from '@nestjs/common'; +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; 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'; + +type PantryQuery = { + userId?: number; +}; @Injectable() export class PantryService { @@ -18,6 +24,38 @@ export class PantryService { }); } + findAllAdmin(query?: PantryQuery) { + return this.prisma.pantryItem.findMany({ + where: typeof query?.userId === 'number' ? { userId: query.userId } : {}, + include: { + user: { + select: { + id: true, + username: true, + email: true, + }, + }, + product: { + include: { + categoryRef: { + include: { + parent: { + include: { + parent: true, + }, + }, + }, + }, + }, + }, + }, + orderBy: [ + { user: { username: 'asc' } }, + { product: { name: 'asc' } }, + ], + }); + } + async create(userId: number, data: CreatePantryItemDto) { const existing = await this.prisma.pantryItem.findUnique({ where: { @@ -53,4 +91,68 @@ export class PantryService { return this.prisma.pantryItem.delete({ where: { id } }); } + + async removeAdmin(id: number) { + const item = await this.prisma.pantryItem.findUnique({ where: { id } }); + + if (!item) { + throw new NotFoundException(`PantryItem med id ${id} hittades inte`); + } + + return this.prisma.pantryItem.delete({ where: { id } }); + } + + private async movePantryItemToInventoryCore( + item: { id: number; userId: number; productId: number; location: string | null }, + data: CreateInventoryDto, + ) { + return this.prisma.$transaction(async (tx) => { + const inventoryItem = await tx.inventoryItem.create({ + data: { + userId: item.userId, + productId: item.productId, + quantity: new Prisma.Decimal(data.quantity), + unit: data.unit.trim(), + location: data.location?.trim() || item.location || undefined, + purchaseDate: data.purchaseDate ? new Date(data.purchaseDate) : undefined, + bestBeforeDate: data.bestBeforeDate ? new Date(data.bestBeforeDate) : undefined, + brand: data.brand?.trim() || undefined, + origin: data.origin?.trim() || undefined, + receiptName: data.receiptName?.trim() || undefined, + opened: data.opened ?? false, + suitableFor: data.suitableFor?.trim() || undefined, + comment: data.comment?.trim() || undefined, + }, + include: { product: true }, + }); + + await tx.pantryItem.delete({ where: { id: item.id } }); + + return inventoryItem; + }); + } + + async moveToInventory(userId: number, pantryItemId: number, data: CreateInventoryDto) { + const item = await this.prisma.pantryItem.findFirst({ + where: { id: pantryItemId, userId }, + }); + + if (!item) { + throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`); + } + + return this.movePantryItemToInventoryCore(item, data); + } + + async moveToInventoryAdmin(pantryItemId: number, data: CreateInventoryDto) { + const item = await this.prisma.pantryItem.findUnique({ + where: { id: pantryItemId }, + }); + + if (!item) { + throw new NotFoundException(`PantryItem med id ${pantryItemId} hittades inte`); + } + + return this.movePantryItemToInventoryCore(item, data); + } } diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index e850e574..1caac841 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -79,6 +79,12 @@ export class ProductsController { return this.productsService.findPending(); } + @Roles('admin') + @Get('private') + findPrivate() { + return this.productsService.findPrivate(); + } + @Roles('admin') @Post('ai-categorize-bulk') @Throttle({ default: { ttl: 60_000, limit: 5 } }) @@ -202,6 +208,15 @@ export class ProductsController { return this.productsService.update(id, body); } + @Roles('admin') + @Post('private/:id/promote') + promotePrivateToGlobal( + @Param('id', ParseIntPipe) id: number, + @Request() req: { user: { id: number } }, + ) { + return this.productsService.promotePrivateToGlobal(id, req.user.id); + } + @Roles('admin') @Delete(':id/permanent') permanentDelete(@Param('id', ParseIntPipe) id: number) { diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index 425ee570..67538a33 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -435,6 +435,17 @@ export class ProductsService { }); } + async findPrivate() { + return this.prisma.product.findMany({ + where: { isPrivate: true, isActive: true }, + include: { + categoryRef: { include: { parent: true } }, + owner: { select: { id: true, username: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + async createPending(data: CreateProductDto, userId: number) { const name = data.name.trim(); const normalizedName = normalizeName(name); @@ -470,6 +481,67 @@ export class ProductsService { return this.prisma.product.update({ where: { id }, data: { status } }); } + async promotePrivateToGlobal(productId: number, adminUserId: number) { + const source = await this.prisma.product.findUnique({ + where: { id: productId }, + select: { + id: true, + name: true, + canonicalName: true, + normalizedName: true, + categoryId: true, + isPrivate: true, + isActive: true, + ownerId: true, + }, + }); + + if (!source) { + throw new NotFoundException(`Product with id ${productId} not found`); + } + + if (!source.isPrivate) { + throw new ForbiddenException('Endast privata produkter kan promoveras till global produkt'); + } + + const name = (source.canonicalName ?? source.name).trim(); + const normalizedName = normalizeName(name); + + const existingGlobal = await this.prisma.product.findUnique({ + where: { normalizedName }, + }); + + if (existingGlobal && existingGlobal.id !== source.id) { + if (!existingGlobal.isActive) { + return this.prisma.product.update({ + where: { id: existingGlobal.id }, + data: { + isActive: true, + deletedAt: null, + name, + canonicalName: name, + categoryId: source.categoryId ?? existingGlobal.categoryId, + }, + }); + } + + return existingGlobal; + } + + return this.prisma.product.create({ + data: { + name, + normalizedName, + canonicalName: name, + isActive: true, + isPrivate: false, + ownerId: adminUserId, + deletedAt: null, + ...(source.categoryId != null ? { categoryId: source.categoryId } : {}), + }, + }); + } + // ── Privata produkter (användare kan hantera sina egna) ────────────────────── // Hjälpfunktioner för att undvika kodduplicering mellan admin och user-scope diff --git a/flutter/lib/core/api/api_error_mapper.dart b/flutter/lib/core/api/api_error_mapper.dart index 3e949a19..8712ac49 100644 --- a/flutter/lib/core/api/api_error_mapper.dart +++ b/flutter/lib/core/api/api_error_mapper.dart @@ -37,3 +37,56 @@ SnackBar buildCopyableErrorSnackBar(BuildContext context, String message) { ), ); } + +Widget buildCopyableErrorPanel({ + required BuildContext context, + required String message, + required VoidCallback onRetry, + String title = 'Ett fel uppstod', +}) { + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + SelectableText(message), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: Text(context.l10n.retryAction), + ), + OutlinedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: message)); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.errorDialogCopied)), + ); + }, + icon: const Icon(Icons.copy_all), + label: Text(context.l10n.errorDialogCopy), + ), + ], + ), + ], + ), + ), + ), + ), + ); +} diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index 77af8475..943dade7 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -6,6 +6,8 @@ class ProductApiPaths { static const list = '/products'; static const mine = '/products/mine'; static const createPrivate = '/products/private'; + static const privateList = '/products/private'; + static String promotePrivate(int id) => '/products/private/$id/promote'; static const pending = '/products/pending'; static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const deleted = '/products/deleted'; @@ -59,6 +61,8 @@ class InventoryApiPaths { static const list = '/inventory'; static String update(int id) => '/inventory/$id'; static String remove(int id) => '/inventory/$id'; + static String moveToPantry(int id) => '/inventory/$id/move-to-pantry'; + static String moveToPantryAdmin(int id) => '/inventory/admin/$id/move-to-pantry'; static String consume(int id) => '/inventory/$id/consume'; static String consumptionHistory(int id) => '/inventory/$id/consumption-history'; } @@ -77,6 +81,7 @@ class AdminInventoryApiPaths { } static String update(int id) => '/inventory/admin/$id'; static String remove(int id) => '/inventory/admin/$id'; + static String moveToPantry(int id) => '/inventory/admin/$id/move-to-pantry'; static const merge = '/inventory/admin/merge'; static String mergePreview(int sourceInventoryId, int targetInventoryId) => '/inventory/admin/merge-preview?sourceInventoryId=$sourceInventoryId&targetInventoryId=$targetInventoryId'; @@ -85,6 +90,10 @@ class AdminInventoryApiPaths { class PantryApiPaths { static const list = '/pantry'; static String remove(int id) => '/pantry/$id'; + 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 String adminRemove(int id) => '/pantry/admin/$id'; } class UserApiPaths { diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index f1f8140f..9db73de5 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -6,6 +6,7 @@ import '../../../core/api/guarded_api_call.dart'; import '../../auth/data/auth_providers.dart'; import '../domain/admin_ai_categorize_result.dart'; import '../domain/admin_category_node.dart'; +import '../domain/admin_pantry_item.dart'; import '../domain/admin_inventory_item.dart'; import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; @@ -177,6 +178,9 @@ class AdminRepository { Future> listGlobalProducts() => _getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false); + Future> listPrivateProducts() => + _getList(ProductApiPaths.privateList, PendingProduct.fromJson); + Future> listDeletedProducts() => _getList(ProductApiPaths.deleted, AdminProduct.fromJson); @@ -186,6 +190,13 @@ class AdminRepository { Future setProductStatus(int productId, String status) => _patchVoid(ProductApiPaths.setStatus(productId), {'status': status}); + Future promotePrivateProduct(int productId) => + _post( + ProductApiPaths.promotePrivate(productId), + body: null, + parse: (d) => AdminProduct.fromJson(Map.from(d as Map)), + ); + Future setProductCategory(int productId, {required int? categoryId}) => _patchVoid(ProductApiPaths.update(productId), {'categoryId': categoryId}); @@ -388,6 +399,29 @@ class AdminRepository { Future removeAdminInventory(int inventoryId) => _deleteVoid(AdminInventoryApiPaths.remove(inventoryId)); + Future moveAdminInventoryToPantry(int inventoryId) => + _postVoid(AdminInventoryApiPaths.moveToPantry(inventoryId)); + + // ── Admin pantry ────────────────────────────────────────────────────────── + + Future> listAdminPantry({int? userId}) { + final params = {}; + if (userId != null) params['userId'] = '$userId'; + final path = params.isEmpty + ? PantryApiPaths.adminList + : '${PantryApiPaths.adminList}?${params.entries.map((e) => '${Uri.encodeQueryComponent(e.key)}=${Uri.encodeQueryComponent(e.value)}').join('&')}'; + return _getList(path, AdminPantryItem.fromJson); + } + + Future removeAdminPantryItem(int pantryItemId) => + _deleteVoid(PantryApiPaths.adminRemove(pantryItemId)); + + Future moveAdminPantryToInventory( + int pantryItemId, + Map body, + ) => + _postVoid(PantryApiPaths.moveToInventoryAdmin(pantryItemId), body); + Future mergeAdminInventory({ required int sourceInventoryId, required int targetInventoryId, diff --git a/flutter/lib/features/admin/domain/admin_pantry_item.dart b/flutter/lib/features/admin/domain/admin_pantry_item.dart new file mode 100644 index 00000000..fd511846 --- /dev/null +++ b/flutter/lib/features/admin/domain/admin_pantry_item.dart @@ -0,0 +1,42 @@ +class AdminPantryItem { + final int id; + final int userId; + final String username; + final String userEmail; + final int productId; + final String productName; + final String? productCanonicalName; + final String? location; + + const AdminPantryItem({ + required this.id, + required this.userId, + required this.username, + required this.userEmail, + required this.productId, + required this.productName, + this.productCanonicalName, + this.location, + }); + + String get displayName { + final canonical = productCanonicalName?.trim(); + if (canonical != null && canonical.isNotEmpty) return canonical; + return productName; + } + + factory AdminPantryItem.fromJson(Map json) { + final user = (json['user'] as Map?) ?? const {}; + final product = (json['product'] as Map?) ?? const {}; + return AdminPantryItem( + id: (json['id'] as num).toInt(), + userId: (json['userId'] as num).toInt(), + username: user['username'] as String? ?? '', + userEmail: user['email'] as String? ?? '', + productId: (json['productId'] as num).toInt(), + productName: product['name'] as String? ?? '', + productCanonicalName: product['canonicalName'] as String?, + location: json['location'] as String?, + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_ai_panel.dart b/flutter/lib/features/admin/presentation/admin_ai_panel.dart index bfad21ad..149c34fe 100644 --- a/flutter/lib/features/admin/presentation/admin_ai_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_ai_panel.dart @@ -55,26 +55,54 @@ class _AdminAiPanelState extends ConsumerState { final theme = Theme.of(context); 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: Text(context.l10n.retryAction)), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa AI-modeller', ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.l10n.adminAiDescription, - style: theme.textTheme.bodyMedium, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('AI', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + context.l10n.adminAiDescription, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Models')), + Chip(label: Text('Access')), + Chip(label: Text('Trigger')), + ], + ), + ], + ), + ), ), const SizedBox(height: 12), + if (_models.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Inga AI-modeller hittades.', + style: theme.textTheme.bodyMedium, + ), + ), + ), ..._models.map( (model) => Card( child: Padding( diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart index 0e98f9dd..f9261a7d 100644 --- a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -147,15 +147,11 @@ class _AdminAliasesPanelState extends ConsumerState { } 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')), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa alias', ); } @@ -206,9 +202,31 @@ class _AdminAliasesPanelState extends ConsumerState { final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.', - style: theme.textTheme.bodyMedium, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Alias', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Globala alias används som fallback i kvittoimporten. När samma kvittonamn upprepas kan rätt produkt matchas direkt.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Fallback')), + Chip(label: Text('Global')), + Chip(label: Text('Receipt import')), + ], + ), + ], + ), + ), ), const SizedBox(height: 12), Row( @@ -267,7 +285,16 @@ class _AdminAliasesPanelState extends ConsumerState { onChanged: (value) => setState(() => _search = value), ), const SizedBox(height: 12), - if (filteredAliases.isEmpty) const Text('Inga alias hittades.'), + if (filteredAliases.isEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + 'Inga alias hittades.', + style: theme.textTheme.bodyMedium, + ), + ), + ), ], ); diff --git a/flutter/lib/features/admin/presentation/admin_database_panel.dart b/flutter/lib/features/admin/presentation/admin_database_panel.dart index 874d481a..23902fd4 100644 --- a/flutter/lib/features/admin/presentation/admin_database_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_database_panel.dart @@ -4,10 +4,16 @@ import 'package:go_router/go_router.dart'; import '../../../core/api/api_error_mapper.dart'; import '../../../core/l10n/l10n.dart'; -import '../../profile/data/profile_repository.dart'; +import 'admin_ai_panel.dart'; +import 'admin_aliases_panel.dart'; +import 'admin_inventory_panel.dart'; +import 'admin_pantry_panel.dart'; +import 'admin_private_products_panel.dart'; +import 'admin_pending_products_panel.dart'; import 'admin_products_panel.dart'; +import '../../profile/data/profile_repository.dart'; -enum _DatabaseTab { inventory, pantry, products } +enum _DatabaseTab { inventory, pantry, products, privateProducts, pending, aliases, ai } class AdminDatabasePanel extends ConsumerStatefulWidget { final bool embedded; @@ -76,49 +82,124 @@ class _AdminDatabasePanelState extends ConsumerState { ); } + Widget _panelShell({ + required String title, + required String description, + required Widget child, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + child, + ], + ); + } + @override Widget build(BuildContext context) { + final theme = Theme.of(context); String tabLabel(_DatabaseTab tab) { - switch (tab) { - case _DatabaseTab.inventory: - return context.l10n.profileInventoryTab; - case _DatabaseTab.pantry: - return context.l10n.profilePantryTab; - case _DatabaseTab.products: - return context.l10n.profileProductsTab; - } + return switch (tab) { + _DatabaseTab.inventory => context.l10n.profileInventoryTab, + _DatabaseTab.pantry => context.l10n.profilePantryTab, + _DatabaseTab.products => context.l10n.profileProductsTab, + _DatabaseTab.privateProducts => 'Privata produkter', + _DatabaseTab.pending => context.l10n.profilePendingTab, + _DatabaseTab.aliases => 'Alias', + _DatabaseTab.ai => 'AI', + }; } Widget activeSection; switch (_activeTab) { case _DatabaseTab.inventory: - activeSection = _sectionCard( - icon: Icons.inventory_2_outlined, + activeSection = _panelShell( title: context.l10n.profileInventoryTab, - description: context.l10n.profileInventoryDescription, - onPressed: () => context.go('/inventory'), - buttonLabel: context.l10n.profileOpenInventory, + description: 'Granska, filtrera och redigera inventory-poster. Välj användare för att arbeta på en specifik ägares data.', + child: const AdminInventoryPanel(embedded: true), ); case _DatabaseTab.pantry: - activeSection = _sectionCard( - icon: Icons.storefront_outlined, + activeSection = _panelShell( title: context.l10n.profilePantryTab, - description: context.l10n.profilePantryDescription, - onPressed: () => context.go('/baslager'), - buttonLabel: context.l10n.profileOpenPantry, + description: 'Granska och redigera användarnas baslager. Flytta poster till inventarie eller ta bort dem vid behov.', + child: const AdminPantryPanel(embedded: true), ); case _DatabaseTab.products: - activeSection = const AdminProductsPanel(embedded: true); + activeSection = _panelShell( + title: context.l10n.profileProductsTab, + description: 'Hantera globala produkter: kategorisering, restaurering, merge och AI-stöd.', + child: const AdminProductsPanel(embedded: true), + ); + case _DatabaseTab.privateProducts: + activeSection = _panelShell( + title: 'Privata produkter', + description: 'Promotera privata produkter till den globala produkt-tabellen.', + child: const AdminPrivateProductsPanel(embedded: true), + ); + case _DatabaseTab.pending: + activeSection = _panelShell( + title: context.l10n.profilePendingTab, + description: 'Godkänn eller avslå nya produkter som föreslagits av användare.', + child: const AdminPendingProductsPanel(embedded: true), + ); + case _DatabaseTab.aliases: + activeSection = _panelShell( + title: 'Alias', + description: 'Hantera globala alias som används i receipt-importens första matchningssteg.', + child: const AdminAliasesPanel(embedded: true), + ); + case _DatabaseTab.ai: + activeSection = _panelShell( + title: 'AI', + description: 'Se vilka AI-modeller som används och hur de är exponerade i systemet.', + child: const AdminAiPanel(embedded: true), + ); } - return SingleChildScrollView( - padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16), + final header = SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.l10n.profileDatabaseDescription, - style: Theme.of(context).textTheme.bodyMedium, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Databas', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Arbetsyta för data med tydlig scope: inventory och baslager är användarspecifika, produkter är globala eller privata och alias styr importmatchning.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-scope')), + Chip(label: Text('Global scope')), + Chip(label: Text('Private products')), + Chip(label: Text('Alias')), + ], + ), + ], + ), + ), ), const SizedBox(height: 12), Card( @@ -127,15 +208,9 @@ class _AdminDatabasePanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - 'Adminverktyg', - style: Theme.of(context).textTheme.titleMedium, - ), + Text('Adminverktyg', style: theme.textTheme.titleMedium), const SizedBox(height: 8), - Text( - 'Uppdatera kategorier manuellt i backend-cachen.', - style: Theme.of(context).textTheme.bodyMedium, - ), + Text('Uppdatera kategorier manuellt i backend-cachen.', style: theme.textTheme.bodyMedium), const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -173,8 +248,18 @@ class _AdminDatabasePanelState extends ConsumerState { .toList(), ), ), + ], + ), + ); + + return Padding( + padding: widget.embedded ? EdgeInsets.zero : const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 2, child: header), const SizedBox(height: 16), - activeSection, + Expanded(flex: 5, child: activeSection), ], ), ); diff --git a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart index be8729de..1ac81b99 100644 --- a/flutter/lib/features/admin/presentation/admin_inventory_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_inventory_panel.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/api/api_error_mapper.dart'; @@ -278,7 +279,13 @@ class _AdminInventoryPanelState extends ConsumerState { width: 460, child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Välj två poster för samma användare, produkt och enhet. Source tas bort och target behålls.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), DropdownButtonFormField( initialValue: sourceId, items: _items @@ -454,12 +461,58 @@ class _AdminInventoryPanelState extends ConsumerState { @override Widget build(BuildContext context) { + final theme = Theme.of(context); if (_isLoading) { return const Center(child: CircularProgressIndicator()); } if (_error != null) { - return Center(child: Text(_error!)); + final message = _error!; + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 720), + child: Card( + margin: const EdgeInsets.all(16), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Kunde inte läsa inventory-data', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + SelectableText(message), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Försök igen'), + ), + OutlinedButton.icon( + onPressed: () { + Clipboard.setData(ClipboardData(text: message)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Felmeddelande kopierat.')), + ); + }, + icon: const Icon(Icons.copy_all), + label: const Text('Kopiera fel'), + ), + ], + ), + ], + ), + ), + ), + ), + ); } final filtered = _filtered; @@ -467,6 +520,33 @@ class _AdminInventoryPanelState extends ConsumerState { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Inventory', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Här arbetar du på användarnas inventory-poster. Du kan filtrera per användare, justera mängder, flytta poster till baslager och slå ihop duplicerade rader.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-scope')), + Chip(label: Text('Merge')), + Chip(label: Text('Flytta till baslager')), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), Row( children: [ SizedBox( @@ -551,35 +631,71 @@ class _AdminInventoryPanelState extends ConsumerState { const SizedBox(height: 8), Expanded( child: Card( - child: ListView.separated( - itemCount: filtered.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) { - final item = filtered[index]; - return ListTile( - title: Text(item.displayName), - subtitle: Text( - '${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})' - '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}', + child: filtered.isEmpty + ? Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('Inventory', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Inga inventory-poster hittades med nuvarande filter.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ) + : ListView.separated( + itemCount: filtered.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = filtered[index]; + return ListTile( + title: Text(item.displayName), + subtitle: Text( + '${item.quantity} ${item.unit} · ${item.username} (${item.userEmail})' + '${item.location == null || item.location!.isEmpty ? '' : ' · ${item.location}'}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Flytta till baslager', + onPressed: () async { + try { + await ref.read(adminRepositoryProvider).moveAdminInventoryToPantry(item.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Flyttade "${item.displayName}" till baslager.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + }, + icon: const Icon(Icons.storefront_outlined), + ), + IconButton( + tooltip: 'Ändra', + onPressed: () => _editItem(item), + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + tooltip: 'Ta bort', + onPressed: () => _deleteItem(item), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ); + }, ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - tooltip: 'Ändra', - onPressed: () => _editItem(item), - icon: const Icon(Icons.edit_outlined), - ), - IconButton( - tooltip: 'Ta bort', - onPressed: () => _deleteItem(item), - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ); - }, - ), ), ), ], @@ -681,7 +797,15 @@ class _InventoryFormDialogState extends State<_InventoryFormDialog> { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + widget.initial == null + ? 'Skapa en ny inventory-rad för en användare. Välj produkt, mängd, enhet och valfria metadata.' + : 'Ändra den valda inventory-raden. Produkt, mängd, enhet och metadata kan justeras utan att byta ägare.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), if (widget.initial == null) ...[ DropdownButtonFormField( initialValue: _ownerUserId, diff --git a/flutter/lib/features/admin/presentation/admin_pantry_panel.dart b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart new file mode 100644 index 00000000..c435bc7d --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_pantry_panel.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +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 '../data/admin_repository.dart'; +import '../domain/admin_pantry_item.dart'; +import '../domain/user_admin.dart'; + +class AdminPantryPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminPantryPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _AdminPantryPanelState(); +} + +class _AdminPantryPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + int? _selectedUserId; + List _items = []; + List _users = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final results = await Future.wait([ + ref.read(adminRepositoryProvider).listAdminPantry(userId: _selectedUserId), + ref.read(adminRepositoryProvider).listUsers(), + ]); + if (!mounted) return; + setState(() { + _items = results[0] as List; + _users = results[1] as List; + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _moveToInventory(AdminPantryItem item) async { + final quantityController = TextEditingController(text: '1'); + String selectedUnit = 'st'; + String? selectedLocation; + String? formError; + + final payload = await showDialog>( + context: context, + builder: (ctx) { + return StatefulBuilder( + builder: (ctx, setDialogState) { + return AlertDialog( + title: Text(context.l10n.pantryAddToInventoryTitle(item.displayName)), + content: SizedBox( + width: 380, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: quantityController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: InputDecoration( + labelText: context.l10n.inventoryQuantityDisplayLabel, + border: const OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedUnit, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.unitLabel, + border: const OutlineInputBorder(), + ), + items: unitOptions + .map((option) => DropdownMenuItem( + value: option.value, + child: Text(option.label), + )) + .toList(), + onChanged: (value) { + if (value == null) return; + setDialogState(() => selectedUnit = value); + }, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: selectedLocation, + isExpanded: true, + decoration: InputDecoration( + labelText: context.l10n.locationOptionalLabel, + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: null, + child: Text(context.l10n.pantryNoLocation), + ), + ...inventoryLocationOptions.map( + (location) => DropdownMenuItem( + value: location, + child: Text(location), + ), + ), + ], + onChanged: (value) { + setDialogState(() => selectedLocation = value); + }, + ), + if (formError != null) ...[ + const SizedBox(height: 8), + Text( + formError!, + style: TextStyle(color: Theme.of(ctx).colorScheme.error), + ), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: Text(context.l10n.cancelAction), + ), + FilledButton( + onPressed: () { + final quantity = double.tryParse( + quantityController.text.trim().replaceAll(',', '.'), + ); + if (quantity == null || quantity <= 0) { + setDialogState(() { + formError = context.l10n.pantryInvalidQuantity; + }); + return; + } + Navigator.pop(ctx, { + 'quantity': quantity, + 'unit': selectedUnit, + 'location': selectedLocation, + }); + }, + child: Text(context.l10n.addAction), + ), + ], + ); + }, + ); + }, + ); + + quantityController.dispose(); + if (payload == null) return; + + try { + await ref.read(adminRepositoryProvider).moveAdminPantryToInventory( + item.id, + { + 'productId': item.productId, + 'quantity': payload['quantity'] as double, + 'unit': payload['unit'] as String, + if (payload['location'] != null) 'location': payload['location'] as String, + }, + ); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa admin pantry', + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-scope')), + Chip(label: Text('Flytta till inventarie')), + Chip(label: Text('Ta bort')), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: DropdownButtonFormField( + initialValue: _selectedUserId, + decoration: const InputDecoration(labelText: 'Filtrera användare'), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Alla användare'), + ), + ..._users.map( + (user) => DropdownMenuItem( + value: user.id, + child: Text('${user.displayName} (${user.username})'), + ), + ), + ], + onChanged: (value) { + setState(() => _selectedUserId = value); + _load(); + }, + ), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Uppdatera'), + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Expanded( + child: _items.isEmpty + ? Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Inga pantry-poster hittades.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ) + : ListView.separated( + itemCount: _items.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final item = _items[index]; + return ListTile( + title: Text(item.displayName), + subtitle: Text( + '${item.username} (${item.userEmail})${item.location == null || item.location!.trim().isEmpty ? '' : ' · ${item.location}'}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Flytta till inventarie', + icon: const Icon(Icons.inventory_2_outlined), + onPressed: () => _moveToInventory(item), + ), + IconButton( + tooltip: 'Ta bort', + icon: const Icon(Icons.delete_outline, color: Colors.red), + onPressed: () async { + try { + await ref.read(adminRepositoryProvider).removeAdminPantryItem(item.id); + if (!mounted) return; + await _load(); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + }, + ), + ], + ), + ); + }, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart index 2fe95861..f3c183ef 100644 --- a/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_pending_products_panel.dart @@ -69,24 +69,32 @@ class _AdminPendingProductsPanelState final theme = Theme.of(context); 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: Text(context.l10n.retryAction)), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Pending produkter', ); } if (_products.isEmpty) { return Card( child: Padding( padding: const EdgeInsets.all(16), - child: Text( - context.l10n.adminNoPendingProducts, - style: theme.textTheme.bodyMedium, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Pending produkter', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Detta är användarsubmittade produkter som väntar på att godkännas eller avslås innan de blir en del av den globala produkt-tabellen.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + context.l10n.adminNoPendingProducts, + style: theme.textTheme.bodyMedium, + ), + ], ), ), ); @@ -145,9 +153,31 @@ class _AdminPendingProductsPanelState child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - context.l10n.adminPendingDescription, - style: theme.textTheme.bodyMedium, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Pending produkter', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Detta är användarsubmittade produkter som väntar på att godkännas eller avslås innan de blir en del av den globala produkt-tabellen.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-suggested')), + Chip(label: Text('Approve/Reject')), + Chip(label: Text('Global promotion')), + ], + ), + ], + ), + ), ), const SizedBox(height: 12), content, diff --git a/flutter/lib/features/admin/presentation/admin_private_products_panel.dart b/flutter/lib/features/admin/presentation/admin_private_products_panel.dart new file mode 100644 index 00000000..fd99cd1c --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_private_products_panel.dart @@ -0,0 +1,156 @@ +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/pending_product.dart'; + +class AdminPrivateProductsPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminPrivateProductsPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => + _AdminPrivateProductsPanelState(); +} + +class _AdminPrivateProductsPanelState extends ConsumerState { + bool _isLoading = true; + String? _error; + List _products = []; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final products = await ref.read(adminRepositoryProvider).listPrivateProducts(); + if (!mounted) return; + setState(() => _products = products); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _promote(PendingProduct product) async { + try { + await ref.read(adminRepositoryProvider).promotePrivateProduct(product.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Promoterade "${product.displayName}" till global produkt.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + if (_isLoading) return const Center(child: CircularProgressIndicator()); + if (_error != null) { + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa privata produkter', + ); + } + + if (_products.isEmpty) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Privata produkter', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Privata produkter är användarägda poster som kan lyftas upp till globala produkter när de ska återanvändas brett.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Text( + 'Inga aktiva privata produkter hittades.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ); + } + + final list = ListView.separated( + shrinkWrap: false, + physics: null, + itemCount: _products.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final product = _products[index]; + return Card( + child: ListTile( + leading: const Icon(Icons.publish_outlined), + title: Text(product.displayName), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (product.categoryPath != null) + Text('Kategori: ${product.categoryPath}'), + Text('Ägare: ${product.ownerUsername ?? '—'}'), + Text('Skapad: ${product.createdAt == null ? '—' : MaterialLocalizations.of(context).formatShortDate(product.createdAt!)}'), + ], + ), + trailing: FilledButton( + onPressed: () => _promote(product), + child: const Text('Promote'), + ), + ), + ); + }, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Privata produkter', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Privata produkter kan promoveras till globala produkter utan att användarens privata kopia försvinner.', + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + ), + const SizedBox(height: 12), + Expanded(child: list), + ], + ); + } +} \ No newline at end of file diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index f1661ef8..ae76a831 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -199,7 +199,10 @@ class _AdminProductsPanelState extends ConsumerState { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(context.l10n.adminMergeProductsHint), + Text( + 'Välj vilken produkt som ska behållas som mål. Källprodukten slås ihop i målet och relaterad inventarie flyttas med.', + style: Theme.of(context).textTheme.bodyMedium, + ), const SizedBox(height: 12), SegmentedButton( segments: [ @@ -330,6 +333,11 @@ class _AdminProductsPanelState extends ConsumerState { children: [ Text('Produkt-ID: ${product.id}'), const SizedBox(height: 12), + Text( + 'Sätt ett kanoniskt namn som ska användas i gränssnitt och vid sammanslagning. Det ändrar inte produkt-ID.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), TextField( controller: controller, autofocus: true, @@ -491,7 +499,7 @@ class _AdminProductsPanelState extends ConsumerState { void _showError(Object e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(mapErrorToUserMessage(e, context))), + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), ); } @@ -530,21 +538,45 @@ class _AdminProductsPanelState extends ConsumerState { 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: Text(context.l10n.retryAction)), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Globala produkter', ); } final content = Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Globala produkter', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Detta är den globala produkt-tabellen. Här ändrar du kategorier, sammanslår poster, återställer borttagna produkter och styr AI-assisterad kategorisering.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + Chip(label: Text('Global')), + Chip(label: Text('CRUD')), + Chip(label: Text('Merge')), + Chip(label: Text('AI-kategorisering')), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), TextField( decoration: InputDecoration( labelText: context.l10n.adminSearchProduct, @@ -665,7 +697,15 @@ class _AdminProductsPanelState extends ConsumerState { ), const SizedBox(height: 16), if (filtered.isEmpty) - Text(context.l10n.adminNoProductsFound) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text( + context.l10n.adminNoProductsFound, + style: theme.textTheme.bodyMedium, + ), + ), + ) else ...filtered.map( (product) => Card( diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index c11bb0fe..e3b2e221 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -1,12 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../core/l10n/l10n.dart'; -import 'admin_ai_panel.dart'; -import 'admin_aliases_panel.dart'; import 'admin_database_panel.dart'; -import 'admin_inventory_panel.dart'; -import 'admin_pending_products_panel.dart'; -import 'admin_products_panel.dart'; import 'admin_users_panel.dart'; class AdminScreen extends ConsumerStatefulWidget { @@ -19,10 +14,40 @@ class AdminScreen extends ConsumerStatefulWidget { class _AdminScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final theme = Theme.of(context); return DefaultTabController( - length: 7, + length: 2, child: Column( children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Admin', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Användare är för konton och roller. Databas är arbetsytan för inventarie, baslager, produkter, alias och importflöden.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Konton')), + Chip(label: Text('Databas')), + Chip(label: Text('Privat + globalt')), + ], + ), + ], + ), + ), + ), + ), Material( color: Theme.of(context).colorScheme.surface, child: TabBar( @@ -30,11 +55,6 @@ class _AdminScreenState extends ConsumerState { tabs: [ Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)), const Tab(text: 'Databas', icon: Icon(Icons.storage_outlined)), - const Tab(text: 'Inventory', icon: Icon(Icons.inventory_outlined)), - const Tab(text: 'Produkter', icon: Icon(Icons.inventory_2_outlined)), - Tab(text: context.l10n.profilePendingTab, icon: const Icon(Icons.pending_actions_outlined)), - const Tab(text: 'Alias', icon: Icon(Icons.link_outlined)), - const Tab(text: 'AI', icon: Icon(Icons.auto_awesome_outlined)), ], ), ), @@ -49,26 +69,6 @@ class _AdminScreenState extends ConsumerState { padding: EdgeInsets.fromLTRB(12, 12, 12, 8), child: AdminDatabasePanel(embedded: true), ), - Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), - child: AdminInventoryPanel(embedded: true), - ), - Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), - child: AdminProductsPanel(embedded: true), - ), - Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), - child: AdminPendingProductsPanel(embedded: true), - ), - Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), - child: AdminAliasesPanel(embedded: true), - ), - Padding( - padding: EdgeInsets.fromLTRB(12, 12, 12, 8), - child: AdminAiPanel(embedded: true), - ), ], ), ), diff --git a/flutter/lib/features/admin/presentation/admin_users_panel.dart b/flutter/lib/features/admin/presentation/admin_users_panel.dart index b039e138..f1c4eadc 100644 --- a/flutter/lib/features/admin/presentation/admin_users_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_users_panel.dart @@ -254,10 +254,7 @@ class _AdminUsersPanelState extends ConsumerState { void _showError(Object e) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(mapErrorToUserMessage(e, context)), - backgroundColor: Theme.of(context).colorScheme.error, - ), + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), ); } @@ -299,15 +296,11 @@ class _AdminUsersPanelState extends ConsumerState { 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: Text(context.l10n.retryAction)), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa användare', ); } if (_users.isEmpty) { @@ -315,14 +308,36 @@ class _AdminUsersPanelState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ if (widget.embedded) ...[ - FilledButton.icon( - onPressed: _createUser, - icon: const Icon(Icons.person_add_outlined), - label: Text(context.l10n.adminNewUser), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Användarkonton', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Här styr du konton, roller, premium och delning. När listan är tom kan du skapa den första användaren direkt.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _createUser, + icon: const Icon(Icons.person_add_outlined), + label: Text(context.l10n.adminNewUser), + ), + ], + ), + ), ), const SizedBox(height: 16), ], - Text(context.l10n.adminNoUsers), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Text(context.l10n.adminNoUsers), + ), + ), ], ); } @@ -354,14 +369,36 @@ class _AdminUsersPanelState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Användarkonton', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + context.l10n.adminUsersDescription, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Roller')), + Chip(label: Text('Premium')), + Chip(label: Text('Delning')), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 12), Row( children: [ - Expanded( - child: Text( - context.l10n.adminUsersDescription, - style: theme.textTheme.bodyMedium, - ), - ), + const Spacer(), IconButton( icon: const Icon(Icons.refresh), tooltip: 'Uppdatera', @@ -561,7 +598,13 @@ class _CreateUserDialogState extends State<_CreateUserDialog> { child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Skapa ett nytt konto med tydlig roll direkt från adminvyn. Du väljer bara uppgifter som krävs för inloggning och åtkomst.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), TextFormField( controller: _usernameCtrl, decoration: InputDecoration(labelText: context.l10n.profileUsernameLabel), diff --git a/flutter/lib/features/inventory/data/inventory_repository.dart b/flutter/lib/features/inventory/data/inventory_repository.dart index 2b6b2a58..b861f981 100644 --- a/flutter/lib/features/inventory/data/inventory_repository.dart +++ b/flutter/lib/features/inventory/data/inventory_repository.dart @@ -52,6 +52,10 @@ class InventoryRepository { await _api.deleteJson(InventoryApiPaths.remove(id), token: token); } + Future moveInventoryItemToPantry(int id, {String? token}) async { + await _api.postJson(InventoryApiPaths.moveToPantry(id), body: null, token: token); + } + Future consumeInventoryItem( int id, { required double amountUsed, diff --git a/flutter/lib/features/inventory/presentation/inventory_screen.dart b/flutter/lib/features/inventory/presentation/inventory_screen.dart index 7d3705ea..6b0eb764 100644 --- a/flutter/lib/features/inventory/presentation/inventory_screen.dart +++ b/flutter/lib/features/inventory/presentation/inventory_screen.dart @@ -95,6 +95,36 @@ class InventoryScreen extends ConsumerWidget { ), ); + final headerSection = Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(context.l10n.profileInventoryTab, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Din personliga inventarie. Här ser du sådant du faktiskt äger, kan sortera på plats och bäst före, och flytta vidare till recept eller baslager.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-scope')), + Chip(label: Text('Bäst före')), + Chip(label: Text('Swipa för +/-')), + ], + ), + ], + ), + ), + ), + ); + if (visibleItems.isEmpty) { return Stack( children: [ @@ -102,6 +132,7 @@ class InventoryScreen extends ConsumerWidget { key: const PageStorageKey('inventory-empty-list'), padding: const EdgeInsets.only(bottom: 88), children: [ + headerSection, filterSection, EmptyStateView(title: context.l10n.inventoryEmpty), ], @@ -124,11 +155,12 @@ class InventoryScreen extends ConsumerWidget { ListView.separated( key: const PageStorageKey('inventory-main-list'), padding: const EdgeInsets.only(bottom: 88), - itemCount: visibleItems.length + 1, + itemCount: visibleItems.length + 2, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) return filterSection; - final item = visibleItems[index - 1]; + if (index == 1) return headerSection; + final item = visibleItems[index - 2]; return SwipeableInventoryTile(item: item); }, ), diff --git a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart index 278d45dc..6c2ea955 100644 --- a/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart +++ b/flutter/lib/features/inventory/presentation/swipeable_inventory_tile.dart @@ -328,6 +328,26 @@ class _TrailingActions extends ConsumerWidget { onPressed: () => context.push('/inventory/${item.id}/edit'), ), ), + Tooltip( + message: 'Flytta till baslager', + child: IconButton( + icon: const Icon(Icons.storefront_outlined), + onPressed: () async { + try { + final token = await ref.read(authStateProvider.future); + await ref.read(inventoryRepositoryProvider).moveInventoryItemToPantry( + item.id, + token: token, + ); + ref.invalidate(inventoryProvider); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + buildCopyableErrorSnackBar(context, mapErrorToUserMessage(e, context)), + ); + } + }, + ), + ), _DeleteButton(item: item), ], ); diff --git a/flutter/lib/features/pantry/data/pantry_repository.dart b/flutter/lib/features/pantry/data/pantry_repository.dart index 6f0b2848..29da414c 100644 --- a/flutter/lib/features/pantry/data/pantry_repository.dart +++ b/flutter/lib/features/pantry/data/pantry_repository.dart @@ -71,4 +71,18 @@ class PantryRepository { rethrow; } } + + Future movePantryItemToInventory( + int id, { + required Map body, + String? token, + }) async { + try { + await _api.postJson(PantryApiPaths.moveToInventory(id), body: body, token: token); + _logger.info('Moved pantry item with ID: $id to inventory'); + } catch (error) { + _logger.severe('Failed to move pantry item to inventory: $error'); + rethrow; + } + } } \ No newline at end of file diff --git a/flutter/lib/features/pantry/presentation/pantry_screen.dart b/flutter/lib/features/pantry/presentation/pantry_screen.dart index 3e9b87e4..2f68f540 100644 --- a/flutter/lib/features/pantry/presentation/pantry_screen.dart +++ b/flutter/lib/features/pantry/presentation/pantry_screen.dart @@ -40,7 +40,7 @@ class _PantryScreenState extends ConsumerState { _logger.info('Initializing PantryScreen'); } - Future _addToInventory(PantryItem item) async { + Future _moveToInventory(PantryItem item) async { final quantityController = TextEditingController(text: '1'); String selectedUnit = 'st'; String? selectedLocation; @@ -155,8 +155,9 @@ class _PantryScreenState extends ConsumerState { try { final token = await ref.read(authStateProvider.future); - await ref.read(inventoryRepositoryProvider).createInventoryItem( - { + await ref.read(pantryRepositoryProvider).movePantryItemToInventory( + item.id, + body: { 'productId': item.productId, 'quantity': payload['quantity'] as double, 'unit': payload['unit'] as String, @@ -164,10 +165,11 @@ class _PantryScreenState extends ConsumerState { }, token: token, ); + ref.invalidate(pantryProvider); ref.invalidate(inventoryProvider); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(context.l10n.pantryItemAdded(item.displayName))), + SnackBar(content: Text('Flyttade "${item.displayName}" till inventarie.')), ); } catch (error) { _logger.severe('Failed to add item to inventory: $error'); @@ -235,12 +237,14 @@ class _PantryScreenState extends ConsumerState { if (pantryAsync.hasError || productsAsync.hasError) { final error = pantryAsync.error ?? productsAsync.error; _logger.severe('Error loading pantry or products: $error'); - return ErrorStateView( + return buildCopyableErrorPanel( + context: context, message: mapErrorToUserMessage(error ?? 'Okänt fel', context), onRetry: () { ref.invalidate(pantryProvider); ref.invalidate(pantryProductsProvider); }, + title: 'Kunde inte läsa baslagret', ); } @@ -323,11 +327,42 @@ class _PantryScreenState extends ConsumerState { ), ); + final headerSection = Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 4), + child: Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Baslager', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Det här är ditt user-scope baslager. Här lagrar du sådant du vill ha lätt åtkomligt och kan flytta poster vidare till inventarie när det behövs.', + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('User-scope')), + Chip(label: Text('Flytta till inventarie')), + Chip(label: Text('Plats + kategori')), + ], + ), + ], + ), + ), + ), + ); + final content = filteredItems.isEmpty ? ListView( key: const PageStorageKey('pantry-empty-list'), padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), children: [ + headerSection, filterSection, const EmptyStateView( title: 'Baslagret är tomt', @@ -338,11 +373,12 @@ class _PantryScreenState extends ConsumerState { : ListView.separated( key: const PageStorageKey('pantry-main-list'), padding: const EdgeInsets.fromLTRB(12, 0, 12, 96), - itemCount: filteredItems.length + 1, + itemCount: filteredItems.length + 2, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { if (index == 0) return filterSection; - final item = filteredItems[index - 1]; + if (index == 1) return headerSection; + final item = filteredItems[index - 2]; final l1Category = _resolveL1Category(item, productById); return ListTile( @@ -358,9 +394,9 @@ class _PantryScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ IconButton( - tooltip: 'Lägg i inventarie', + tooltip: 'Flytta till inventarie', icon: const Icon(Icons.inventory_2_outlined), - onPressed: () => _addToInventory(item), + onPressed: () => _moveToInventory(item), ), IconButton( tooltip: 'Ta bort', diff --git a/flutter/lib/features/profile/presentation/profile_screen.dart b/flutter/lib/features/profile/presentation/profile_screen.dart index d4c3053f..9039cac8 100644 --- a/flutter/lib/features/profile/presentation/profile_screen.dart +++ b/flutter/lib/features/profile/presentation/profile_screen.dart @@ -9,8 +9,6 @@ import '../data/profile_repository.dart'; import '../domain/user_profile.dart'; import 'user_aliases_screen.dart'; -enum _ProfileTab { profile } - class ProfileScreen extends ConsumerStatefulWidget { const ProfileScreen({super.key}); @@ -24,7 +22,6 @@ class _ProfileScreenState extends ConsumerState { bool _isSaving = false; String? _error; UserProfile? _profile; - _ProfileTab _activeTab = _ProfileTab.profile; late final TextEditingController _emailCtrl; late final TextEditingController _firstNameCtrl; @@ -99,40 +96,6 @@ class _ProfileScreenState extends ConsumerState { context.go('/login'); } - - List<_ProfileTab> _visibleTabs(bool isAdmin) { - return [ - _ProfileTab.profile, - ]; - } - - String _tabLabel(_ProfileTab tab) { - switch (tab) { - case _ProfileTab.profile: - return context.l10n.profileMyProfileTab; - } - } - - Widget _buildTabBar(BuildContext context, List<_ProfileTab> tabs) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: tabs - .map( - (tab) => Padding( - padding: const EdgeInsets.only(right: 8), - child: ChoiceChip( - label: Text(_tabLabel(tab)), - selected: _activeTab == tab, - onSelected: (_) => setState(() => _activeTab = tab), - ), - ), - ) - .toList(), - ), - ); - } - Widget _buildProfileForm(BuildContext context, ThemeData theme) { return Form( key: _formKey, @@ -182,15 +145,16 @@ class _ProfileScreenState extends ConsumerState { const SizedBox(height: 24), SizedBox( width: double.infinity, - child: FilledButton( + child: FilledButton.icon( onPressed: _isSaving ? null : _save, - child: _isSaving + icon: _isSaving ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text(context.l10n.profileSaveAction), + : const Icon(Icons.save_outlined), + label: Text(context.l10n.profileSaveAction), ), ), ], @@ -198,103 +162,132 @@ class _ProfileScreenState extends ConsumerState { ); } - Widget _buildActiveTabContent(BuildContext context, ThemeData theme) { - switch (_activeTab) { - case _ProfileTab.profile: - return _buildProfileForm(context, theme); - } - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); - final tabs = _visibleTabs(_profile?.isAdmin == true); - if (!tabs.contains(_activeTab)) { - _activeTab = _ProfileTab.profile; - } 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: _loadProfile, child: Text(context.l10n.retryAction)), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _loadProfile, + title: 'Kunde inte läsa profilen', ); } + final profile = _profile!; + return ListView( padding: const EdgeInsets.all(16), children: [ - Row( - children: [ - CircleAvatar( - radius: 28, - child: Text( - (_profile?.username.isNotEmpty == true - ? _profile!.username[0] - : '?') - .toUpperCase(), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _profile?.username ?? '', - style: theme.textTheme.titleLarge, + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + radius: 28, + child: Text( + (profile.username.isNotEmpty ? profile.username[0] : '?').toUpperCase(), ), - if ((_profile?.email ?? '').isNotEmpty) - Text( - _profile!.email, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(profile.username, style: theme.textTheme.titleLarge), + const SizedBox(height: 4), + Text( + profile.email, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), ), - ), - ], - ), + if ((profile.firstName ?? '').isNotEmpty || (profile.lastName ?? '').isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + [profile.firstName, profile.lastName] + .where((part) => part != null && part.trim().isNotEmpty) + .join(' '), + style: theme.textTheme.bodySmall, + ), + ], + ], + ), + ), + if (profile.isAdmin) + Chip( + label: const Text('Admin'), + avatar: const Icon(Icons.shield_outlined, size: 16), + backgroundColor: theme.colorScheme.primaryContainer, + labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer), + ), + ], ), - if (_profile?.isAdmin == true) - Chip( - label: const Text('Admin'), - avatar: const Icon(Icons.shield_outlined, size: 16), - backgroundColor: theme.colorScheme.primaryContainer, - labelStyle: TextStyle(color: theme.colorScheme.onPrimaryContainer), - ), - ], - ), - const SizedBox(height: 16), - _buildTabBar(context, tabs), - const SizedBox(height: 16), - _buildActiveTabContent(context, theme), - const SizedBox(height: 24), - ListTile( - leading: const Icon(Icons.link_outlined), - title: const Text('Mina kvittoalias'), - subtitle: const Text('Visa och hantera sparade alias från kvittoimport'), - trailing: const Icon(Icons.chevron_right), - onTap: () => Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const UserAliasesScreen()), ), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - tileColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.4), ), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: _logout, - icon: const Icon(Icons.logout), - label: Text(context.l10n.logoutAction), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Min profil', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Här uppdaterar du kontaktuppgifter och ditt namn. Alias och importrelaterad data finns i en separat vy.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + _buildProfileForm(context, theme), + ], + ), + ), + ), + const SizedBox(height: 12), + Card( + child: ListTile( + leading: const Icon(Icons.link_outlined), + title: const Text('Mina kvittoalias'), + subtitle: const Text( + 'Visa privata alias och globala fallback-alias som används i receipt-importen.', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const UserAliasesScreen()), + ), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Snabbåtgärder', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Logga ut eller gå vidare till aliasvyn när du behöver granska importmatchningar.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: _logout, + icon: const Icon(Icons.logout), + label: Text(context.l10n.logoutAction), + ), + ), + ], + ), ), ), ], diff --git a/flutter/lib/features/profile/presentation/user_aliases_screen.dart b/flutter/lib/features/profile/presentation/user_aliases_screen.dart index 641f4cef..3a414013 100644 --- a/flutter/lib/features/profile/presentation/user_aliases_screen.dart +++ b/flutter/lib/features/profile/presentation/user_aliases_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../../core/api/api_error_mapper.dart'; import '../../admin/data/admin_repository.dart'; import '../../admin/domain/receipt_alias.dart'; @@ -39,7 +40,7 @@ class _UserAliasesScreenState extends ConsumerState { }); } catch (e) { if (!mounted) return; - setState(() => _error = 'Kunde inte ladda alias: $e'); + setState(() => _error = mapErrorToUserMessage(e, context)); } finally { if (mounted) setState(() => _isLoading = false); } @@ -95,87 +96,148 @@ class _UserAliasesScreenState extends ConsumerState { body: Builder(builder: (_) { 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: 12), - FilledButton(onPressed: _load, child: const Text('Försök igen')), - ], - ), + return buildCopyableErrorPanel( + context: context, + message: _error!, + onRetry: _load, + title: 'Kunde inte läsa alias', ); } if (_aliases.isEmpty) { - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant), - const SizedBox(height: 16), - Text( - 'Inga alias sparade ännu.', - style: theme.textTheme.bodyLarge, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Alias skapas när du väljer att lära in dem under kvittoimporten.', - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - return ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: _aliases.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (ctx, i) { - final alias = _aliases[i]; - return ListTile( - leading: Icon( - alias.isGlobal ? Icons.public_outlined : Icons.link_outlined, - color: theme.colorScheme.primary, - ), - title: Text( - alias.receiptName, - style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '→ ${alias.displayProductName}', - style: theme.textTheme.bodySmall, - ), - const SizedBox(height: 4), - Wrap( - spacing: 8, + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Chip( - visualDensity: VisualDensity.compact, - label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'), + Text('Aliasöversikt', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Privat alias')), + Chip(label: Text('Global fallback')), + Chip(label: Text('Receipt-import')), + ], ), ], ), - ], + ), ), - trailing: alias.isPrivate - ? IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: 'Ta bort alias', - color: theme.colorScheme.error, - onPressed: () => _delete(alias), - ) - : null, - ); - }, + const SizedBox(height: 16), + Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.link_off_outlined, size: 48, color: theme.colorScheme.outlineVariant), + const SizedBox(height: 16), + Text( + 'Inga alias sparade ännu.', + style: theme.textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Alias skapas när du väljer att lära in dem under kvittoimporten.', + style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ], + ); + } + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Aliasöversikt', style: theme.textTheme.titleMedium), + const SizedBox(height: 8), + Text( + 'Privata alias gäller bara dig. Globala alias används som fallback i receipt-importen och skapas av admin.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 8), + const Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Chip(label: Text('Privat alias')), + Chip(label: Text('Global fallback')), + Chip(label: Text('Receipt-import')), + ], + ), + ], + ), + ), + ), + const SizedBox(height: 16), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _aliases.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (ctx, i) { + final alias = _aliases[i]; + return ListTile( + leading: Icon( + alias.isGlobal ? Icons.public_outlined : Icons.link_outlined, + color: theme.colorScheme.primary, + ), + title: Text( + alias.receiptName, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '→ ${alias.displayProductName}', + style: theme.textTheme.bodySmall, + ), + const SizedBox(height: 4), + Wrap( + spacing: 8, + children: [ + Chip( + visualDensity: VisualDensity.compact, + label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'), + ), + ], + ), + ], + ), + trailing: alias.isPrivate + ? IconButton( + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + color: theme.colorScheme.error, + onPressed: () => _delete(alias), + ) + : null, + ); + }, + ), + ], ); }), );