feat: implement alias strategy for receipt import with user-scoped and global fallback, enhance validation and normalization, and update UI components
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:
+13
-4
@@ -29,11 +29,20 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
|
|||||||
|
|
||||||
## Huvudprioriteringar
|
## Huvudprioriteringar
|
||||||
|
|
||||||
1. Aliasstrategi i kvittoimport: user-scope som standard, global fallback via admin.
|
1. 🟡 Aliasstrategi i kvittoimport: DELVIS GENOMFÖRD (2026-05-09)
|
||||||
- Målbild:
|
- Målbild:
|
||||||
- Vanliga användare skapar och använder privata alias som bara gäller deras egna importer.
|
- Vanliga användare skapar och använder privata alias som bara gäller deras egna importer.
|
||||||
- Admin kan dessutom skapa globala alias som fungerar som fallback för alla användare.
|
- Admin kan dessutom skapa globala alias som fungerar som fallback för alla användare.
|
||||||
- Matchningsordning ska alltid vara: user alias -> global alias -> vanlig produktmatchning.
|
- Matchningsordning ska alltid vara: user alias -> global alias -> vanlig produktmatchning.
|
||||||
|
- Genomfört nu:
|
||||||
|
- Gemensam aliasnormalisering införd för lookup, upsert och alias-inlärning.
|
||||||
|
- Guardrails införda för tomma alias och brusalias som `rabatt`, `summa`, `pant`.
|
||||||
|
- Receipt import lär inte längre in alias automatiskt; användaren måste välja det explicit i edit-dialogen.
|
||||||
|
- Aliasöversikter i Flutter visar nu scope tydligare (`privat` vs `global fallback`).
|
||||||
|
- Tester tillagda för normalisering, prioritet och behörighet.
|
||||||
|
- Kvar:
|
||||||
|
- Manuell verifiering i produktionslik miljö av aliasflödet under riktig receipt import.
|
||||||
|
- Eventuell vidareutveckling av separat aliasöversikt om behov uppstår.
|
||||||
- Backend:
|
- Backend:
|
||||||
- Centralisera normalisering av `receiptName` så samma regler används i lookup, upsert och alias-inlärning.
|
- Centralisera normalisering av `receiptName` så samma regler används i lookup, upsert och alias-inlärning.
|
||||||
- Härda guardrails för alias: blockera tomma alias, uppenbart brus (`rabatt`, `summa`, `pant`) och andra olämpliga kvittonamn.
|
- Härda guardrails för alias: blockera tomma alias, uppenbart brus (`rabatt`, `summa`, `pant`) och andra olämpliga kvittonamn.
|
||||||
@@ -50,9 +59,9 @@ All detaljhistorik och djup teknisk bakgrund finns i respektive tekniska dokumen
|
|||||||
- Verifiera att manuell korrigering + `learnAlias` ger direkt träff vid nästa import.
|
- Verifiera att manuell korrigering + `learnAlias` ger direkt träff vid nästa import.
|
||||||
- Verifiera att normalisering gör alias robust mot versaler, whitespace och enklare stavningsvariationer.
|
- Verifiera att normalisering gör alias robust mot versaler, whitespace och enklare stavningsvariationer.
|
||||||
- Leveransordning:
|
- Leveransordning:
|
||||||
- Fas 1: backend-hardening + tester.
|
- ✅ Fas 1: backend-hardening + tester.
|
||||||
- Fas 2: UI-stöd i receipt import för alias-inlärning.
|
- ✅ Fas 2: UI-stöd i receipt import för alias-inlärning.
|
||||||
- Fas 3: separat aliasöversikt för användare och admin.
|
- 🟡 Fas 3: separat aliasöversikt för användare och admin (grund finns, kan vidareutvecklas vid behov).
|
||||||
2. ✅ **[CLEANUP] Receipt import legacy code (2026-05-09):** KLART
|
2. ✅ **[CLEANUP] Receipt import legacy code (2026-05-09):** KLART
|
||||||
- Borttaget: `matchProducts()`, `enrichWithAiCategories()`, `findWordMatch()` (gammal), m.fl.
|
- Borttaget: `matchProducts()`, `enrichWithAiCategories()`, `findWordMatch()` (gammal), m.fl.
|
||||||
- Tester uppdaterade och gröna (66/66)
|
- Tester uppdaterade och gröna (66/66)
|
||||||
|
|||||||
@@ -158,6 +158,68 @@ Fil: `deploy.sh`
|
|||||||
3. UI för users: Om private rename/merge ska exponeras i användar-app (backend redan klart, saknas bara UI)
|
3. UI för users: Om private rename/merge ska exponeras i användar-app (backend redan klart, saknas bara UI)
|
||||||
4. Unit/integration tests för private endpoints
|
4. Unit/integration tests för private endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Sessionlogg: Aliasstrategi i kvittoimport (samma dag, senare)
|
||||||
|
|
||||||
|
## Mål under denna del
|
||||||
|
- Göra aliasstrategin konsekvent med user-scope som standard och global fallback via admin.
|
||||||
|
- Sluta lära alias automatiskt vid manuell korrigering och kräva explicit val i UI.
|
||||||
|
- Härda backend mot brusiga eller ogiltiga alias.
|
||||||
|
|
||||||
|
## Genomförda förändringar
|
||||||
|
|
||||||
|
### 1) Gemensam aliasnormalisering och guardrails (backend)
|
||||||
|
Filer:
|
||||||
|
- `backend/src/common/utils/receipt-alias.ts`
|
||||||
|
- `backend/src/receipt-alias/receipt-alias.service.ts`
|
||||||
|
- `backend/src/receipt-import/receipt-import.service.ts`
|
||||||
|
|
||||||
|
- Infört gemensam utility för aliasnormalisering (`trim`, lowercase, kollapsad whitespace).
|
||||||
|
- Infört validering som blockerar tomma alias och brusiga alias som `rabatt`, `summa`, `pant`, `att betala`, `totalt`, m.fl.
|
||||||
|
- Receipt import och alias-API använder nu samma regler för både lookup och sparande.
|
||||||
|
|
||||||
|
### 2) Receipt import lär inte längre alias automatiskt (Flutter)
|
||||||
|
Filer:
|
||||||
|
- `flutter/lib/features/import/data/receipt_import_session.dart`
|
||||||
|
- `flutter/lib/features/import/presentation/edit_dialog.dart`
|
||||||
|
- `flutter/lib/features/import/presentation/receipt_import_tab.dart`
|
||||||
|
|
||||||
|
- Infört explicit `learnAlias`-val i edit-dialogen.
|
||||||
|
- Alias sparas nu bara om användaren aktivt markerar att kvittonamnet ska läras in.
|
||||||
|
- Valet persisteras i receipt import-sessionen så att tabbyte inte tappar användarens val.
|
||||||
|
- Om raden redan matchades via alias visas förklarande text i stället för ny aliasinlärning.
|
||||||
|
|
||||||
|
### 3) Aliasöversikter visar scope tydligare (Flutter)
|
||||||
|
Filer:
|
||||||
|
- `flutter/lib/features/admin/domain/receipt_alias.dart`
|
||||||
|
- `flutter/lib/features/profile/presentation/user_aliases_screen.dart`
|
||||||
|
- `flutter/lib/features/admin/presentation/admin_aliases_panel.dart`
|
||||||
|
|
||||||
|
- Aliasmodellen utökad med `ownerId` och `isGlobal`.
|
||||||
|
- User alias-skärmen visar nu skillnad mellan `Privat alias` och `Global fallback`.
|
||||||
|
- Delete-knapp visas bara för privata alias i användarvyn, så UI:t matchar backend-behörigheten.
|
||||||
|
- Adminpanelen visar scope även för aliasposter.
|
||||||
|
|
||||||
|
### 4) Tester för aliasflödet
|
||||||
|
Filer:
|
||||||
|
- `backend/src/receipt-import/receipt-import.service.spec.ts`
|
||||||
|
- `backend/src/receipt-alias/receipt-alias.service.spec.ts`
|
||||||
|
|
||||||
|
- Tester tillagda för normalisering av whitespace vid alias-lookup.
|
||||||
|
- Tester tillagda för alias-upsert med normalisering.
|
||||||
|
- Tester tillagda för blockering av brusalias.
|
||||||
|
- Tester tillagda för behörighetsregler kring globala alias och borttagning.
|
||||||
|
|
||||||
|
## Verifiering
|
||||||
|
- ✅ Backend tests: 31/31 gröna för berörda aliasspecar
|
||||||
|
- ✅ Flutter analyze: OK för alla berörda alias/import-filer
|
||||||
|
|
||||||
|
## Kvar att göra
|
||||||
|
1. Manuell test i appen: receipt import med explicit alias-inlärning.
|
||||||
|
2. Produktionstest: verifiera att privata alias och global fallback beter sig rätt mot riktiga kvitton.
|
||||||
|
3. Bedöm om aliasöversikterna behöver mer avancerad filtrering eller redigering senare.
|
||||||
|
|
||||||
## Snabb checklista för nästa session
|
## Snabb checklista för nästa session
|
||||||
- [ ] Deploy backend + Flutter
|
- [ ] Deploy backend + Flutter
|
||||||
- [ ] Testa scroll-fix i prod
|
- [ ] Testa scroll-fix i prod
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const ignoredReceiptAliasPatterns = [
|
||||||
|
/^rabatt\b/,
|
||||||
|
/^summa\b/,
|
||||||
|
/^moms\b/,
|
||||||
|
/^pant\b/,
|
||||||
|
/^att\s+betala\b/,
|
||||||
|
/^totalt\b/,
|
||||||
|
/^kort\b/,
|
||||||
|
/^kontant\b/,
|
||||||
|
/^willys\s+plus\s*[:\-]?\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizeReceiptAliasName(value: string | null | undefined): string {
|
||||||
|
return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isIgnoredReceiptAliasName(value: string | null | undefined): boolean {
|
||||||
|
const normalized = normalizeReceiptAliasName(value);
|
||||||
|
if (!normalized) return false;
|
||||||
|
|
||||||
|
return ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateReceiptAliasName(value: string | null | undefined): string | null {
|
||||||
|
const normalized = normalizeReceiptAliasName(value);
|
||||||
|
if (!normalized) {
|
||||||
|
return 'Alias får inte vara tomt.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/[a-z0-9åäö]/.test(normalized)) {
|
||||||
|
return 'Alias måste innehålla bokstäver eller siffror.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoredReceiptAliasPatterns.some((pattern) => pattern.test(normalized))) {
|
||||||
|
return 'Aliaset ser ut som en kvittorad som ska ignoreras och kan inte sparas.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { BadRequestException, ForbiddenException, NotFoundException } from '@nestjs/common';
|
||||||
|
import { ReceiptAliasService } from './receipt-alias.service';
|
||||||
|
|
||||||
|
describe('ReceiptAliasService', () => {
|
||||||
|
const prismaMock = {
|
||||||
|
receiptAlias: {
|
||||||
|
findMany: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
|
findUnique: jest.fn(),
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new ReceiptAliasService(prismaMock as any);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normaliserar alias före upsert', async () => {
|
||||||
|
prismaMock.receiptAlias.findFirst.mockResolvedValue(null);
|
||||||
|
prismaMock.receiptAlias.create.mockResolvedValue({ id: 1 });
|
||||||
|
|
||||||
|
await service.upsert(
|
||||||
|
{ receiptName: ' ARLA MJOLK 1L ', productId: 7 },
|
||||||
|
10,
|
||||||
|
'user',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(prismaMock.receiptAlias.findFirst).toHaveBeenCalledWith({
|
||||||
|
where: { receiptName: 'arla mjolk 1l', ownerId: 10, isGlobal: false },
|
||||||
|
});
|
||||||
|
expect(prismaMock.receiptAlias.create).toHaveBeenCalledWith({
|
||||||
|
data: { receiptName: 'arla mjolk 1l', productId: 7, ownerId: 10, isGlobal: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blockerar brusalias', async () => {
|
||||||
|
await expect(
|
||||||
|
service.upsert({ receiptName: ' Rabatt kupong ', productId: 7 }, 10, 'user'),
|
||||||
|
).rejects.toBeInstanceOf(BadRequestException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tillåter inte vanlig användare att skapa globalt alias', async () => {
|
||||||
|
await expect(
|
||||||
|
service.upsert({ receiptName: 'mjolk 1l', productId: 7, isGlobal: true }, 10, 'user'),
|
||||||
|
).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tillåter inte vanlig användare att ta bort globalt alias', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue({
|
||||||
|
id: 9,
|
||||||
|
ownerId: null,
|
||||||
|
isGlobal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.remove(9, 10, 'user')).rejects.toBeInstanceOf(ForbiddenException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returnerar not found vid borttagning av okänt alias', async () => {
|
||||||
|
prismaMock.receiptAlias.findUnique.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.remove(99, 10, 'admin')).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} 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';
|
||||||
|
import {
|
||||||
|
normalizeReceiptAliasName,
|
||||||
|
validateReceiptAliasName,
|
||||||
|
} from '../common/utils/receipt-alias';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReceiptAliasService {
|
export class ReceiptAliasService {
|
||||||
@@ -24,7 +33,11 @@ export class ReceiptAliasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
|
async upsert(dto: CreateReceiptAliasDto, userId: number, role: string) {
|
||||||
const normalized = dto.receiptName.toLowerCase().trim();
|
const normalized = normalizeReceiptAliasName(dto.receiptName);
|
||||||
|
const validationError = validateReceiptAliasName(normalized);
|
||||||
|
if (validationError) {
|
||||||
|
throw new BadRequestException(validationError);
|
||||||
|
}
|
||||||
const wantsGlobal = dto.isGlobal === true;
|
const wantsGlobal = dto.isGlobal === true;
|
||||||
|
|
||||||
if (wantsGlobal && role !== 'admin') {
|
if (wantsGlobal && role !== 'admin') {
|
||||||
@@ -45,10 +58,11 @@ export class ReceiptAliasService {
|
|||||||
ownerId: number | null,
|
ownerId: number | null,
|
||||||
isGlobal: boolean,
|
isGlobal: boolean,
|
||||||
) {
|
) {
|
||||||
|
const normalizedReceiptName = normalizeReceiptAliasName(receiptName);
|
||||||
const existing = await this.prisma.receiptAlias.findFirst({
|
const existing = await this.prisma.receiptAlias.findFirst({
|
||||||
where: isGlobal
|
where: isGlobal
|
||||||
? { receiptName, isGlobal: true }
|
? { receiptName: normalizedReceiptName, isGlobal: true }
|
||||||
: { receiptName, ownerId, isGlobal: false },
|
: { receiptName: normalizedReceiptName, ownerId, isGlobal: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -59,7 +73,7 @@ export class ReceiptAliasService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.receiptAlias.create({
|
return this.prisma.receiptAlias.create({
|
||||||
data: { receiptName, productId, ownerId, isGlobal },
|
data: { receiptName: normalizedReceiptName, productId, ownerId, isGlobal },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ export class SaveReceiptItemDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
learnAlias?: boolean;
|
learnAlias?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
learnAliasGlobally?: boolean;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
learnUnitMapping?: boolean;
|
learnUnitMapping?: boolean;
|
||||||
@@ -70,8 +74,4 @@ export class SaveReceiptDto {
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => SaveReceiptItemDto)
|
@Type(() => SaveReceiptItemDto)
|
||||||
items!: SaveReceiptItemDto[];
|
items!: SaveReceiptItemDto[];
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsBoolean()
|
|
||||||
isAdminLearning?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export class ReceiptImportController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = req?.user?.role === 'admin';
|
const isAdmin = req?.user?.role === 'admin';
|
||||||
if (dto.isAdminLearning && !isAdmin) {
|
if (dto.items.some((item) => item.learnAliasGlobally === true) && !isAdmin) {
|
||||||
throw new BadRequestException('Endast administratörer kan spara globala aliaser.');
|
throw new BadRequestException('Endast administratörer kan spara globala aliaser.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -165,6 +165,28 @@ describe('ReceiptImportService test matrix', () => {
|
|||||||
expect(result.matchedProductName).toBe('Snickers');
|
expect(result.matchedProductName).toBe('Snickers');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('normaliserar whitespace vid alias-lookup', async () => {
|
||||||
|
const aliases = [
|
||||||
|
{
|
||||||
|
receiptName: 'arla mjolk 1l',
|
||||||
|
productId: 700,
|
||||||
|
product: {
|
||||||
|
id: 700,
|
||||||
|
name: 'Arla Mjolk 1l',
|
||||||
|
canonicalName: 'Mjolk',
|
||||||
|
categoryId: 30,
|
||||||
|
categoryRef: { id: 30, name: 'Mejeri' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const context = makeContext(aliases, [], [], 42);
|
||||||
|
const result = await (service as any).matchAndEnrichReceiptItem({ rawName: ' ARLA MJOLK 1L ' }, context);
|
||||||
|
|
||||||
|
expect(result.matchedProductId).toBe(700);
|
||||||
|
expect(result.matchedVia).toBe('alias');
|
||||||
|
});
|
||||||
|
|
||||||
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
|
it('flöde: manuell korrigering lär alias och nästa import matchar direkt', async () => {
|
||||||
const products = [
|
const products = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import { SaveReceiptResponse } from './dto/save-receipt.response';
|
|||||||
import { AiService, CategorySuggestion } from '../ai/ai.service';
|
import { AiService, CategorySuggestion } from '../ai/ai.service';
|
||||||
import { CategoriesService } from '../categories/categories.service';
|
import { CategoriesService } from '../categories/categories.service';
|
||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
|
import {
|
||||||
|
isIgnoredReceiptAliasName,
|
||||||
|
normalizeReceiptAliasName,
|
||||||
|
validateReceiptAliasName,
|
||||||
|
} from '../common/utils/receipt-alias';
|
||||||
|
|
||||||
const IMPORTER_SERVICE_URL =
|
const IMPORTER_SERVICE_URL =
|
||||||
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
process.env.IMPORTER_SERVICE_URL || 'http://importer-api:3001';
|
||||||
@@ -37,20 +42,7 @@ function tokenize(value: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
export function isIgnoredReceiptName(value: string | null | undefined): boolean {
|
||||||
const normalized = (value ?? '').trim().toLowerCase();
|
return isIgnoredReceiptAliasName(value);
|
||||||
if (!normalized) return false;
|
|
||||||
|
|
||||||
if (/^rabatt\b/.test(normalized)) return true;
|
|
||||||
if (/^summa\b/.test(normalized)) return true;
|
|
||||||
if (/^moms\b/.test(normalized)) return true;
|
|
||||||
if (/^pant\b/.test(normalized)) return true;
|
|
||||||
if (/^att\s+betala\b/.test(normalized)) return true;
|
|
||||||
if (/^totalt\b/.test(normalized)) return true;
|
|
||||||
if (/^kort\b/.test(normalized)) return true;
|
|
||||||
if (/^kontant\b/.test(normalized)) return true;
|
|
||||||
if (/^willys\s+plus\s*[:\-]?\b/.test(normalized)) return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeToken(s: string): string {
|
function normalizeToken(s: string): string {
|
||||||
@@ -220,8 +212,10 @@ export class ReceiptImportService {
|
|||||||
|
|
||||||
const aliasByReceiptName = new Map<string, AliasLite>();
|
const aliasByReceiptName = new Map<string, AliasLite>();
|
||||||
for (const alias of aliases) {
|
for (const alias of aliases) {
|
||||||
if (!aliasByReceiptName.has(alias.receiptName)) {
|
const normalizedReceiptName = normalizeReceiptAliasName(alias.receiptName);
|
||||||
aliasByReceiptName.set(alias.receiptName, alias);
|
if (!normalizedReceiptName) continue;
|
||||||
|
if (!aliasByReceiptName.has(normalizedReceiptName)) {
|
||||||
|
aliasByReceiptName.set(normalizedReceiptName, alias);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,15 +466,20 @@ export class ReceiptImportService {
|
|||||||
|
|
||||||
// === Steg 4: Lär in alias om requested ===
|
// === Steg 4: Lär in alias om requested ===
|
||||||
if (item.learnAlias) {
|
if (item.learnAlias) {
|
||||||
const normalizedReceiptName = (item.rawName ?? '').trim().toLowerCase();
|
const normalizedReceiptName = normalizeReceiptAliasName(item.rawName);
|
||||||
|
const aliasValidationError = validateReceiptAliasName(normalizedReceiptName);
|
||||||
|
if (aliasValidationError) {
|
||||||
|
throw new Error(aliasValidationError);
|
||||||
|
}
|
||||||
if (normalizedReceiptName) {
|
if (normalizedReceiptName) {
|
||||||
const aliasOwnerId: number | null = dto.isAdminLearning ? null : userId || null;
|
const isGlobalAlias = item.learnAliasGlobally === true;
|
||||||
|
const aliasOwnerId: number | null = isGlobalAlias ? null : userId || null;
|
||||||
await tx.receiptAlias.upsert({
|
await tx.receiptAlias.upsert({
|
||||||
where: {
|
where: {
|
||||||
receiptName_ownerId_isGlobal: {
|
receiptName_ownerId_isGlobal: {
|
||||||
receiptName: normalizedReceiptName,
|
receiptName: normalizedReceiptName,
|
||||||
ownerId: aliasOwnerId as any,
|
ownerId: aliasOwnerId as any,
|
||||||
isGlobal: dto.isAdminLearning ? true : false,
|
isGlobal: isGlobalAlias,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
@@ -489,8 +488,8 @@ export class ReceiptImportService {
|
|||||||
create: {
|
create: {
|
||||||
receiptName: normalizedReceiptName,
|
receiptName: normalizedReceiptName,
|
||||||
productId,
|
productId,
|
||||||
ownerId: (dto.isAdminLearning ? null : userId || null) as any,
|
ownerId: aliasOwnerId as any,
|
||||||
isGlobal: dto.isAdminLearning ? true : false,
|
isGlobal: isGlobalAlias,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
response.aliasesLearned++;
|
response.aliasesLearned++;
|
||||||
@@ -573,7 +572,7 @@ export class ReceiptImportService {
|
|||||||
): Promise<ParsedReceiptItem> {
|
): Promise<ParsedReceiptItem> {
|
||||||
if (!item.rawName) return item;
|
if (!item.rawName) return item;
|
||||||
|
|
||||||
const raw = item.rawName.toLowerCase().trim();
|
const raw = normalizeReceiptAliasName(item.rawName);
|
||||||
const debug: MatchDebug = { steps: [], tree: {} };
|
const debug: MatchDebug = { steps: [], tree: {} };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -173,6 +173,9 @@ class AdminRepository {
|
|||||||
Future<List<AdminProduct>> listProducts() =>
|
Future<List<AdminProduct>> listProducts() =>
|
||||||
_getList(ProductApiPaths.mine, AdminProduct.fromJson);
|
_getList(ProductApiPaths.mine, AdminProduct.fromJson);
|
||||||
|
|
||||||
|
Future<List<AdminProduct>> listGlobalProducts() =>
|
||||||
|
_getList(ProductApiPaths.list, AdminProduct.fromJson, requiresAuth: false);
|
||||||
|
|
||||||
Future<List<AdminProduct>> listDeletedProducts() =>
|
Future<List<AdminProduct>> listDeletedProducts() =>
|
||||||
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
_getList(ProductApiPaths.deleted, AdminProduct.fromJson);
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class ReceiptAlias {
|
|||||||
final int id;
|
final int id;
|
||||||
final String receiptName;
|
final String receiptName;
|
||||||
final int productId;
|
final int productId;
|
||||||
|
final int? ownerId;
|
||||||
|
final bool isGlobal;
|
||||||
final String? productName;
|
final String? productName;
|
||||||
final String? productCanonicalName;
|
final String? productCanonicalName;
|
||||||
|
|
||||||
@@ -9,10 +11,14 @@ class ReceiptAlias {
|
|||||||
required this.id,
|
required this.id,
|
||||||
required this.receiptName,
|
required this.receiptName,
|
||||||
required this.productId,
|
required this.productId,
|
||||||
|
required this.ownerId,
|
||||||
|
required this.isGlobal,
|
||||||
this.productName,
|
this.productName,
|
||||||
this.productCanonicalName,
|
this.productCanonicalName,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bool get isPrivate => !isGlobal;
|
||||||
|
|
||||||
String get displayProductName {
|
String get displayProductName {
|
||||||
final canonical = productCanonicalName?.trim();
|
final canonical = productCanonicalName?.trim();
|
||||||
if (canonical != null && canonical.isNotEmpty) return canonical;
|
if (canonical != null && canonical.isNotEmpty) return canonical;
|
||||||
@@ -33,6 +39,8 @@ class ReceiptAlias {
|
|||||||
productId: (json['productId'] as num?)?.toInt() ??
|
productId: (json['productId'] as num?)?.toInt() ??
|
||||||
(productMap['id'] as num?)?.toInt() ??
|
(productMap['id'] as num?)?.toInt() ??
|
||||||
0,
|
0,
|
||||||
|
ownerId: (json['ownerId'] as num?)?.toInt(),
|
||||||
|
isGlobal: json['isGlobal'] == true,
|
||||||
productName: productMap['name']?.toString(),
|
productName: productMap['name']?.toString(),
|
||||||
productCanonicalName: productMap['canonicalName']?.toString(),
|
productCanonicalName: productMap['canonicalName']?.toString(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
try {
|
try {
|
||||||
final results = await Future.wait<dynamic>([
|
final results = await Future.wait<dynamic>([
|
||||||
ref.read(adminRepositoryProvider).listReceiptAliases(),
|
ref.read(adminRepositoryProvider).listReceiptAliases(),
|
||||||
ref.read(adminRepositoryProvider).listProducts(),
|
ref.read(adminRepositoryProvider).listGlobalProducts(),
|
||||||
]);
|
]);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -67,7 +67,7 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _upsertAlias() async {
|
Future<void> _upsertAlias() async {
|
||||||
final rawAlias = _aliasController.text.trim().toLowerCase();
|
final rawAlias = _aliasController.text.trim();
|
||||||
final productId = _selectedProductId;
|
final productId = _selectedProductId;
|
||||||
if (rawAlias.isEmpty || productId == null) {
|
if (rawAlias.isEmpty || productId == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -186,6 +186,11 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
color: Theme.of(context).colorScheme.outline,
|
color: Theme.of(context).colorScheme.outline,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: Text(alias.isGlobal ? 'Global' : 'Privat'),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
@@ -198,17 +203,6 @@ class _AdminAliasesPanelState extends ConsumerState<AdminAliasesPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget buildAliasList({EdgeInsetsGeometry padding = EdgeInsets.zero}) {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: padding,
|
|
||||||
itemCount: filteredAliases.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final alias = filteredAliases[index];
|
|
||||||
return buildAliasCard(alias);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final content = Column(
|
final content = Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -265,7 +265,6 @@ class ImportRepository {
|
|||||||
/// - Learning unit mappings
|
/// - Learning unit mappings
|
||||||
Future<Map<String, dynamic>> saveReceipt({
|
Future<Map<String, dynamic>> saveReceipt({
|
||||||
required List<Map<String, dynamic>> items,
|
required List<Map<String, dynamic>> items,
|
||||||
bool isAdminLearning = false,
|
|
||||||
String? token,
|
String? token,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
@@ -280,7 +279,6 @@ class ImportRepository {
|
|||||||
},
|
},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'items': items,
|
'items': items,
|
||||||
if (isAdminLearning) 'isAdminLearning': true,
|
|
||||||
}),
|
}),
|
||||||
).timeout(
|
).timeout(
|
||||||
const Duration(seconds: 60),
|
const Duration(seconds: 60),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ enum CategorySelectionSource { ai, manual }
|
|||||||
class ItemEdit {
|
class ItemEdit {
|
||||||
final int? productId;
|
final int? productId;
|
||||||
final String? productName;
|
final String? productName;
|
||||||
|
final bool learnAlias;
|
||||||
|
final bool learnAliasGlobally;
|
||||||
final int? categoryId;
|
final int? categoryId;
|
||||||
final String? categoryPath;
|
final String? categoryPath;
|
||||||
final CategorySelectionSource? categorySource;
|
final CategorySelectionSource? categorySource;
|
||||||
@@ -29,6 +31,8 @@ class ItemEdit {
|
|||||||
const ItemEdit({
|
const ItemEdit({
|
||||||
this.productId,
|
this.productId,
|
||||||
this.productName,
|
this.productName,
|
||||||
|
this.learnAlias = false,
|
||||||
|
this.learnAliasGlobally = false,
|
||||||
this.categoryId,
|
this.categoryId,
|
||||||
this.categoryPath,
|
this.categoryPath,
|
||||||
this.categorySource,
|
this.categorySource,
|
||||||
@@ -85,6 +89,8 @@ class ReceiptImportSession {
|
|||||||
'edits': edits.map((key, value) => MapEntry(key.toString(), {
|
'edits': edits.map((key, value) => MapEntry(key.toString(), {
|
||||||
'productId': value.productId,
|
'productId': value.productId,
|
||||||
'productName': value.productName,
|
'productName': value.productName,
|
||||||
|
'learnAlias': value.learnAlias,
|
||||||
|
'learnAliasGlobally': value.learnAliasGlobally,
|
||||||
'categoryId': value.categoryId,
|
'categoryId': value.categoryId,
|
||||||
'categoryPath': value.categoryPath,
|
'categoryPath': value.categoryPath,
|
||||||
'categorySource': value.categorySource?.name,
|
'categorySource': value.categorySource?.name,
|
||||||
@@ -114,6 +120,8 @@ class ReceiptImportSession {
|
|||||||
edits[idx] = ItemEdit(
|
edits[idx] = ItemEdit(
|
||||||
productId: (value['productId'] as num?)?.toInt(),
|
productId: (value['productId'] as num?)?.toInt(),
|
||||||
productName: value['productName'] as String?,
|
productName: value['productName'] as String?,
|
||||||
|
learnAlias: value['learnAlias'] == true,
|
||||||
|
learnAliasGlobally: value['learnAliasGlobally'] == true,
|
||||||
categoryId: (value['categoryId'] as num?)?.toInt(),
|
categoryId: (value['categoryId'] as num?)?.toInt(),
|
||||||
categoryPath: value['categoryPath'] as String?,
|
categoryPath: value['categoryPath'] as String?,
|
||||||
categorySource: switch (value['categorySource']) {
|
categorySource: switch (value['categorySource']) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class EditDialog extends StatefulWidget {
|
|||||||
final List<AdminCategoryNode> categoryTree;
|
final List<AdminCategoryNode> categoryTree;
|
||||||
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
|
final Future<ProductOption?> Function(String name, int? categoryId)? onCreate;
|
||||||
final ImportProductEntryMode? initialEntryMode;
|
final ImportProductEntryMode? initialEntryMode;
|
||||||
|
final bool canLearnGlobalAlias;
|
||||||
|
|
||||||
const EditDialog({
|
const EditDialog({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -28,6 +29,7 @@ class EditDialog extends StatefulWidget {
|
|||||||
required this.categoryTree,
|
required this.categoryTree,
|
||||||
this.onCreate,
|
this.onCreate,
|
||||||
this.initialEntryMode,
|
this.initialEntryMode,
|
||||||
|
this.canLearnGlobalAlias = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -53,6 +55,8 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
_Destination _destination = _Destination.inventory;
|
_Destination _destination = _Destination.inventory;
|
||||||
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
|
ImportProductEntryMode _entryMode = ImportProductEntryMode.existing;
|
||||||
bool _isCreatingProduct = false;
|
bool _isCreatingProduct = false;
|
||||||
|
bool _learnAlias = false;
|
||||||
|
bool _learnAliasGlobally = false;
|
||||||
|
|
||||||
// Lokal lista — utökas om nya produkter skapas under dialogen
|
// Lokal lista — utökas om nya produkter skapas under dialogen
|
||||||
late List<ProductOption> _localProducts;
|
late List<ProductOption> _localProducts;
|
||||||
@@ -68,6 +72,8 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
_productName = widget.current.productName == null
|
_productName = widget.current.productName == null
|
||||||
? null
|
? null
|
||||||
: normalizeProductName(widget.current.productName!);
|
: normalizeProductName(widget.current.productName!);
|
||||||
|
_learnAlias = widget.current.learnAlias;
|
||||||
|
_learnAliasGlobally = widget.current.learnAliasGlobally;
|
||||||
_destination = widget.current.destination;
|
_destination = widget.current.destination;
|
||||||
_entryMode = widget.initialEntryMode ??
|
_entryMode = widget.initialEntryMode ??
|
||||||
(_productId == null
|
(_productId == null
|
||||||
@@ -273,6 +279,8 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
ItemEdit(
|
ItemEdit(
|
||||||
productId: _productId,
|
productId: _productId,
|
||||||
productName: _productName,
|
productName: _productName,
|
||||||
|
learnAlias: _learnAlias,
|
||||||
|
learnAliasGlobally: _learnAlias && widget.canLearnGlobalAlias && _learnAliasGlobally,
|
||||||
categoryId: _productCategoryId,
|
categoryId: _productCategoryId,
|
||||||
categoryPath: _productCategoryPath,
|
categoryPath: _productCategoryPath,
|
||||||
categorySource: _productCategorySource,
|
categorySource: _productCategorySource,
|
||||||
@@ -358,6 +366,8 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
else
|
else
|
||||||
_buildCreateProductSection(theme, aiLabel),
|
_buildCreateProductSection(theme, aiLabel),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
_buildAliasSection(theme, item),
|
||||||
|
const SizedBox(height: 12),
|
||||||
if (_destination == _Destination.inventory)
|
if (_destination == _Destination.inventory)
|
||||||
_buildQuantitySection(theme, totalPreview, currentUnit)
|
_buildQuantitySection(theme, totalPreview, currentUnit)
|
||||||
else
|
else
|
||||||
@@ -429,6 +439,70 @@ class _EditDialogState extends State<EditDialog> {
|
|||||||
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Widget _buildAliasSection(ThemeData theme, ParsedReceiptItem item) {
|
||||||
|
final alreadyAliasMatch =
|
||||||
|
_entryMode == ImportProductEntryMode.existing &&
|
||||||
|
_productId != null &&
|
||||||
|
item.matchedVia == 'alias' &&
|
||||||
|
item.matchedProductId == _productId;
|
||||||
|
|
||||||
|
if (alreadyAliasMatch) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primaryContainer.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Det här kvittonamnet matchades redan via alias. Ingen ny aliasinlärning behövs.',
|
||||||
|
style: theme.textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
CheckboxListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: _learnAlias,
|
||||||
|
onChanged: (value) => setState(() {
|
||||||
|
_learnAlias = value ?? false;
|
||||||
|
if (!_learnAlias) _learnAliasGlobally = false;
|
||||||
|
}),
|
||||||
|
title: const Text('Lär detta kvittonamn för framtiden'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Sparar ett alias så att samma kvittonamn kan matchas direkt vid nästa import.',
|
||||||
|
),
|
||||||
|
controlAffinity: ListTileControlAffinity.leading,
|
||||||
|
),
|
||||||
|
if (widget.canLearnGlobalAlias && _learnAlias)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12),
|
||||||
|
child: SegmentedButton<bool>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment<bool>(
|
||||||
|
value: false,
|
||||||
|
label: Text('Privat alias'),
|
||||||
|
icon: Icon(Icons.lock_outline, size: 16),
|
||||||
|
),
|
||||||
|
ButtonSegment<bool>(
|
||||||
|
value: true,
|
||||||
|
label: Text('Global fallback'),
|
||||||
|
icon: Icon(Icons.public_outlined, size: 16),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_learnAliasGlobally},
|
||||||
|
onSelectionChanged: (selection) =>
|
||||||
|
setState(() => _learnAliasGlobally = selection.first),
|
||||||
|
style: const ButtonStyle(visualDensity: VisualDensity.compact),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildExistingProductSection(
|
Widget _buildExistingProductSection(
|
||||||
ThemeData theme,
|
ThemeData theme,
|
||||||
ParsedReceiptItem item,
|
ParsedReceiptItem item,
|
||||||
|
|||||||
@@ -402,6 +402,7 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
products: _products,
|
products: _products,
|
||||||
categoryTree: _categoryTree,
|
categoryTree: _categoryTree,
|
||||||
initialEntryMode: initialEntryMode,
|
initialEntryMode: initialEntryMode,
|
||||||
|
canLearnGlobalAlias: ref.read(isAdminProvider),
|
||||||
onCreate: (name, categoryId) async {
|
onCreate: (name, categoryId) async {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
@@ -478,7 +479,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
try {
|
try {
|
||||||
final token = await ref.read(authStateProvider.future);
|
final token = await ref.read(authStateProvider.future);
|
||||||
final repo = ref.read(importRepositoryProvider);
|
final repo = ref.read(importRepositoryProvider);
|
||||||
final canManageAliases = ref.read(isAdminProvider);
|
|
||||||
|
|
||||||
// Bygg upp items för saveReceipt endpoint
|
// Bygg upp items för saveReceipt endpoint
|
||||||
final saveItems = <Map<String, dynamic>>[];
|
final saveItems = <Map<String, dynamic>>[];
|
||||||
@@ -507,10 +507,13 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
|
if (edit.packageCount != null) saveItem['packageCount'] = edit.packageCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lär in alias om den inte redan matchades via alias
|
// Lär in alias bara om användaren uttryckligen valt det
|
||||||
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
final alreadyAliasMatch = item.matchedVia == 'alias' && item.matchedProductId == pid;
|
||||||
if (item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
if (edit.learnAlias && item.rawName.trim().isNotEmpty && !alreadyAliasMatch) {
|
||||||
saveItem['learnAlias'] = true;
|
saveItem['learnAlias'] = true;
|
||||||
|
if (edit.learnAliasGlobally) {
|
||||||
|
saveItem['learnAliasGlobally'] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lär in enhetsmappning för inventory
|
// Lär in enhetsmappning för inventory
|
||||||
@@ -528,7 +531,6 @@ class _ReceiptImportTabState extends ConsumerState<ReceiptImportTab> {
|
|||||||
// Gör ett enda anrop till saveReceipt
|
// Gör ett enda anrop till saveReceipt
|
||||||
final response = await repo.saveReceipt(
|
final response = await repo.saveReceipt(
|
||||||
items: saveItems,
|
items: saveItems,
|
||||||
isAdminLearning: canManageAliases,
|
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,11 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases();
|
final aliases = await ref.read(adminRepositoryProvider).listReceiptAliases();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_aliases = aliases;
|
_aliases = [...aliases]
|
||||||
|
..sort((a, b) {
|
||||||
|
if (a.isGlobal != b.isGlobal) return a.isGlobal ? 1 : -1;
|
||||||
|
return a.receiptName.compareTo(b.receiptName);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -118,7 +122,7 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Alias skapas automatiskt när du sparar kvittorader i inventariet.',
|
'Alias skapas när du väljer att lära in dem under kvittoimporten.',
|
||||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -135,23 +139,41 @@ class _UserAliasesScreenState extends ConsumerState<UserAliasesScreen> {
|
|||||||
final alias = _aliases[i];
|
final alias = _aliases[i];
|
||||||
return ListTile(
|
return ListTile(
|
||||||
leading: Icon(
|
leading: Icon(
|
||||||
Icons.link_outlined,
|
alias.isGlobal ? Icons.public_outlined : Icons.link_outlined,
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
alias.receiptName,
|
alias.receiptName,
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500),
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Column(
|
||||||
'→ ${alias.displayProductName}',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: theme.textTheme.bodySmall,
|
mainAxisSize: MainAxisSize.min,
|
||||||
),
|
children: [
|
||||||
trailing: IconButton(
|
Text(
|
||||||
icon: const Icon(Icons.delete_outline),
|
'→ ${alias.displayProductName}',
|
||||||
tooltip: 'Ta bort alias',
|
style: theme.textTheme.bodySmall,
|
||||||
color: theme.colorScheme.error,
|
),
|
||||||
onPressed: () => _delete(alias),
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Chip(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
label: Text(alias.isGlobal ? 'Global fallback' : 'Privat alias'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
|
trailing: alias.isPrivate
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
tooltip: 'Ta bort alias',
|
||||||
|
color: theme.colorScheme.error,
|
||||||
|
onPressed: () => _delete(alias),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user