feat: implement user-scoped receipt aliases with global fallback; enhance alias management in admin panel
Test Suite / test (24.15.0) (push) Has been cancelled

This commit is contained in:
Nils-Johan Gynther
2026-05-04 19:43:13 +02:00
parent d73ea5ef7c
commit 64b06435cf
15 changed files with 751 additions and 36 deletions
@@ -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;
+9 -1
View File
@@ -26,6 +26,7 @@ model User {
ownedProducts Product[] ownedProducts Product[]
pantryItems PantryItem[] pantryItems PantryItem[]
mealPlanEntries MealPlanEntry[] mealPlanEntries MealPlanEntry[]
receiptAliases ReceiptAlias[]
} }
model Product { model Product {
@@ -175,10 +176,17 @@ model PantryItem {
model ReceiptAlias { model ReceiptAlias {
id Int @id @default(autoincrement()) 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 productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade) product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([receiptName, ownerId, isGlobal])
@@index([ownerId])
@@index([isGlobal])
} }
model MealPlanEntry { model MealPlanEntry {
@@ -1,4 +1,4 @@
import { IsInt, IsString, MinLength } from 'class-validator'; import { IsBoolean, IsInt, IsOptional, IsString, MinLength } from 'class-validator';
export class CreateReceiptAliasDto { export class CreateReceiptAliasDto {
@IsString() @IsString()
@@ -7,4 +7,8 @@ export class CreateReceiptAliasDto {
@IsInt() @IsInt()
productId!: number; productId!: number;
@IsOptional()
@IsBoolean()
isGlobal?: boolean;
} }
@@ -1,25 +1,30 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, ParseIntPipe, Post } from '@nestjs/common';
import { ReceiptAliasService } from './receipt-alias.service'; import { ReceiptAliasService } from './receipt-alias.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; 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') @Controller('receipt-aliases')
export class ReceiptAliasController { export class ReceiptAliasController {
constructor(private readonly receiptAliasService: ReceiptAliasService) {} constructor(private readonly receiptAliasService: ReceiptAliasService) {}
@Get() @Get()
findAll() { findAll(@CurrentUser() user: { userId: number; role: string }) {
return this.receiptAliasService.findAll(); return this.receiptAliasService.findAllForUser(user.userId, user.role);
} }
@Post() @Post()
upsert(@Body() dto: CreateReceiptAliasDto) { upsert(
return this.receiptAliasService.upsert(dto); @Body() dto: CreateReceiptAliasDto,
@CurrentUser() user: { userId: number; role: string },
) {
return this.receiptAliasService.upsert(dto, user.userId, user.role);
} }
@Delete(':id') @Delete(':id')
remove(@Param('id', ParseIntPipe) id: number) { remove(
return this.receiptAliasService.remove(id); @Param('id', ParseIntPipe) id: number,
@CurrentUser() user: { userId: number; role: string },
) {
return this.receiptAliasService.remove(id, user.userId, user.role);
} }
} }
@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto'; import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
@@ -6,23 +6,88 @@ import { CreateReceiptAliasDto } from './dto/create-receipt-alias.dto';
export class ReceiptAliasService { export class ReceiptAliasService {
constructor(private readonly prisma: PrismaService) {} 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({ return this.prisma.receiptAlias.findMany({
where,
include: { product: { select: { id: true, name: true, canonicalName: true } } }, include: { product: { select: { id: true, name: true, canonicalName: true } } },
orderBy: { receiptName: 'asc' }, orderBy: { receiptName: 'asc' },
}); });
} }
async upsert(dto: CreateReceiptAliasDto) { async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
const normalized = dto.receiptName.toLowerCase().trim(); const normalized = dto.receiptName.toLowerCase().trim();
return this.prisma.receiptAlias.upsert({
where: { receiptName: normalized }, const wantsGlobal = dto.isGlobal === true;
create: { receiptName: normalized, productId: dto.productId }, if (wantsGlobal && role !== 'admin') {
update: { productId: dto.productId }, 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 } }); return this.prisma.receiptAlias.delete({ where: { id } });
} }
} }
@@ -56,6 +56,12 @@ describe('ReceiptImportService test matrix', () => {
categoriesServiceMock as any, categoriesServiceMock as any,
); );
beforeEach(() => {
jest.clearAllMocks();
prismaMock.receiptAlias.findMany.mockResolvedValue([]);
prismaMock.product.findMany.mockResolvedValue([]);
});
describe('ignore patterns', () => { describe('ignore patterns', () => {
it.each([ it.each([
'Willys Plus:Bröd', 'Willys Plus:Bröd',
@@ -96,4 +102,125 @@ describe('ReceiptImportService test matrix', () => {
expect(suggestion?.path).toBe(expectedPath); 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();
});
});
}); });
@@ -208,11 +208,20 @@ export class ReceiptImportService {
// Hämta alias och produkter parallellt — filtrera på userId om angivet // Hämta alias och produkter parallellt — filtrera på userId om angivet
const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true }; const productFilter = userId ? { isActive: true, ownerId: userId } : { isActive: true };
const aliasFilter = userId const aliasFilter = userId
? { product: { ownerId: userId } } ? {
: {}; OR: [
{ ownerId: userId, isGlobal: false },
{ isGlobal: true },
],
}
: { isGlobal: true };
const [aliases, products] = await Promise.all([ const [aliases, products] = await Promise.all([
this.prisma.receiptAlias.findMany({ this.prisma.receiptAlias.findMany({
where: aliasFilter, 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 } } } } }, 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({ this.prisma.product.findMany({
+7
View File
@@ -10,6 +10,8 @@ class ProductApiPaths {
static const aiCategorizeBulk = '/products/ai-categorize-bulk'; static const aiCategorizeBulk = '/products/ai-categorize-bulk';
static const deleted = '/products/deleted'; static const deleted = '/products/deleted';
static const merge = '/products/merge'; 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 setStatus(int id) => '/products/$id/status';
static String update(int id) => '/products/$id'; static String update(int id) => '/products/$id';
static String remove(int id) => '/products/$id'; static String remove(int id) => '/products/$id';
@@ -29,6 +31,11 @@ class ReceiptImportApiPaths {
static const refreshCategories = '/receipt-import/refresh-categories'; static const refreshCategories = '/receipt-import/refresh-categories';
} }
class ReceiptAliasApiPaths {
static const list = '/receipt-aliases';
static String remove(int id) => '/receipt-aliases/$id';
}
class RecipeApiPaths { class RecipeApiPaths {
static const list = '/recipes'; static const list = '/recipes';
static String detail(int id) => '/recipes/$id'; static String detail(int id) => '/recipes/$id';
+3 -1
View File
@@ -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) { int? _selectedIndex(List<_AppDestination> destinations) {
final index = destinations.indexWhere( final index = destinations.indexWhere(
@@ -9,6 +9,7 @@ import '../domain/admin_category_node.dart';
import '../domain/admin_product.dart'; import '../domain/admin_product.dart';
import '../domain/ai_model_info.dart'; import '../domain/ai_model_info.dart';
import '../domain/pending_product.dart'; import '../domain/pending_product.dart';
import '../domain/receipt_alias.dart';
import '../domain/user_admin.dart'; import '../domain/user_admin.dart';
final adminRepositoryProvider = Provider<AdminRepository>((ref) { final adminRepositoryProvider = Provider<AdminRepository>((ref) {
@@ -213,6 +214,21 @@ class AdminRepository {
'targetProductId': targetProductId, 'targetProductId': targetProductId,
}); });
Future<Map<String, dynamic>> 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<String, dynamic>.from(data as Map);
}
Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({ Future<List<AdminAiCategorizeResult>> aiCategorizeBulk({
List<int>? productIds, List<int>? productIds,
}) async { }) async {
@@ -246,4 +262,23 @@ class AdminRepository {
/// OBS: endpointen /ai/models kräver autentisering. /// OBS: endpointen /ai/models kräver autentisering.
Future<List<AiModelInfo>> listAiModels() => Future<List<AiModelInfo>> listAiModels() =>
_getList(AiApiPaths.models, AiModelInfo.fromJson); _getList(AiApiPaths.models, AiModelInfo.fromJson);
// ── Kvittoalias (admin/global fallback) ───────────────────────────────────
Future<List<ReceiptAlias>> listReceiptAliases() =>
_getList(ReceiptAliasApiPaths.list, ReceiptAlias.fromJson);
Future<void> upsertReceiptAlias({
required String receiptName,
required int productId,
bool isGlobal = false,
}) =>
_postVoid(ReceiptAliasApiPaths.list, {
'receiptName': receiptName,
'productId': productId,
'isGlobal': isGlobal,
});
Future<void> removeReceiptAlias(int id) =>
_deleteVoid(ReceiptAliasApiPaths.remove(id));
} }
@@ -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<String, dynamic> json) {
final product = json['product'];
final productMap = product is Map<String, dynamic>
? product
: const <String, dynamic>{};
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(),
);
}
}
@@ -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<AdminAliasesPanel> createState() => _AdminAliasesPanelState();
}
class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
bool _isLoading = true;
bool _isSaving = false;
String? _error;
String _search = '';
List<ReceiptAlias> _aliases = [];
List<AdminProduct> _products = [];
final TextEditingController _aliasController = TextEditingController();
int? _selectedProductId;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_aliasController.dispose();
super.dispose();
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final results = await Future.wait<dynamic>([
ref.read(adminRepositoryProvider).listReceiptAliases(),
ref.read(adminRepositoryProvider).listProducts(),
]);
if (!mounted) return;
setState(() {
_aliases = (results[0] as List<ReceiptAlias>)
..sort((a, b) => a.receiptName.compareTo(b.receiptName));
_products = (results[1] as List<AdminProduct>)
..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<void> _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<void> _removeAlias(ReceiptAlias alias) async {
final confirmed = await showDialog<bool>(
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<int>(
value: _selectedProductId,
decoration: const InputDecoration(
labelText: 'Produkt',
border: OutlineInputBorder(),
),
items: _products
.map(
(product) => DropdownMenuItem<int>(
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;
}
}
@@ -199,7 +199,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
second: first, second: first,
}; };
final confirmed = await showDialog<bool>( final selectedPair = await showDialog<({int sourceId, int targetId})>(
context: context, context: context,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( return StatefulBuilder(
@@ -236,11 +236,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(dialogContext, false), onPressed: () => Navigator.pop(dialogContext),
child: Text(context.l10n.cancelAction), child: Text(context.l10n.cancelAction),
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(dialogContext, true), onPressed: () => Navigator.pop(
dialogContext,
(
sourceId: sourceId,
targetId: optionToTarget[sourceId]!,
),
),
child: Text(context.l10n.adminMergeAction), child: Text(context.l10n.adminMergeAction),
), ),
], ],
@@ -249,12 +255,61 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
}, },
); );
if (confirmed != true || !mounted) return; if (selectedPair == null || !mounted) return;
final targetId = sourceId == first ? second : first;
try { try {
final preview = await ref.read(adminRepositoryProvider).previewMerge(
sourceProductId: selectedPair.sourceId,
targetProductId: selectedPair.targetId,
);
if (!mounted) return;
final source = Map<String, dynamic>.from(preview['source'] as Map);
final target = Map<String, dynamic>.from(preview['target'] as Map);
final outcome = Map<String, dynamic>.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<bool>(
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( await ref.read(adminRepositoryProvider).mergeProducts(
sourceProductId: sourceId, sourceProductId: selectedPair.sourceId,
targetProductId: targetId, targetProductId: selectedPair.targetId,
); );
if (!mounted) return; if (!mounted) return;
setState(() => _selectedIds.clear()); setState(() => _selectedIds.clear());
@@ -320,7 +375,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load(); await _load();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Valda produkter återställda.')), SnackBar(content: Text(context.l10n.adminProductsRestored)),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -339,7 +394,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load(); await _load();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Produkt återställd.')), SnackBar(content: Text(context.l10n.adminProductRestored)),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -375,7 +430,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
await _load(); await _load();
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Kategori uppdaterad för ${product.displayName}.')), SnackBar(content: Text(context.l10n.adminCategoryUpdated(product.displayName))),
); );
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
@@ -525,7 +580,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
width: 18, width: 18,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: Text('Uppdatera valda (${_selectedIds.length})'), : Text(context.l10n.adminUpdateSelected(_selectedIds.length)),
), ),
if (!_showDeletedOnly) if (!_showDeletedOnly)
FilledButton.tonal( FilledButton.tonal(
@@ -538,14 +593,14 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
) )
: Text( : Text(
_selectedIds.isEmpty _selectedIds.isEmpty
? 'AI-kategorisera okategoriserade' ? context.l10n.adminAiCategorizeAll
: 'AI-kategorisera valda (${_selectedIds.length})', : context.l10n.adminAiCategorizeSelected(_selectedIds.length),
), ),
), ),
if (!_showDeletedOnly) if (!_showDeletedOnly)
FilledButton.tonal( FilledButton.tonal(
onPressed: _selectedIds.length == 2 ? _mergeSelected : null, onPressed: _selectedIds.length == 2 ? _mergeSelected : null,
child: const Text('Slå ihop 2 valda'), child: Text(context.l10n.adminMerge2Selected),
), ),
if (_showDeletedOnly) if (_showDeletedOnly)
FilledButton.tonal( FilledButton.tonal(
@@ -1,5 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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'; import 'admin_users_panel.dart';
class AdminScreen extends ConsumerStatefulWidget { class AdminScreen extends ConsumerStatefulWidget {
@@ -12,7 +17,52 @@ class AdminScreen extends ConsumerStatefulWidget {
class _AdminScreenState extends ConsumerState<AdminScreen> { class _AdminScreenState extends ConsumerState<AdminScreen> {
@override @override
Widget build(BuildContext context) { 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),
),
],
),
),
],
),
);
} }
} }
@@ -463,10 +463,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
int merged = 0; int merged = 0;
int pantryAdded = 0; int pantryAdded = 0;
int pantrySkipped = 0; int pantrySkipped = 0;
int aliasesLearned = 0;
try { try {
final token = await ref.read(authStateProvider.future); final token = await ref.read(authStateProvider.future);
final invRepo = ref.read(inventoryRepositoryProvider); final invRepo = ref.read(inventoryRepositoryProvider);
final pantryRepo = ref.read(pantryRepositoryProvider); final pantryRepo = ref.read(pantryRepositoryProvider);
final adminRepo = ref.read(adminRepositoryProvider);
final canManageAliases = ref.read(isAdminProvider);
for (final i in toAdd) { for (final i in toAdd) {
final edit = _edits[i]!; final edit = _edits[i]!;
@@ -514,6 +517,24 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
created++; 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; if (!mounted) return;
@@ -522,6 +543,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie', if (merged > 0) '$merged ${merged == 1 ? 'sammanslagen' : 'sammanslagna'} i inventarie',
if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager', if (pantryAdded > 0) '$pantryAdded tillagd${pantryAdded == 1 ? '' : 'a'} i baslager',
if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager', if (pantrySkipped > 0) '$pantrySkipped fanns redan i baslager',
if (aliasesLearned > 0) '$aliasesLearned alias inlärda',
]; ];
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(parts.join(', ') + '.')), SnackBar(content: Text(parts.join(', ') + '.')),