diff --git a/backend/prisma/migrations/20260504130000_receipt_alias_user_scope/migration.sql b/backend/prisma/migrations/20260504130000_receipt_alias_user_scope/migration.sql new file mode 100644 index 00000000..d4c5a9b1 --- /dev/null +++ b/backend/prisma/migrations/20260504130000_receipt_alias_user_scope/migration.sql @@ -0,0 +1,25 @@ +-- Make receipt aliases user-scoped with optional admin-managed global fallback. +ALTER TABLE `ReceiptAlias` + ADD COLUMN `ownerId` INT NULL, + ADD COLUMN `isGlobal` BOOLEAN NOT NULL DEFAULT false; + +-- Existing aliases become global aliases. +UPDATE `ReceiptAlias` +SET `isGlobal` = true +WHERE `isGlobal` = false; + +-- Replace previous global unique key on receiptName. +ALTER TABLE `ReceiptAlias` + DROP INDEX `ReceiptAlias_receiptName_key`; + +-- Add scoped uniqueness and lookup indexes. +ALTER TABLE `ReceiptAlias` + ADD UNIQUE INDEX `ReceiptAlias_receiptName_ownerId_isGlobal_key`(`receiptName`, `ownerId`, `isGlobal`), + ADD INDEX `ReceiptAlias_ownerId_idx`(`ownerId`), + ADD INDEX `ReceiptAlias_isGlobal_idx`(`isGlobal`); + +-- Link aliases to owner when user-scoped. +ALTER TABLE `ReceiptAlias` + ADD CONSTRAINT `ReceiptAlias_ownerId_fkey` + FOREIGN KEY (`ownerId`) REFERENCES `User`(`id`) + ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 681f0c4d..ce361f3d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { ownedProducts Product[] pantryItems PantryItem[] mealPlanEntries MealPlanEntry[] + receiptAliases ReceiptAlias[] } model Product { @@ -175,10 +176,17 @@ model PantryItem { model ReceiptAlias { id Int @id @default(autoincrement()) - receiptName String @unique // normaliserat kvittonamn (lowercase, trim) + receiptName String // normaliserat kvittonamn (lowercase, trim) + ownerId Int? + owner User? @relation(fields: [ownerId], references: [id], onDelete: Cascade) + isGlobal Boolean @default(false) productId Int product Product @relation(fields: [productId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) + + @@unique([receiptName, ownerId, isGlobal]) + @@index([ownerId]) + @@index([isGlobal]) } model MealPlanEntry { diff --git a/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts b/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts index 92381c65..df0d421a 100644 --- a/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts +++ b/backend/src/receipt-alias/dto/create-receipt-alias.dto.ts @@ -1,4 +1,4 @@ -import { IsInt, IsString, MinLength } from 'class-validator'; +import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator'; export class CreateReceiptAliasDto { @IsString() @@ -7,4 +7,8 @@ export class CreateReceiptAliasDto { @IsInt() productId!: number; + + @IsOptional() + @IsBoolean() + isGlobal?: boolean; } diff --git a/backend/src/receipt-alias/receipt-alias.controller.ts b/backend/src/receipt-alias/receipt-alias.controller.ts index 46cb1812..6dde435b 100644 --- a/backend/src/receipt-alias/receipt-alias.controller.ts +++ b/backend/src/receipt-alias/receipt-alias.controller.ts @@ -1,25 +1,30 @@ import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; import { ReceiptAliasService } from './receipt-alias.service'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; -import { Roles } from '../auth/decorators/roles.decorator'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; -@Roles('admin') @Controller('receipt-aliases') export class ReceiptAliasController { constructor(private readonly receiptAliasService: ReceiptAliasService) {} @Get() - findAll() { - return this.receiptAliasService.findAll(); + findAll(@CurrentUser() user: { userId: number; role: string }) { + return this.receiptAliasService.findAllForUser(user.userId, user.role); } @Post() - upsert(@Body() dto: CreateReceiptAliasDto) { - return this.receiptAliasService.upsert(dto); + upsert( + @Body() dto: CreateReceiptAliasDto, + @CurrentUser() user: { userId: number; role: string }, + ) { + return this.receiptAliasService.upsert(dto, user.userId, user.role); } @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number) { - return this.receiptAliasService.remove(id); + remove( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: { userId: number; role: string }, + ) { + return this.receiptAliasService.remove(id, user.userId, user.role); } } diff --git a/backend/src/receipt-alias/receipt-alias.service.ts b/backend/src/receipt-alias/receipt-alias.service.ts index 961d33e4..f1a5e820 100644 --- a/backend/src/receipt-alias/receipt-alias.service.ts +++ b/backend/src/receipt-alias/receipt-alias.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { ForbiddenException, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; @@ -6,23 +6,88 @@ import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; export class ReceiptAliasService { constructor(private readonly prisma: PrismaService) {} - findAll() { + findAllForUser(userId: number, role: string) { + const where = role === 'admin' + ? undefined + : { + OR: [ + { ownerId: userId, isGlobal: false }, + { isGlobal: true }, + ], + }; + return this.prisma.receiptAlias.findMany({ + where, include: { product: { select: { id: true, name: true, canonicalName: true } } }, orderBy: { receiptName: 'asc' }, }); } - async upsert(dto: CreateReceiptAliasDto) { + async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) { const normalized = dto.receiptName.toLowerCase().trim(); - return this.prisma.receiptAlias.upsert({ - where: { receiptName: normalized }, - create: { receiptName: normalized, productId: dto.productId }, - update: { productId: dto.productId }, + + const wantsGlobal = dto.isGlobal === true; + if (wantsGlobal && role !== 'admin') { + throw new ForbiddenException('Endast admin kan skapa globala alias'); + } + + if (wantsGlobal) { + const existing = await this.prisma.receiptAlias.findFirst({ + where: { receiptName: normalized, isGlobal: true }, + }); + + if (existing) { + return this.prisma.receiptAlias.update({ + where: { id: existing.id }, + data: { productId: dto.productId }, + }); + } + + return this.prisma.receiptAlias.create({ + data: { + receiptName: normalized, + productId: dto.productId, + isGlobal: true, + ownerId: null, + }, + }); + } + + const existing = await this.prisma.receiptAlias.findFirst({ + where: { receiptName: normalized, ownerId: userId, isGlobal: false }, + }); + + if (existing) { + return this.prisma.receiptAlias.update({ + where: { id: existing.id }, + data: { productId: dto.productId }, + }); + } + + return this.prisma.receiptAlias.create({ + data: { + receiptName: normalized, + productId: dto.productId, + ownerId: userId, + isGlobal: false, + }, }); } - remove(id: number) { + async remove(id: number, userId: number, role: string) { + const alias = await this.prisma.receiptAlias.findUnique({ where: { id } }); + if (!alias) { + return this.prisma.receiptAlias.delete({ where: { id } }); + } + + const canDelete = + role === 'admin' || + (alias.ownerId === userId && alias.isGlobal === false); + + if (!canDelete) { + throw new ForbiddenException('Du har inte behörighet att ta bort aliaset'); + } + return this.prisma.receiptAlias.delete({ where: { id } }); } } diff --git a/backend/src/receipt-import/receipt-import.service.spec.ts b/backend/src/receipt-import/receipt-import.service.spec.ts index d00d1a3f..795204dd 100644 --- a/backend/src/receipt-import/receipt-import.service.spec.ts +++ b/backend/src/receipt-import/receipt-import.service.spec.ts @@ -56,6 +56,12 @@ describe('ReceiptImportService test matrix', () => { categoriesServiceMock as any, ); + beforeEach(() => { + jest.clearAllMocks(); + prismaMock.receiptAlias.findMany.mockResolvedValue([]); + prismaMock.product.findMany.mockResolvedValue([]); + }); + describe('ignore patterns', () => { it.each([ 'Willys Plus:Bröd', @@ -96,4 +102,125 @@ describe('ReceiptImportService test matrix', () => { expect(suggestion?.path).toBe(expectedPath); }); }); + + describe('alias fallback och prioritet', () => { + it('prioriterar user-alias före global alias för samma receiptName', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([ + { + receiptName: 'mjolk 1l', + productId: 501, + product: { + id: 501, + name: 'Mjolk user', + canonicalName: 'Mjolk user', + categoryId: 30, + categoryRef: { id: 30, name: 'Mejeri' }, + }, + }, + { + receiptName: 'mjolk 1l', + productId: 999, + product: { + id: 999, + name: 'Mjolk global', + canonicalName: 'Mjolk global', + categoryId: 30, + categoryRef: { id: 30, name: 'Mejeri' }, + }, + }, + ]); + + prismaMock.product.findMany.mockResolvedValue([]); + + const result = await (service as any).matchProducts( + [{ rawName: 'MJOLK 1L' }], + 77, + ); + + expect(prismaMock.receiptAlias.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + OR: [ + { ownerId: 77, isGlobal: false }, + { isGlobal: true }, + ], + }, + }), + ); + + expect(result[0].matchedProductId).toBe(501); + expect(result[0].matchedProductName).toBe('Mjolk user'); + }); + + it('använder global alias när user-alias saknas', async () => { + prismaMock.receiptAlias.findMany.mockResolvedValue([ + { + receiptName: 'snickers', + productId: 222, + product: { + id: 222, + name: 'Snickers', + canonicalName: 'Snickers', + categoryId: 53, + categoryRef: { id: 53, name: 'Choklad' }, + }, + }, + ]); + + prismaMock.product.findMany.mockResolvedValue([]); + + const result = await (service as any).matchProducts( + [{ rawName: 'SNICKERS' }], + 88, + ); + + expect(result[0].matchedProductId).toBe(222); + expect(result[0].matchedProductName).toBe('Snickers'); + }); + + it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => { + const aliases: any[] = []; + prismaMock.receiptAlias.findMany.mockImplementation(async () => aliases); + + prismaMock.product.findMany.mockResolvedValue([ + { + id: 700, + name: 'Arla Mjolk 1l', + canonicalName: 'Mjolk', + categoryId: 30, + categoryRef: { id: 30, name: 'Mejeri' }, + }, + ]); + + const first = await (service as any).matchProducts( + [{ rawName: 'ARLA MJOLK 1L' }], + 42, + ); + + expect(first[0].matchedProductId).toBeUndefined(); + expect(first[0].suggestedProductId).toBe(700); + + // Simulerar att användaren manuellt korrigerar och alias lärs in. + aliases.push({ + receiptName: 'arla mjolk 1l', + productId: 700, + product: { + id: 700, + name: 'Arla Mjolk 1l', + canonicalName: 'Mjolk', + categoryId: 30, + categoryRef: { id: 30, name: 'Mejeri' }, + }, + }); + + const second = await (service as any).matchProducts( + [{ rawName: 'ARLA MJOLK 1L' }], + 42, + ); + + expect(second[0].matchedProductId).toBe(700); + expect(second[0].matchedProductName).toBe('Mjolk'); + expect(second[0].suggestedProductId).toBeUndefined(); + }); + }); }); diff --git a/backend/src/receipt-import/receipt-import.service.ts b/backend/src/receipt-import/receipt-import.service.ts index 40a9686f..0115db5d 100644 --- a/backend/src/receipt-import/receipt-import.service.ts +++ b/backend/src/receipt-import/receipt-import.service.ts @@ -208,11 +208,20 @@ export class ReceiptImportService { // Hämta alias och produkter parallellt — filtrera på userId om angivet const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true }; const aliasFilter = userId - ? { product: { ownerId: userId } } - : {}; + ? { + OR: [ + { ownerId: userId, isGlobal: false }, + { isGlobal: true }, + ], + } + : { isGlobal: true }; const [aliases, products] = await Promise.all([ this.prisma.receiptAlias.findMany({ where: aliasFilter, + orderBy: [ + { isGlobal: 'asc' }, + { id: 'asc' }, + ], select: { receiptName: true, productId: true, product: { select: { id: true, name: true, canonicalName: true, categoryId: true, categoryRef: { select: { id: true, name: true } } } } }, }), this.prisma.product.findMany({ diff --git a/flutter/lib/core/api/api_paths.dart b/flutter/lib/core/api/api_paths.dart index abdd06b3..6169183d 100644 --- a/flutter/lib/core/api/api_paths.dart +++ b/flutter/lib/core/api/api_paths.dart @@ -10,6 +10,8 @@ class ProductApiPaths { static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const deleted = '/products/deleted'; static const merge = '/products/merge'; + static String mergePreview(int sourceProductId, int targetProductId) => + '/products/merge-preview?sourceProductId=$sourceProductId&targetProductId=$targetProductId'; static String setStatus(int id) => '/products/$id/status'; static String update(int id) => '/products/$id'; static String remove(int id) => '/products/$id'; @@ -29,6 +31,11 @@ class ReceiptImportApiPaths { static const refreshCategories = '/receipt-import/refresh-categories'; } +class ReceiptAliasApiPaths { + static const list = '/receipt-aliases'; + static String remove(int id) => '/receipt-aliases/$id'; +} + class RecipeApiPaths { static const list = '/recipes'; static String detail(int id) => '/recipes/$id'; diff --git a/flutter/lib/core/ui/app_shell.dart b/flutter/lib/core/ui/app_shell.dart index 397e2482..404efeef 100644 --- a/flutter/lib/core/ui/app_shell.dart +++ b/flutter/lib/core/ui/app_shell.dart @@ -62,7 +62,9 @@ class AppShell extends ConsumerWidget { ), ]; - List<_AppDestination> _destinations(bool isAdmin) => _baseDestinations; + List<_AppDestination> _destinations(bool isAdmin) => isAdmin + ? [..._baseDestinations, _adminHeaderDestination] + : _baseDestinations; int? _selectedIndex(List<_AppDestination> destinations) { final index = destinations.indexWhere( diff --git a/flutter/lib/features/admin/data/admin_repository.dart b/flutter/lib/features/admin/data/admin_repository.dart index fe5405af..be7abeeb 100644 --- a/flutter/lib/features/admin/data/admin_repository.dart +++ b/flutter/lib/features/admin/data/admin_repository.dart @@ -9,6 +9,7 @@ import '../domain/admin_category_node.dart'; import '../domain/admin_product.dart'; import '../domain/ai_model_info.dart'; import '../domain/pending_product.dart'; +import '../domain/receipt_alias.dart'; import '../domain/user_admin.dart'; final adminRepositoryProvider = Provider((ref) { @@ -213,6 +214,21 @@ class AdminRepository { 'targetProductId': targetProductId, }); + Future> previewMerge({ + required int sourceProductId, + required int targetProductId, + }) async { + final token = await _token(); + final data = await guardedApiCall( + _ref, + () => _apiClient.getJson( + ProductApiPaths.mergePreview(sourceProductId, targetProductId), + token: token, + ), + ); + return Map.from(data as Map); + } + Future> aiCategorizeBulk({ List? productIds, }) async { @@ -246,4 +262,23 @@ class AdminRepository { /// OBS: endpointen /ai/models kräver autentisering. Future> listAiModels() => _getList(AiApiPaths.models, AiModelInfo.fromJson); + + // ── Kvittoalias (admin/global fallback) ─────────────────────────────────── + + Future> listReceiptAliases() => + _getList(ReceiptAliasApiPaths.list, ReceiptAlias.fromJson); + + Future upsertReceiptAlias({ + required String receiptName, + required int productId, + bool isGlobal = false, + }) => + _postVoid(ReceiptAliasApiPaths.list, { + 'receiptName': receiptName, + 'productId': productId, + 'isGlobal': isGlobal, + }); + + Future removeReceiptAlias(int id) => + _deleteVoid(ReceiptAliasApiPaths.remove(id)); } diff --git a/flutter/lib/features/admin/domain/receipt_alias.dart b/flutter/lib/features/admin/domain/receipt_alias.dart new file mode 100644 index 00000000..ae312329 --- /dev/null +++ b/flutter/lib/features/admin/domain/receipt_alias.dart @@ -0,0 +1,40 @@ +class ReceiptAlias { + final int id; + final String receiptName; + final int productId; + final String? productName; + final String? productCanonicalName; + + const ReceiptAlias({ + required this.id, + required this.receiptName, + required this.productId, + this.productName, + this.productCanonicalName, + }); + + String get displayProductName { + final canonical = productCanonicalName?.trim(); + if (canonical != null && canonical.isNotEmpty) return canonical; + final fallback = productName?.trim(); + if (fallback != null && fallback.isNotEmpty) return fallback; + return '#$productId'; + } + + factory ReceiptAlias.fromJson(Map json) { + final product = json['product']; + final productMap = product is Map + ? product + : const {}; + + return ReceiptAlias( + id: (json['id'] as num).toInt(), + receiptName: (json['receiptName'] ?? '').toString(), + productId: (json['productId'] as num?)?.toInt() ?? + (productMap['id'] as num?)?.toInt() ?? + 0, + productName: productMap['name']?.toString(), + productCanonicalName: productMap['canonicalName']?.toString(), + ); + } +} diff --git a/flutter/lib/features/admin/presentation/admin_aliases_panel.dart b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart new file mode 100644 index 00000000..310fd9f5 --- /dev/null +++ b/flutter/lib/features/admin/presentation/admin_aliases_panel.dart @@ -0,0 +1,261 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../core/api/api_error_mapper.dart'; +import '../data/admin_repository.dart'; +import '../domain/admin_product.dart'; +import '../domain/receipt_alias.dart'; + +class AdminAliasesPanel extends ConsumerStatefulWidget { + final bool embedded; + + const AdminAliasesPanel({super.key, this.embedded = false}); + + @override + ConsumerState createState() => _AdminAliasesPanelState(); +} + +class _AdminAliasesPanelState extends ConsumerState { + bool _isLoading = true; + bool _isSaving = false; + String? _error; + String _search = ''; + List _aliases = []; + List _products = []; + + final TextEditingController _aliasController = TextEditingController(); + int? _selectedProductId; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _aliasController.dispose(); + super.dispose(); + } + + Future _load() async { + setState(() { + _isLoading = true; + _error = null; + }); + try { + final results = await Future.wait([ + ref.read(adminRepositoryProvider).listReceiptAliases(), + ref.read(adminRepositoryProvider).listProducts(), + ]); + if (!mounted) return; + setState(() { + _aliases = (results[0] as List) + ..sort((a, b) => a.receiptName.compareTo(b.receiptName)); + _products = (results[1] as List) + ..sort( + (a, b) => + a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()), + ); + }); + } catch (e) { + if (!mounted) return; + setState(() => _error = mapErrorToUserMessage(e, context)); + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _upsertAlias() async { + final rawAlias = _aliasController.text.trim().toLowerCase(); + final productId = _selectedProductId; + if (rawAlias.isEmpty || productId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Ange alias och välj produkt.')), + ); + return; + } + + setState(() => _isSaving = true); + try { + await ref.read(adminRepositoryProvider).upsertReceiptAlias( + receiptName: rawAlias, + productId: productId, + isGlobal: true, + ); + if (!mounted) return; + _aliasController.clear(); + setState(() => _selectedProductId = null); + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alias sparad.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } finally { + if (mounted) setState(() => _isSaving = false); + } + } + + Future _removeAlias(ReceiptAlias alias) async { + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('Ta bort alias'), + content: Text('Ta bort alias "${alias.receiptName}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('Avbryt'), + ), + FilledButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: const Text('Ta bort'), + ), + ], + ), + ); + + if (confirmed != true || !mounted) return; + + try { + await ref.read(adminRepositoryProvider).removeReceiptAlias(alias.id); + if (!mounted) return; + await _load(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Alias borttaget.')), + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(mapErrorToUserMessage(e, context))), + ); + } + } + + @override + Widget build(BuildContext context) { + 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: const Text('Försök igen')), + ], + ), + ); + } + + final filteredAliases = _aliases.where((alias) { + final query = _search.trim().toLowerCase(); + if (query.isEmpty) return true; + return alias.receiptName.contains(query) || + alias.displayProductName.toLowerCase().contains(query); + }).toList(); + + 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, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _aliasController, + decoration: const InputDecoration( + labelText: 'Kvittonamn (alias)', + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + value: _selectedProductId, + decoration: const InputDecoration( + labelText: 'Produkt', + border: OutlineInputBorder(), + ), + items: _products + .map( + (product) => DropdownMenuItem( + value: product.id, + child: Text(product.displayName), + ), + ) + .toList(), + onChanged: _isSaving + ? null + : (value) => setState(() => _selectedProductId = value), + ), + ), + const SizedBox(width: 8), + FilledButton.icon( + onPressed: _isSaving ? null : _upsertAlias, + icon: _isSaving + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: const Text('Spara'), + ), + ], + ), + const SizedBox(height: 12), + TextField( + decoration: const InputDecoration( + labelText: 'Sök alias', + prefixIcon: Icon(Icons.search), + border: OutlineInputBorder(), + ), + onChanged: (value) => setState(() => _search = value), + ), + const SizedBox(height: 12), + if (filteredAliases.isEmpty) + const Text('Inga alias hittades.') + else + ...filteredAliases.map( + (alias) => Card( + child: ListTile( + title: Text(alias.receiptName), + subtitle: Text('Produkt: ${alias.displayProductName}'), + trailing: IconButton( + onPressed: () => _removeAlias(alias), + icon: const Icon(Icons.delete_outline), + tooltip: 'Ta bort alias', + ), + ), + ), + ), + ], + ); + + if (!widget.embedded) { + return ListView( + padding: const EdgeInsets.all(16), + children: [content], + ); + } + + return content; + } +} diff --git a/flutter/lib/features/admin/presentation/admin_products_panel.dart b/flutter/lib/features/admin/presentation/admin_products_panel.dart index 310b2f64..4ef20025 100644 --- a/flutter/lib/features/admin/presentation/admin_products_panel.dart +++ b/flutter/lib/features/admin/presentation/admin_products_panel.dart @@ -199,7 +199,7 @@ class _AdminProductsPanelState extends ConsumerState { second: first, }; - final confirmed = await showDialog( + final selectedPair = await showDialog<({int sourceId, int targetId})>( context: context, builder: (dialogContext) { return StatefulBuilder( @@ -236,11 +236,17 @@ class _AdminProductsPanelState extends ConsumerState { ), actions: [ TextButton( - onPressed: () => Navigator.pop(dialogContext, false), + onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.cancelAction), ), FilledButton( - onPressed: () => Navigator.pop(dialogContext, true), + onPressed: () => Navigator.pop( + dialogContext, + ( + sourceId: sourceId, + targetId: optionToTarget[sourceId]!, + ), + ), child: Text(context.l10n.adminMergeAction), ), ], @@ -249,12 +255,61 @@ class _AdminProductsPanelState extends ConsumerState { }, ); - if (confirmed != true || !mounted) return; - final targetId = sourceId == first ? second : first; + if (selectedPair == null || !mounted) return; + try { + final preview = await ref.read(adminRepositoryProvider).previewMerge( + sourceProductId: selectedPair.sourceId, + targetProductId: selectedPair.targetId, + ); + if (!mounted) return; + + final source = Map.from(preview['source'] as Map); + final target = Map.from(preview['target'] as Map); + final outcome = Map.from(preview['outcome'] as Map); + + final sourceName = (source['name'] as String?)?.trim(); + final targetName = (target['name'] as String?)?.trim(); + final sourceCount = (source['inventoryCount'] as num?)?.toInt() ?? 0; + final targetCount = (target['inventoryCount'] as num?)?.toInt() ?? 0; + final moveCount = (outcome['inventoryItemsToMove'] as num?)?.toInt() ?? 0; + + final approved = await showDialog( + context: context, + builder: (previewContext) => AlertDialog( + title: const Text('Merge preview'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Källa: ${sourceName ?? selectedPair.sourceId}'), + Text('Mål: ${targetName ?? selectedPair.targetId}'), + const SizedBox(height: 8), + Text('Inventarieposter i källa: $sourceCount'), + Text('Inventarieposter i mål: $targetCount'), + Text('Kommer flyttas: $moveCount'), + const SizedBox(height: 8), + const Text('Källprodukten soft-raderas efter merge.'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(previewContext, false), + child: Text(context.l10n.cancelAction), + ), + FilledButton( + onPressed: () => Navigator.pop(previewContext, true), + child: Text(context.l10n.adminMergeAction), + ), + ], + ), + ); + + if (approved != true || !mounted) return; + await ref.read(adminRepositoryProvider).mergeProducts( - sourceProductId: sourceId, - targetProductId: targetId, + sourceProductId: selectedPair.sourceId, + targetProductId: selectedPair.targetId, ); if (!mounted) return; setState(() => _selectedIds.clear()); @@ -320,7 +375,7 @@ class _AdminProductsPanelState extends ConsumerState { await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Valda produkter återställda.')), + SnackBar(content: Text(context.l10n.adminProductsRestored)), ); } catch (e) { if (!mounted) return; @@ -339,7 +394,7 @@ class _AdminProductsPanelState extends ConsumerState { await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Produkt återställd.')), + SnackBar(content: Text(context.l10n.adminProductRestored)), ); } catch (e) { if (!mounted) return; @@ -375,7 +430,7 @@ class _AdminProductsPanelState extends ConsumerState { await _load(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Kategori uppdaterad för ${product.displayName}.')), + SnackBar(content: Text(context.l10n.adminCategoryUpdated(product.displayName))), ); } catch (e) { if (!mounted) return; @@ -525,7 +580,7 @@ class _AdminProductsPanelState extends ConsumerState { width: 18, child: CircularProgressIndicator(strokeWidth: 2), ) - : Text('Uppdatera valda (${_selectedIds.length})'), + : Text(context.l10n.adminUpdateSelected(_selectedIds.length)), ), if (!_showDeletedOnly) FilledButton.tonal( @@ -538,14 +593,14 @@ class _AdminProductsPanelState extends ConsumerState { ) : Text( _selectedIds.isEmpty - ? 'AI-kategorisera okategoriserade' - : 'AI-kategorisera valda (${_selectedIds.length})', + ? context.l10n.adminAiCategorizeAll + : context.l10n.adminAiCategorizeSelected(_selectedIds.length), ), ), if (!_showDeletedOnly) FilledButton.tonal( onPressed: _selectedIds.length == 2 ? _mergeSelected : null, - child: const Text('Slå ihop 2 valda'), + child: Text(context.l10n.adminMerge2Selected), ), if (_showDeletedOnly) FilledButton.tonal( diff --git a/flutter/lib/features/admin/presentation/admin_screen.dart b/flutter/lib/features/admin/presentation/admin_screen.dart index bc42794e..90c8d339 100644 --- a/flutter/lib/features/admin/presentation/admin_screen.dart +++ b/flutter/lib/features/admin/presentation/admin_screen.dart @@ -1,5 +1,10 @@ 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_pending_products_panel.dart'; +import 'admin_products_panel.dart'; import 'admin_users_panel.dart'; class AdminScreen extends ConsumerStatefulWidget { @@ -12,7 +17,52 @@ class AdminScreen extends ConsumerStatefulWidget { class _AdminScreenState extends ConsumerState { @override Widget build(BuildContext context) { - return const AdminUsersPanel(); + return DefaultTabController( + length: 5, + child: Column( + children: [ + Material( + color: Theme.of(context).colorScheme.surface, + child: TabBar( + isScrollable: true, + tabs: [ + Tab(text: context.l10n.profileUsersTab, icon: const Icon(Icons.people_outline)), + 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)), + ], + ), + ), + const Expanded( + child: TabBarView( + children: [ + Padding( + padding: EdgeInsets.fromLTRB(12, 12, 12, 8), + child: AdminUsersPanel(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/import/presentation/receipt_import_tab.dart b/flutter/lib/features/import/presentation/receipt_import_tab.dart index a9c3f324..ca5b6c60 100644 --- a/flutter/lib/features/import/presentation/receipt_import_tab.dart +++ b/flutter/lib/features/import/presentation/receipt_import_tab.dart @@ -463,10 +463,13 @@ class _ReceiptImportTabState extends ConsumerState { int merged = 0; int pantryAdded = 0; int pantrySkipped = 0; + int aliasesLearned = 0; try { final token = await ref.read(authStateProvider.future); final invRepo = ref.read(inventoryRepositoryProvider); final pantryRepo = ref.read(pantryRepositoryProvider); + final adminRepo = ref.read(adminRepositoryProvider); + final canManageAliases = ref.read(isAdminProvider); for (final i in toAdd) { final edit = _edits[i]!; @@ -514,6 +517,24 @@ class _ReceiptImportTabState extends ConsumerState { created++; } } + + final normalizedReceiptName = item.rawName.trim().toLowerCase(); + final shouldLearnAlias = + canManageAliases && + normalizedReceiptName.isNotEmpty && + item.matchedProductId != pid; + if (shouldLearnAlias) { + try { + await adminRepo.upsertReceiptAlias( + receiptName: normalizedReceiptName, + productId: pid, + ); + aliasesLearned++; + } catch (e, st) { + debugPrint('ReceiptImportTab alias upsert failed: $e'); + debugPrintStack(stackTrace: st); + } + } } if (!mounted) return; @@ -522,6 +543,7 @@ class _ReceiptImportTabState extends ConsumerState { if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie', if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager', if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager', + if (aliasesLearned > 0) '$aliasesLearned alias inlärda', ]; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(parts.join(', ') + '.')),