feat: implement two-step category and product picker with private product creation support
This commit is contained in:
@@ -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)
|
## Arkitektur: User-scope för baslager och matplan (2026-04-22)
|
||||||
|
|
||||||
> **Designregel: User-scope vid ny funktionalitet**
|
> **Designregel: User-scope vid ny funktionalitet**
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ class CategoryThenProductPicker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
|
// Samla alla kategori-IDs i den valda grenen (inkl. ättlingar)
|
||||||
final categoryIds = _collectIds(selectedCategory);
|
final categoryIds = _collectIds(selectedCategory!);
|
||||||
|
|
||||||
// Filtrera produkter på dessa kategorier
|
// Filtrera produkter på dessa kategorier
|
||||||
final filtered = products
|
final filtered = products
|
||||||
@@ -84,7 +84,7 @@ class CategoryThenProductPicker {
|
|||||||
// Bygg eventuell onCreate-callback med categoryId inbunden
|
// Bygg eventuell onCreate-callback med categoryId inbunden
|
||||||
final onCreateBound = onCreate == null
|
final onCreateBound = onCreate == null
|
||||||
? null
|
? null
|
||||||
: (String name) => onCreate(name, selectedCategory.id);
|
: (String name) => onCreate(name, selectedCategory!.id);
|
||||||
|
|
||||||
// Steg 2 — välj produkt
|
// Steg 2 — välj produkt
|
||||||
if (!context.mounted) return null;
|
if (!context.mounted) return null;
|
||||||
@@ -92,7 +92,7 @@ class CategoryThenProductPicker {
|
|||||||
context,
|
context,
|
||||||
products: useList,
|
products: useList,
|
||||||
value: currentProductId,
|
value: currentProductId,
|
||||||
label: 'Produkt i "${selectedCategory.name}"',
|
label: 'Produkt i "${selectedCategory!.name}"',
|
||||||
categoryFilter: null, // redan förfiltrerat
|
categoryFilter: null, // redan förfiltrerat
|
||||||
onCreate: onCreateBound,
|
onCreate: onCreateBound,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,65 @@
|
|||||||
|
|
||||||
Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda Windows-sökvägar. Använd inte `c:/dev/recipe-app/...` eftersom bygg- och testmiljön är på en remote Ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu.
|
Viktigt att komma ihåg vid implementering av nya funktioner och kodning är att inte använda Windows-sökvägar. Använd inte `c:/dev/recipe-app/...` eftersom bygg- och testmiljön är på en remote Ubuntu-server. Utveckling sker lokalt och test samt drift sker på remote server. Säkerställ att inga absoluta Windows-sökvägar används i koden, för att stödja bygg och drift på Linux/Ubuntu.
|
||||||
|
|
||||||
## Senaste ändringar (2026-05-01)
|
## Senaste ändringar (2026-05-01, session 2)
|
||||||
|
|
||||||
|
### Tvåstegs-picker: Kategori → Produkt
|
||||||
|
|
||||||
|
Problembeskrivning: AI:n kan föreslå en kategori men produktpickern sökte bara på produktnamn, vilket gav nollresultat när kategorinamnet matades in som söktext. Lösningen är ett nytt separat arbetsflöde för produktval via kategoriträdet.
|
||||||
|
|
||||||
|
**Ny widget: `lib/core/ui/category_then_product_picker.dart`**
|
||||||
|
|
||||||
|
`CategoryThenProductPicker.show()` är en statisk metod som orkestrerar hela flödet:
|
||||||
|
|
||||||
|
1. **Steg 1 — Välj kategori:** Öppnar ett bottenark (`_CategoryPickerSheet`) med hela kategoriträdet (L1 → L2 → L3). Trädet är sökbart — sökfältet filtrerar till matchande lövnoder och visar hela sökvägen som brödsmula (t.ex. *Mat > Frukt & Grönt > Äpplen*).
|
||||||
|
2. **Steg 2 — Välj produkt:** Öppnar `ProductPickerField.showSheet()` filtrerad på alla produkter som tillhör den valda kategorin **eller någon av dess ättlingar** (L1 samlar alltså in L2- och L3-produkter rekursivt via `_collectIds()`).
|
||||||
|
|
||||||
|
**Inbyggt AI-stöd:** Om `preselectedCategoryId` skickas in (från AI-förslaget) hoppar `show()` direkt till steg 2 — kategoriträdet visas aldrig. Om kategorin inte hittas i trädet faller den tillbaka till att visa trädet.
|
||||||
|
|
||||||
|
**Fallback för L1/L2-noder:** Mellanliggande noder (L1, L2) som inte är löv har en liten "Välj"-knapp till höger i raden. Klick på kategorinamnet/pilen expanderar/kollapsar som vanligt; klick på "Välj" väljer kategorin och öppnar produktpickern direkt.
|
||||||
|
|
||||||
|
**Trädknapp i redigeringsdialogen:** Bredvid det vanliga produktsökfältet finns nu en `OutlinedButton` med trädikon (🌳). Klick öppnar tvåstegs-pickern utan AI-förval.
|
||||||
|
|
||||||
|
### Skapa ny privat produkt i importflödet
|
||||||
|
|
||||||
|
Om inget i produktlistan matchar kvittoradens vara kan användaren skapa en egen produkt direkt från produktpickern.
|
||||||
|
|
||||||
|
**Flöde:**
|
||||||
|
1. Välj kategori (via trädet eller AI-direkthopp).
|
||||||
|
2. Produktpickern visas med en **"Skapa ny"**-knapp i rubrikraden.
|
||||||
|
3. En enkel dialog öppnas med ett namnfält (förfyllt med söksträngen om sådan finns).
|
||||||
|
4. Vid bekräftelse anropas `POST /products/private` — produkten skapas som privat och user-scopad.
|
||||||
|
5. Den nya produkten läggs till i den lokala produktlistan och väljs direkt.
|
||||||
|
|
||||||
|
**Privata produkter — arkitektur:**
|
||||||
|
|
||||||
|
| | Globala produkter | Privata produkter |
|
||||||
|
|---|---|---|
|
||||||
|
| Endpoint (hämta) | `GET /products` | `GET /products/mine` |
|
||||||
|
| Endpoint (skapa) | `POST /products` (admin) | `POST /products/private` (alla inloggade) |
|
||||||
|
| Synlighet | Alla användare | Bara ägaren |
|
||||||
|
| `isPrivate` | `false` | `true` |
|
||||||
|
| `normalizedName` | `normalize(name)` | `private:{userId}:{normalize(name)}` |
|
||||||
|
| Kategori | Global (admin-only) | Väljs vid skapandet, global kategori |
|
||||||
|
|
||||||
|
Importfliken laddar globala och privata produkter parallellt via `Future.wait` och slår ihop dem till en gemensam `_products`-lista. Den lokala `_localProducts`-listan i `_EditDialogState` utökas om en ny produkt skapas under dialogen, utan att en ny nätverksanrop krävs.
|
||||||
|
|
||||||
|
### Redigeringsdialog — sammanfattning av alla fält
|
||||||
|
|
||||||
|
`_EditDialog` i `receipt_import_tab.dart` innehåller nu:
|
||||||
|
- **AI-chip (grön):** Klickbar. Om AI föreslog en matchad produkt direkt → väljer den. Om AI föreslog en kategori → öppnar produktpickern filtrerad på den kategorin (utan att visa trädet).
|
||||||
|
- **Destinationsväljare:** `SegmentedButton` — Inventarie eller Baslager.
|
||||||
|
- **Produktfält + trädknapp:** `ProductPickerField` (fritext-sökning) + knapp för att öppna tvåstegs-picker.
|
||||||
|
- **Antal/Enhet:** Visas bara vid Inventarie-destination.
|
||||||
|
|
||||||
|
### Designregel bekräftad: User-scope
|
||||||
|
|
||||||
|
User-scope-principen dokumenterades formellt i båda tekniska beskrivningarna (2026-05-01). Privata produkter är det första exemplet på mönstret för resurser som är varken globala (alla ser dem) eller fullt user-owned (bara ägaren ser dem):
|
||||||
|
- `Product.isPrivate = true` + `Product.ownerId = userId`
|
||||||
|
- `normalizedName`-prefix undviker databaskollision med globala produkter
|
||||||
|
- Migration: `20260501000000_add_product_is_private`
|
||||||
|
|
||||||
|
## Senaste ändringar (2026-05-01, session 1)
|
||||||
|
|
||||||
**Kvittoimport Fas 6b — komplett:**
|
**Kvittoimport Fas 6b — komplett:**
|
||||||
- Granskningssteg i `receipt_import_tab.dart` med per-rad checkbox, redigeringsdialog och destinationsväljare.
|
- Granskningssteg i `receipt_import_tab.dart` med per-rad checkbox, redigeringsdialog och destinationsväljare.
|
||||||
|
|||||||
Reference in New Issue
Block a user