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[]
|
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 },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 } });
|
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({
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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(', ') + '.')),
|
||||||
|
|||||||
Reference in New Issue
Block a user