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
Test Suite / test (24.15.0) (push) Has been cancelled
This commit is contained in:
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
|
||||
remove(id: number) {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<AdminRepository>((ref) {
|
||||
@@ -213,6 +214,21 @@ class AdminRepository {
|
||||
'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({
|
||||
List<int>? productIds,
|
||||
}) async {
|
||||
@@ -246,4 +262,23 @@ class AdminRepository {
|
||||
/// OBS: endpointen /ai/models kräver autentisering.
|
||||
Future<List<AiModelInfo>> listAiModels() =>
|
||||
_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,
|
||||
};
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
final selectedPair = await showDialog<({int sourceId, int targetId})>(
|
||||
context: context,
|
||||
builder: (dialogContext) {
|
||||
return StatefulBuilder(
|
||||
@@ -236,11 +236,17 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
),
|
||||
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<AdminProductsPanel> {
|
||||
},
|
||||
);
|
||||
|
||||
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<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(
|
||||
sourceProductId: sourceId,
|
||||
targetProductId: targetId,
|
||||
sourceProductId: selectedPair.sourceId,
|
||||
targetProductId: selectedPair.targetId,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _selectedIds.clear());
|
||||
@@ -320,7 +375,7 @@ class _AdminProductsPanelState extends ConsumerState<AdminProductsPanel> {
|
||||
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<AdminProductsPanel> {
|
||||
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<AdminProductsPanel> {
|
||||
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<AdminProductsPanel> {
|
||||
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<AdminProductsPanel> {
|
||||
)
|
||||
: 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(
|
||||
|
||||
@@ -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<AdminScreen> {
|
||||
@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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -463,10 +463,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
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<ReceiptImportTab> {
|
||||
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(', ') + '.')),
|
||||
|
||||
Reference in New Issue
Block a user