feat: implement two-step category and product picker with private product creation support

This commit is contained in:
Nils-Johan Gynther
2026-05-01 02:44:30 +02:00
parent 4f387fe6eb
commit f983458ff0
3 changed files with 116 additions and 4 deletions
+54
View File
@@ -1030,6 +1030,60 @@ Kvitto-alias lagrar mappningar från kvittots råtext till produkt-ID. Nä
---
## Arkitektur: Privata produkter och kvittoimport produktval (2026-05-01)
### Bakgrund
Kvittoimportflödet (Fas 6b) behövde ett sätt för användare att koppla kvittorader till produkter i systemet. Tre problem identifierades:
1. **Produktsökning gav nollträffar** när AI-kategorins namn (t.ex. "Frukt & Grönt") matades in som söksträng i produktfältet — produkter heter inte samma sak som kategorier.
2. **AI-föreslagna kategorier var ofta L1/L2** (t.ex. "Mat") men produkter är kopplade till L3-noder. Att filtrera på bara det exakta kategori-ID:t gav 0 produkter.
3. **Saknad produkt** — om varan på kvittot inte finns i systemet alls (t.ex. "Röda äpplen") hade användaren ingen väg framåt.
### Lösning 1: Tvåstegs-picker (Kategori → Produkt)
En ny widget `CategoryThenProductPicker` (i `lib/core/ui/`) orkestrerar ett tvåstegsflöde:
**Steg 1 — Kategoriträdet:** Användaren bläddrar i L1 → L2 → L3 eller söker fritext. Sökresultaten visar hela sökvägen som brödsmula. Alla icke-lövnoder (L1, L2) har en "Välj"-knapp som fallback om man vill stoppa på mellannivå.
**Steg 2 — Produktpicker:** `ProductPickerField.showSheet()` öppnas filtrerad på den valda kategorins ID **plus alla ättlingars ID:n** (insamlade rekursivt via `_collectIds()`). Det innebär att valet av "Mat > Frukt & Grönt" ger alla produkter i samtliga L3-underkategorier.
```
L1: Mat
L2: Frukt & Grönt ← välj här → ger produkter från alla L3 nedan
L3: Äpplen ← välj här → ger bara äpple-produkter
L3: Bananer
L3: Citrusfrukter
```
### Lösning 2: AI-direkthopp
Om `preselectedCategoryId` finns (AI-förslag) hoppar `CategoryThenProductPicker.show()` **direkt till steg 2** utan att visa trädet. Användaren klickar på AI-chip → produktpickern öppnas direkt med rätt kategorifilter. Om kategorin inte hittas i trädet visas trädet som fallback.
### Lösning 3: Privata produkter
**Problemet:** `POST /products` (admin) skapar globala produkter. Vanliga användare ska kunna skapa egna produkter utan att smutsa ned den globala produktkatalogen.
**Modell:** `Product` fick ett nytt fält `isPrivate: Boolean @default(false)`. Privata produkter är kopplade till en specifik användare via `ownerId` och syns bara för den användaren.
**`normalizedName`-prefix:** För att undvika kollision i det unika indexet prefixas privata produkters normaliserade namn med `private:{userId}:`. Det innebär att "röda äpplen" för användare 5 lagras som `private:5:roda applen` och kolliderar varken med en global produkt med samma namn eller en annan användares privata produkt.
**Nya endpoints:**
| Endpoint | Åtkomst | Beskrivning |
|---|---|---|
| `GET /products` | Publik | Returnerar bara globala produkter (`isPrivate: false`) |
| `GET /products/mine` | Inloggad | Returnerar inloggad användares privata produkter |
| `POST /products/private` | Inloggad | Skapar en privat produkt kopplad till inloggad användare |
**Migration:** `backend/prisma/migrations/20260501000000_add_product_is_private/migration.sql`
### Flutter-integration
- `_loadProducts()` i `receipt_import_tab.dart` laddar `GET /products` och `GET /products/mine` parallellt via `Future.wait` och slår ihop till en lista.
- `_EditDialogState` håller en lokal `_localProducts`-kopia som utökas om en ny produkt skapas under dialogen — utan ytterligare nätverksanrop.
- "Skapa ny"-dialogen i produktpickern är förifylld med den aktuella söksträngen för snabbt namngivande.
## Arkitektur: User-scope för baslager och matplan (2026-04-22)
> **Designregel: User-scope vid ny funktionalitet**