Compare commits
2 Commits
adcfa97c06
...
21dc06829a
| Author | SHA1 | Date | |
|---|---|---|---|
| 21dc06829a | |||
| a81bd6b460 |
+25
-73
@@ -15,23 +15,31 @@
|
||||
| Kvittoimport (Mistral AI, OCR, alias) | ✅ Klart |
|
||||
| Matplanering (veckovy, inköpslista) | ✅ Klart |
|
||||
| Baslager (lista, lägg till, ta bort) | ✅ Klart |
|
||||
| Admin: Produkter (edit, merge, duplicate, restore) | ✅ Klart |
|
||||
| Admin: Produkter (edit, merge, duplicate, restore, reset) | ✅ Klart |
|
||||
| Receptredigering (frontend UX) | ✅ Klart |
|
||||
| Receptbilder (upload URL) | ✅ Klart |
|
||||
| Autentisering (JWT, Auth.js v5, User-modell) | ✅ Klart |
|
||||
| Användarprofil (firstName, lastName, email) | ✅ Klart |
|
||||
| Produktkategorier — hierarkisk struktur (3 nivåer) | ✅ Klart |
|
||||
| Taggning av produkter | ✅ Klart |
|
||||
| Näringsvärden på produkter | ✅ Klart (schema + API) |
|
||||
| Kategoritilldelning i admin-UI | ✅ Klart |
|
||||
| Portionsjustering | ❌ Saknas |
|
||||
| Produktkategorier — fast lista | ❌ Saknas |
|
||||
| Receptlista — filtrering & kortvy | ✅ Klart |
|
||||
| Matplan — inventariejämförelse | ❌ Saknas |
|
||||
| Taggning av produkter | ⚠️ Delvis — kräver migration |
|
||||
| Näringsvärden på produkter | ⚠️ Delvis — kräver migration |
|
||||
| Autentisering (User-modell) | ❌ Saknas |
|
||||
| Användarspecifika produkter (UserProduct) | ❌ Saknas — kräver auth |
|
||||
| Seed produktdata med kategoritilldelning | ❌ Saknas (002-seed-products.sql.disabled) |
|
||||
| Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic |
|
||||
|
||||
---
|
||||
|
||||
## Prioriterade förbättringar
|
||||
|
||||
### 1. Portionsjustering av recept
|
||||
### 1. Seed produktdata med kategoritilldelning
|
||||
`db/init/002-seed-products.sql` är inaktiverad (`.disabled`) tills den uppdateras med rätt `categoryId` för varje produkt. Utan detta är produktdatabasen tom vid fresh install.
|
||||
- Gå igenom de ~190 produkterna och tilldela rätt kategori-ID från tabellen `Category`
|
||||
- Aktivera filen igen genom att ta bort `.disabled`-suffixet
|
||||
- Alternativt: bygg ett admin-verktyg för bulk-kategorisering
|
||||
|
||||
### 2. Portionsjustering av recept
|
||||
Recept lagras utan portionsangivelse. Lägg till ett `servings`-fält och låt användaren justera antal portioner i receptvyn — ingrediensmängderna räknas om proportionellt (t.ex. 4 → 6 pers: × 1,5).
|
||||
- **Databas:** `servings Int?` på `Recipe` i Prisma + migration
|
||||
- **Backend:** `servings` exponeras i `RecipeDto`, sätts vid create/update
|
||||
@@ -42,67 +50,10 @@ Recept lagras utan portionsangivelse. Lägg till ett `servings`-fält och låt a
|
||||
### 3. Matplanering — jämförelse mot inventariet
|
||||
Veckovy och inköpslista fungerar. Nästa steg är att visa vilka ingredienser på inköpslistan som redan finns hemma och i vilken mängd — liknande receptvyns inventory-preview. Implementeras via `GET /api/recipes/:id/inventory-preview` per recept, aggregerat på veckonivå.
|
||||
|
||||
### 4. Produktkategorier — definiera en fast lista
|
||||
Kategorier skrivs in som fritext i admin. Byt till en dropdown med fördefinierade kategorier (t.ex. "Mejeri, ost & ägg", "Kött, chark & fågel", "Frukt & Grönt") för konsistent data och bättre gruppering i baslagervyn.
|
||||
|
||||
### 5. Utökad databas med taggning
|
||||
Lägg till stöd för taggar, underkategorier och varumärke direkt på produkter. Möjliggör filtrering, sökning och rekommendationer baserade på taggar.
|
||||
|
||||
**Schemaändringar (Prisma):**
|
||||
- **`Product`** — lägg till `subcategory String?` och `brand String?` (behåll `canonicalName`)
|
||||
- **`Tag`** — ny modell: `id`, `name @unique`
|
||||
- **`ProductTag`** — ny relationstabell (many-to-many: `Product ↔ Tag`)
|
||||
|
||||
**Implementeringssteg:**
|
||||
1. Uppdatera `backend/prisma/schema.prisma` med nya modeller och relationer
|
||||
2. Kör migration: `docker exec recipe-api npm exec prisma migrate dev --name add_tags_subcategory_brand`
|
||||
3. Skapa seed-fil (`data/seed_tags.sql`) med taggar och kopplingar
|
||||
4. Kör seed-filen mot databasen
|
||||
5. Exponera `tags`, `subcategory`, `brand` i produkt-DTOs och `GET /api/products` (lägg till `?tag=` och `?subcategory=` som filterparametrar)
|
||||
6. Admin: lägg till tagg-hantering och underkategori-fält
|
||||
7. Baslager/produktlista: filtrera per tagg eller underkategori
|
||||
|
||||
**Rekommenderade taggar:** `ekologisk`, `svensk`, `laktosfri`, `glutenfri`, `vegan`, `nötfri`, `säsong`, `rökt`, `premium`, `lamm`, `korv`, `färs`, m.fl.
|
||||
|
||||
### 6. Näringsvärden på produkter
|
||||
Lägg till en `Nutrition`-modell kopplad till `Product` (one-to-one) med näringsvärden per 100g: kalorier, protein, fett, kolhydrater, salt, socker, fiber. Kan implementeras oberoende av autentisering.
|
||||
|
||||
**Schemaändring:**
|
||||
```prisma
|
||||
model Nutrition {
|
||||
id Int @id @default(autoincrement())
|
||||
calories Float?
|
||||
protein Float?
|
||||
fat Float?
|
||||
carbohydrates Float?
|
||||
salt Float?
|
||||
sugar Float?
|
||||
fiber Float?
|
||||
product Product @relation(fields: [productId], references: [id])
|
||||
productId Int @unique
|
||||
}
|
||||
```
|
||||
- **Backend:** CRUD via produktendpoints, exponeras i `ProductDto`
|
||||
- **Frontend:** Visa näringsvärden i produktdetalj och eventuellt i receptvyn (summerat per portion)
|
||||
|
||||
### 7. Autentisering — User-modell
|
||||
Förutsättning för användarspecifika produkter (punkt 10). Idag saknar hela appen autentisering — alla kan CRUD allt.
|
||||
|
||||
**Scope:** JWT-baserad auth med `User`-modell (id, name, email, passwordHash). Berör:
|
||||
- Backend: AuthModule med NestJS Guards, JWT-strategi, skyddade routes
|
||||
- Frontend: Inloggningsflöde, token-hantering i API-anrop
|
||||
- Databas: `User`-tabell + migration
|
||||
|
||||
> ⚠️ Detta är ett stort projekt i sig. Överväg om appen verkligen behöver fler användare eller om enkel HTTP Basic Auth räcker som skydd.
|
||||
|
||||
### 8. Användarspecifika produkter (UserProduct)
|
||||
Låter en användare spara egna produktvarianter med eget namn (t.ex. "Mormors Prästost") kopplade till en standardprodukt — eller fristående utan koppling. Kräver att punkt 9 (auth) är på plats.
|
||||
|
||||
> ⚠️ **Överlapp med InventoryItem:** `InventoryItem` lagrar redan productId, quantity, unit, brand, bestBeforeDate och är i princip en "användarens produkt i lager". Klargör skillnaden:
|
||||
> - `InventoryItem` = vad som finns hemma just nu (lager)
|
||||
> - `UserProduct` = ett eget produktkort/favorit som kan återanvändas utan att vara lager
|
||||
>
|
||||
> Om distinktionen inte är tydlig, riskerar `UserProduct` att duplicera `InventoryItem`-logiken.
|
||||
### 4. Bulk-kategorisering av produkter i admin
|
||||
Admin-UI:t tillåter idag att sätta kategori per produkt. För att effektivt kategorisera hundratals produkter behövs:
|
||||
- Filtervy för okategoriserade produkter
|
||||
- Möjlighet att sätta kategori på flera produkter samtidigt (bulk-select)
|
||||
|
||||
---
|
||||
|
||||
@@ -137,7 +88,8 @@ Frontend-server-actions saknar validering på inkommande fält (tom sträng, fö
|
||||
|
||||
## Produktdatabasen
|
||||
|
||||
193 svenska produkter är inseedad. Nästa naturliga steg:
|
||||
- Lägg till fler saknade produkter som dyker upp vid receptimport
|
||||
- Gå igenom produkter utan `canonicalName` i admin och fyll i dem
|
||||
- Kontrollera att `category` är ifyllt för alla produkter (för bättre gruppering i baslager)
|
||||
Produktdatabasen är just nu tom — seedfilen `db/init/002-seed-products.sql.disabled` innehåller ~190 svenska baslivsmedel men är inaktiverad tills produkterna har tilldelats rätt `categoryId`. Nästa naturliga steg:
|
||||
- Gå igenom produkterna och tilldela kategorier via admin-UI eller uppdatera seed-filen direkt
|
||||
- Aktivera seed-filen igen (`002-seed-products.sql`) för reproducerbarhet vid fresh install
|
||||
- Lägg till fler produkter som dyker upp vid receptimport
|
||||
- Kontrollera att `canonicalName` är ifyllt för alla produkter
|
||||
|
||||
@@ -30,12 +30,14 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på
|
||||
- **Lägg till och ta bort** — välj från produktlistan via dropdown, ta bort med ett klick
|
||||
|
||||
### Admin: Produkter
|
||||
- **Redigera produkter** — uppdatera visningsnamn (name), canonical name och kategori inline direkt i listan
|
||||
- **Redigera produkter** — uppdatera visningsnamn (name), canonical name, kategori (hierarkisk dropdown) och varumärke inline direkt i listan
|
||||
- **Kategoritilldelning** — välj kategori ur ett 3-nivåträd (huvudkategori → underkategori → typ) som laddas dynamiskt från API:et
|
||||
- **Ta bort produkter** — soft-delete enskilda produkter
|
||||
- **Hitta dubbletter** — identifiera produkter med samma normaliserade namn
|
||||
- **Slå ihop produkter** — merge två produktposter, flytta alla inventarieföremål till målprodukten (källan soft-deleteras)
|
||||
- **Förhandsvisning** — granska vad som kommer att hända innan merge genomförs
|
||||
- **Återställ produkter** — restore tidigare raderade produkter
|
||||
- **Återställ all produktdata** — rensningsknapp som raderar alla produkter, inventorie, taggar och kvitto-alias (behåller användare och kategorier)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+116
-25
@@ -53,16 +53,22 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
|
||||
- **Framework:** Next.js 16.2 (App Router, server + client components)
|
||||
- **Språk:** TypeScript 5.4.5
|
||||
- **UI:** React 19.2, ingen CSS-ramverk (ren CSS-in-JS och inline-stilar)
|
||||
- **Autentisering:** Auth.js v5 (next-auth beta), JWT-session, `auth()` i server components
|
||||
- **Bygg:** Standalone output, körs i Docker-container
|
||||
- **API-anrop:** Fetch mot backend och Next.js API routes
|
||||
- **API-anrop:** `fetchJson` (server-side med auth-headers) + Next.js API route-proxies (client-side)
|
||||
- **Felhantering:** Global parseErrorResponse utility, svenska felmeddelanden
|
||||
|
||||
> **Viktigt:** `Navigation.tsx` är en async server component som anropar `auth()`. Den får aldrig importeras av client components — rendera den alltid i `page.tsx` (server component).
|
||||
|
||||
### Frontend-sidor och komponenter
|
||||
|
||||
| Sida | Fil | Funktionalitet |
|
||||
|------|-----|---|
|
||||
| **Hem** | `app/page.tsx` | Startsida |
|
||||
| **Navigering** | `app/Navigation.tsx` | Huvudmeny |
|
||||
| **Navigering** | `app/Navigation.tsx` | Huvudmeny, inloggad användare, länk till profil |
|
||||
| **Inloggning** | `app/login/page.tsx` | Inloggningssida med Auth.js Credentials |
|
||||
| **Profil** | `app/profil/page.tsx` | Redigera firstName, lastName, email |
|
||||
| | `app/profil/ProfileClient.tsx` | Klientkomponent för profilformulär |
|
||||
| **Inventorie** | `app/inventory/page.tsx` | Lista, filtrera, sortera varor |
|
||||
| | `InventoryList.tsx` | Ritning av inventarieföremål |
|
||||
| | `InventoryForm.tsx` | Skapa nytt inventarieföremål |
|
||||
@@ -73,17 +79,21 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
|
||||
| | `actions.ts` | Server actions för inventarie |
|
||||
| **Recept** | `app/recipes/page.tsx` | Lista recept |
|
||||
| | `RecipePreview.tsx` | Receptförhandsvisning med inventariestatus |
|
||||
| **Lägg till recept** | `app/recipes/create/page.tsx` | Meny för receptskaping (val mellan två vägar) |
|
||||
| **Skriv in recept** | `app/recipes/write/page.tsx` | Startpunkt för Markdown-inmatning |
|
||||
| | `app/recipes/write/WriteRecipePage.tsx` | Komponenter för receptskapande (Markdown-baserat, 3-steg) |
|
||||
| **Lägg till recept** | `app/recipes/create/page.tsx` | Server component med Navigation |
|
||||
| | `app/recipes/create/CreateRecipeClient.tsx` | Klientkomponent: snabbimport + metodval |
|
||||
| **Skriv in recept** | `app/recipes/write/page.tsx` | Server component med Navigation |
|
||||
| | `app/recipes/write/WriteRecipePage.tsx` | Markdown-baserat receptskapande (3-steg) |
|
||||
| **Importera från fil** | `app/recipes/import/page.tsx` | Startpunkt för fil/länk-import |
|
||||
| | `app/recipes/import/ImportFilePage.tsx` | Komponenter för fil-/länk-import (PDF, URL, etc) |
|
||||
| | `app/recipes/import/ImportFilePage.tsx` | Fil-/länk-import (PDF, URL, etc) |
|
||||
| **Import (flikar)** | `app/import/page.tsx` | Server component med Navigation + flikvy |
|
||||
| | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar |
|
||||
| **Recipe detail** | `app/recipes/[id]/` | Enskilt recept (detaljer, redigering) |
|
||||
| **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel |
|
||||
| | `AdminProductList.tsx` | Lista produkter, sök, sortera |
|
||||
| | `EditProductForm.tsx` | Inline redigering av name, canonicalName, category + soft-delete |
|
||||
| | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar |
|
||||
| | `ResetProductsButton.tsx` | Knapp för att rensa all produktdata |
|
||||
| | `MergePreviewForm.tsx` | Förhandsgranska merge |
|
||||
| | `actions.ts` | Server actions: updateProduct, deleteProduct |
|
||||
| | `actions.ts` | Server actions: updateProduct, deleteProduct, resetAllProducts |
|
||||
| **Baslager** | `app/baslager/page.tsx` | Visa och hantera baslager (server component) |
|
||||
| | `AddToPantryForm.tsx` | Lägg till produkt i baslager (dropdown) |
|
||||
| | `PantryList.tsx` | Visa baslager grupperat per kategori |
|
||||
@@ -91,15 +101,29 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
|
||||
|
||||
### API-proxy routes (Next.js)
|
||||
|
||||
Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordrar `Authorization: Bearer <token>` till backend.
|
||||
|
||||
| Route | Metod | Syfte |
|
||||
|-------|-------|-------|
|
||||
| `/api/quick-import-proxy` | POST | Proxies `POST /api/quick-import` för URL-, PDF- och bildimport |
|
||||
| `/api/parse-markdown-proxy` | POST | Proxies `POST /api/recipes/parse-markdown` (Markdown-tolkning för skriv-in-recept) |
|
||||
| `/api/inventory-history-proxy` | GET | Proxies konsumtionshistorik |
|
||||
| `/api/recipe-preview-proxy` | GET | Proxies receptförhandsvisning |
|
||||
| `/api/admin/merge-preview-proxy` | GET | Proxies produktmerge-preview |
|
||||
| `/api/products` | GET | Lista/proxies produkter |
|
||||
| `/api/recipes` | GET, POST | Lista recept + spara nytt recept (proxy till backend) |
|
||||
| `/api/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) |
|
||||
| `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) |
|
||||
| `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) |
|
||||
| `/api/profile` | GET, PATCH | Hämta/uppdatera användarprofil |
|
||||
| `/api/recipes` | GET, POST | Lista recept + spara nytt |
|
||||
| `/api/quick-import-proxy` | POST | URL-, PDF- och bildimport |
|
||||
| `/api/parse-markdown-proxy` | POST | Markdown-tolkning för skriv-in-recept |
|
||||
| `/api/inventory-history-proxy` | GET | Konsumtionshistorik |
|
||||
| `/api/recipe-preview-proxy` | GET | Receptförhandsvisning |
|
||||
| `/api/admin/merge-preview-proxy` | GET | Produktmerge-preview |
|
||||
| `/api/receipt-import-proxy` | POST | Kvittoimport via Mistral AI |
|
||||
| `/api/user-products` | GET, POST, DELETE | Användarspecifika produkter |
|
||||
|
||||
### Autentisering (Auth.js v5)
|
||||
|
||||
- `auth.ts` — NextAuth-konfiguration med Credentials provider
|
||||
- `middleware.ts` — Skyddar alla routes utom `/login`, `/register` och `/api/auth`
|
||||
- `lib/auth-headers.ts` — `getAuthHeaders()` hämtar Bearer-token från session (server-side)
|
||||
- `lib/api.ts` — `fetchJson()` lägger automatiskt till auth-headers server-side, redirectar till `/login` vid 401
|
||||
|
||||
### Frontend utbyggbarhet
|
||||
|
||||
@@ -115,24 +139,41 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;"
|
||||
- **Språk:** TypeScript 5.4.5
|
||||
- **Databas:** MariaDB 11 (via Prisma 6.12.0 ORM)
|
||||
- **API:** REST, validering med class-validator
|
||||
- **Autentisering:** JWT (7 dagars token), JwtAuthGuard skyddar alla routes, `@Public()` dekorator för öppna endpoints
|
||||
- **Felhantering:** GlobalExceptionFilter (svenska felmeddelanden)
|
||||
- **Hälsokontroll:** /health endpoints
|
||||
- **Bygg:** `nest build`, körs i Docker-container
|
||||
|
||||
### Backend-moduler och strukturen läsa
|
||||
### Backend-moduler och strukturen
|
||||
|
||||
```
|
||||
backend/src/
|
||||
├── app.module.ts # Root module
|
||||
├── main.ts # Startpunkt (port 8080)
|
||||
├── main.ts # Startpunkt (port 8080, global prefix "api")
|
||||
├── auth/
|
||||
│ ├── auth.controller.ts # POST /api/auth/login
|
||||
│ ├── auth.service.ts # validateUser, login (JWT-signering)
|
||||
│ ├── auth.module.ts
|
||||
│ ├── jwt.strategy.ts # Passport JWT-strategi
|
||||
│ ├── jwt-auth.guard.ts # Global guard (skyddar allt utom @Public)
|
||||
│ └── decorators/
|
||||
│ └── public.decorator.ts # @Public() – markerar öppen endpoint
|
||||
├── users/
|
||||
│ ├── users.controller.ts # GET/PATCH /api/users/me
|
||||
│ ├── users.service.ts # findByUsername, create, updateProfile
|
||||
│ └── users.module.ts
|
||||
├── categories/
|
||||
│ ├── categories.controller.ts # GET /api/categories, GET /api/categories/tree (@Public)
|
||||
│ ├── categories.service.ts # findAll (flat), findTree (hierarkisk)
|
||||
│ └── categories.module.ts
|
||||
├── common/
|
||||
│ ├── filters/
|
||||
│ │ └── global-exception.filter.ts # Centraliserad felhantering
|
||||
│ └── utils/
|
||||
│ └── normalize-name.ts # Namnormalisering
|
||||
├── health/
|
||||
│ ├── health.controller.ts # GET /health, /health/db
|
||||
│ ├── health.service.ts # Hälsotillstånd-logik
|
||||
│ ├── health.controller.ts # GET /health, /health/db (@Public)
|
||||
│ ├── health.service.ts
|
||||
│ └── health.module.ts
|
||||
├── inventory/
|
||||
│ ├── inventory.controller.ts # CRUD endpoints
|
||||
@@ -146,8 +187,8 @@ backend/src/
|
||||
│ ├── prisma.service.ts # PrismaClient wrapper
|
||||
│ └── prisma.module.ts
|
||||
├── products/
|
||||
│ ├── products.controller.ts # CRUD, merge, duplicates
|
||||
│ ├── products.service.ts # Produktlogik
|
||||
│ ├── products.controller.ts # CRUD, merge, duplicates, reset-all
|
||||
│ ├── products.service.ts # Produktlogik inkl. resetAll()
|
||||
│ ├── products.module.ts
|
||||
│ └── dto/
|
||||
│ ├── create-product.dto.ts
|
||||
@@ -291,9 +332,23 @@ GET /api/products/merge-preview Förhandsgranska merge
|
||||
POST /api/products/merge Slå ihop två produkter
|
||||
PATCH /api/products/:id/canonical-name Uppdatera canonical name
|
||||
POST /api/products/backfill-canonical Backfill canonical names (admin)
|
||||
POST /api/products/reset-all Rensa all produktdata (admin)
|
||||
```
|
||||
|
||||
### 🛀 Baslager-endpoints
|
||||
### Kategori-endpoints
|
||||
```
|
||||
GET /api/categories Flat lista av alla kategorier (@Public)
|
||||
GET /api/categories/tree Hierarkiskt träd (@Public)
|
||||
```
|
||||
|
||||
### Användar-endpoints
|
||||
```
|
||||
POST /api/auth/login Logga in, returnerar JWT (@Public)
|
||||
GET /api/users/me Hämta inloggad användares profil
|
||||
PATCH /api/users/me Uppdatera firstName, lastName, email
|
||||
```
|
||||
|
||||
### Baslager-endpoints
|
||||
```
|
||||
GET /api/pantry Lista alla baslagerartiklar (inkl. produktinfo)
|
||||
POST /api/pantry Lägg till produkt i baslagret
|
||||
@@ -304,6 +359,36 @@ DELETE /api/pantry/:id Ta bort produkt från baslagret
|
||||
|
||||
## Datamodell (Prisma ORM)
|
||||
|
||||
### User
|
||||
```prisma
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
username String @unique
|
||||
email String @unique
|
||||
firstName String?
|
||||
lastName String?
|
||||
passwordHash String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
```
|
||||
|
||||
### Category
|
||||
```prisma
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
parentId Int?
|
||||
parent Category? @relation("CategoryTree", ...) # Förälder (null = toppnivå)
|
||||
children Category[] @relation("CategoryTree") # Underkategorier
|
||||
products Product[]
|
||||
|
||||
@@unique([name, parentId])
|
||||
}
|
||||
```
|
||||
Hierarkin har 3 nivåer: **Huvudkategori → Underkategori → Typ**
|
||||
Exempelträd: `Mejeri, ost & ägg → Mjölk → Laktosfri mjölk`
|
||||
|
||||
### Product
|
||||
```prisma
|
||||
model Product {
|
||||
@@ -311,14 +396,20 @@ model Product {
|
||||
name String # Visningsnamn
|
||||
normalizedName String @unique # Normaliserat namn (lowercase, utan skiljetecken)
|
||||
canonicalName String? # Canonical namn för receptmatchning
|
||||
category String? # Produktkategori
|
||||
isActive Boolean @default(true) # Soft-delete flag
|
||||
deletedAt DateTime? # Tidpunkt för soft-delete
|
||||
category String? # Fritext-kategori (äldre fält, ersätts av categoryRef)
|
||||
subcategory String? # Fritext-underkategori (äldre fält)
|
||||
brand String? # Varumärke
|
||||
categoryId Int? # FK till Category (ny hierarki)
|
||||
categoryRef Category? # Relation till Category
|
||||
isActive Boolean @default(true)
|
||||
deletedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
inventoryItems InventoryItem[]
|
||||
recipeIngredients RecipeIngredient[]
|
||||
tags ProductTag[]
|
||||
nutrition Nutrition?
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `MealPlanEntry` ADD COLUMN `servings` INTEGER NULL;
|
||||
@@ -181,6 +181,7 @@ model MealPlanEntry {
|
||||
date DateTime @db.Date
|
||||
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||
recipeId Int
|
||||
servings Int?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsDateString, IsInt, IsPositive } from 'class-validator';
|
||||
import { IsDateString, IsInt, IsOptional, IsPositive, Min } from 'class-validator';
|
||||
|
||||
export class CreateMealPlanEntryDto {
|
||||
@IsDateString()
|
||||
@@ -7,4 +7,9 @@ export class CreateMealPlanEntryDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
recipeId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
servings?: number;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const recipeSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
imageUrl: true,
|
||||
servings: true,
|
||||
ingredients: {
|
||||
select: {
|
||||
quantity: true,
|
||||
@@ -36,8 +37,8 @@ export class MealPlanService {
|
||||
const date = new Date(dto.date);
|
||||
return this.prisma.mealPlanEntry.upsert({
|
||||
where: { date },
|
||||
create: { date, recipeId: dto.recipeId },
|
||||
update: { recipeId: dto.recipeId },
|
||||
create: { date, recipeId: dto.recipeId, servings: dto.servings ?? null },
|
||||
update: { recipeId: dto.recipeId, servings: dto.servings ?? null },
|
||||
include: { recipe: { select: recipeSelect } },
|
||||
});
|
||||
}
|
||||
@@ -55,13 +56,16 @@ export class MealPlanService {
|
||||
async shoppingList(from: string, to: string) {
|
||||
const entries = await this.findByRange(from, to);
|
||||
|
||||
// Summera ingredienser per produkt+enhet
|
||||
// Summera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
|
||||
for (const entry of entries) {
|
||||
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||
const entryServings = (entry as any).servings as number | null;
|
||||
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||
for (const ing of entry.recipe.ingredients) {
|
||||
const key = `${ing.product.id}-${ing.unit}`;
|
||||
const existing = map.get(key);
|
||||
const qty = Number(ing.quantity);
|
||||
const qty = Number(ing.quantity) * scale;
|
||||
if (existing) {
|
||||
existing.quantity += qty;
|
||||
} else {
|
||||
@@ -82,12 +86,15 @@ export class MealPlanService {
|
||||
async inventoryCompare(from: string, to: string) {
|
||||
const entries = await this.findByRange(from, to);
|
||||
|
||||
// Aggregera ingredienser per produkt+enhet
|
||||
// Aggregera ingredienser per produkt+enhet (skalat per portionsantal)
|
||||
const map = new Map<string, { productId: number; name: string; required: number; unit: string }>();
|
||||
for (const entry of entries) {
|
||||
const recipeServings = (entry.recipe as any).servings as number | null;
|
||||
const entryServings = (entry as any).servings as number | null;
|
||||
const scale = recipeServings && entryServings ? entryServings / recipeServings : 1;
|
||||
for (const ing of entry.recipe.ingredients) {
|
||||
const key = `${ing.product.id}-${ing.unit}`;
|
||||
const qty = Number(ing.quantity);
|
||||
const qty = Number(ing.quantity) * scale;
|
||||
const existing = map.get(key);
|
||||
if (existing) {
|
||||
existing.required += qty;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { IsArray, IsInt, IsNumber, IsOptional, ArrayMinSize } from 'class-validator';
|
||||
|
||||
export class BulkUpdateProductsDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@IsInt({ each: true })
|
||||
ids: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
categoryId?: number | null;
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { ProductsService } from './products.service';
|
||||
import { MergeProductsDto } from './dto/merge-products.dto';
|
||||
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
||||
import { SetTagsDto } from './dto/set-tags.dto';
|
||||
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
||||
import { BulkUpdateProductsDto } from './dto/bulk-update-products.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductsController {
|
||||
@@ -116,4 +116,10 @@ export class ProductsController {
|
||||
resetAll() {
|
||||
return this.productsService.resetAll();
|
||||
}
|
||||
|
||||
@Post('bulk-update')
|
||||
@HttpCode(200)
|
||||
bulkUpdate(@Body() body: BulkUpdateProductsDto) {
|
||||
return this.productsService.bulkUpdate(body.ids, { categoryId: body.categoryId });
|
||||
}
|
||||
}
|
||||
@@ -397,4 +397,14 @@ export class ProductsService {
|
||||
]);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async bulkUpdate(ids: number[], data: { categoryId?: number | null }) {
|
||||
const updateData: Record<string, any> = {};
|
||||
if ('categoryId' in data) {
|
||||
updateData.categoryId = data.categoryId;
|
||||
}
|
||||
if (Object.keys(updateData).length === 0) return { updated: 0 };
|
||||
await this.prisma.product.updateMany({ where: { id: { in: ids } }, data: updateData });
|
||||
return { updated: ids.length };
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import type { Product } from '../../../features/inventory/types';
|
||||
import { useState, useMemo, useEffect, useTransition } from 'react';
|
||||
import type { Product, Category } from '../../../features/inventory/types';
|
||||
import EditProductForm from './EditProductForm';
|
||||
import { bulkSetCategory } from './actions';
|
||||
|
||||
type CategoryNode = Category & { children: CategoryNode[] };
|
||||
|
||||
type Props = {
|
||||
products: Product[];
|
||||
@@ -13,21 +16,48 @@ const sortOptions = [
|
||||
{ value: 'nameAsc', label: 'Namn A–Ö' },
|
||||
];
|
||||
|
||||
function flattenTree(nodes: CategoryNode[], depth = 0): { id: number; label: string }[] {
|
||||
const result: { id: number; label: string }[] = [];
|
||||
for (const node of nodes) {
|
||||
result.push({ id: node.id, label: '\u00a0\u00a0'.repeat(depth) + (depth > 0 ? '↳ ' : '') + node.name });
|
||||
if (node.children?.length) result.push(...flattenTree(node.children, depth + 1));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export default function AdminProductList({ products }: Props) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState('createdDesc');
|
||||
const [showUncategorizedOnly, setShowUncategorizedOnly] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [bulkCategoryId, setBulkCategoryId] = useState<string>('');
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
const [bulkError, setBulkError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/categories')
|
||||
.then((r) => r.json())
|
||||
.then((data) => { if (Array.isArray(data)) setCategoryTree(data); })
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const categoryOptions = useMemo(() => flattenTree(categoryTree), [categoryTree]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
|
||||
let result = q
|
||||
? products.filter(
|
||||
(p) =>
|
||||
let result = products.filter((p) => {
|
||||
if (showUncategorizedOnly && p.categoryId != null) return false;
|
||||
if (q) {
|
||||
return (
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
(p.canonicalName ?? '').toLowerCase().includes(q) ||
|
||||
(p.normalizedName ?? '').toLowerCase().includes(q),
|
||||
)
|
||||
: [...products];
|
||||
(p.normalizedName ?? '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (sort === 'nameAsc') {
|
||||
result.sort((a, b) =>
|
||||
@@ -38,31 +68,61 @@ export default function AdminProductList({ products }: Props) {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [products, search, sort]);
|
||||
}, [products, search, sort, showUncategorizedOnly]);
|
||||
|
||||
const allVisibleSelected = filtered.length > 0 && filtered.every((p) => selectedIds.has(p.id));
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (allVisibleSelected) {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((p) => next.delete(p.id));
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
filtered.forEach((p) => next.add(p.id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkApply = () => {
|
||||
setBulkError(null);
|
||||
const ids = Array.from(selectedIds);
|
||||
if (ids.length === 0) return;
|
||||
const categoryId = bulkCategoryId === '' ? null : bulkCategoryId === '__remove__' ? null : Number(bulkCategoryId);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await bulkSetCategory(ids, categoryId);
|
||||
setSelectedIds(new Set());
|
||||
setBulkCategoryId('');
|
||||
} catch (err) {
|
||||
setBulkError(err instanceof Error ? err.message : 'Fel vid uppdatering');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '1rem',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1rem',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{/* Sök + sortering + filter */}
|
||||
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Sök produkt…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
style={{
|
||||
flex: '1 1 200px',
|
||||
padding: '0.5rem 0.75rem',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '6px',
|
||||
fontSize: '1rem',
|
||||
}}
|
||||
style={{ flex: '1 1 200px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '1rem' }}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
|
||||
@@ -84,39 +144,116 @@ export default function AdminProductList({ products }: Props) {
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowUncategorizedOnly((v) => !v); setSelectedIds(new Set()); }}
|
||||
style={{
|
||||
padding: '0.45rem 0.75rem',
|
||||
borderRadius: '999px',
|
||||
border: '1px solid ' + (showUncategorizedOnly ? '#f59e0b' : '#ddd'),
|
||||
background: showUncategorizedOnly ? '#fffbeb' : '#fff',
|
||||
fontWeight: showUncategorizedOnly ? 600 : 400,
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem',
|
||||
color: showUncategorizedOnly ? '#92400e' : 'inherit',
|
||||
}}
|
||||
>
|
||||
Okategoriserade
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{search && (
|
||||
<span style={{ color: '#666', fontSize: '0.9rem', whiteSpace: 'nowrap' }}>
|
||||
{filtered.length} av {products.length} produkter
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk-åtgärd */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div style={{
|
||||
display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap',
|
||||
padding: '0.75rem 1rem', marginBottom: '1rem',
|
||||
background: '#f0f7ff', border: '1px solid #bfdbfe', borderRadius: '8px',
|
||||
}}>
|
||||
<span style={{ fontWeight: 600, fontSize: '0.9rem' }}>{selectedIds.size} valda</span>
|
||||
<select
|
||||
value={bulkCategoryId}
|
||||
onChange={(e) => setBulkCategoryId(e.target.value)}
|
||||
style={{ padding: '0.4rem 0.6rem', border: '1px solid #ddd', borderRadius: '6px', fontSize: '0.9rem', minWidth: '200px' }}
|
||||
>
|
||||
<option value="">Välj kategori…</option>
|
||||
<option value="__remove__">— Ta bort kategori —</option>
|
||||
{categoryOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBulkApply}
|
||||
disabled={isPending}
|
||||
style={{ padding: '0.4rem 0.9rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 600, fontSize: '0.9rem' }}
|
||||
>
|
||||
{isPending ? 'Sparar…' : 'Sätt kategori'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
style={{ padding: '0.4rem 0.6rem', background: 'transparent', border: '1px solid #ddd', borderRadius: '6px', cursor: 'pointer', fontSize: '0.9rem' }}
|
||||
>
|
||||
Avmarkera
|
||||
</button>
|
||||
{bulkError && <span style={{ color: '#dc2626', fontSize: '0.85rem' }}>{bulkError}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Välj alla synliga */}
|
||||
{filtered.length > 0 && (
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', cursor: 'pointer', fontSize: '0.875rem', color: '#555' }}>
|
||||
<input type="checkbox" checked={allVisibleSelected} onChange={toggleSelectAll} />
|
||||
Välj alla synliga ({filtered.length})
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'grid', gap: '1rem' }}>
|
||||
{filtered.map((product) => (
|
||||
<article
|
||||
key={product.id}
|
||||
style={{
|
||||
border: '1px solid #ddd',
|
||||
border: selectedIds.has(product.id) ? '1px solid #93c5fd' : '1px solid #ddd',
|
||||
borderRadius: '8px',
|
||||
padding: '1rem',
|
||||
display: 'grid',
|
||||
gap: '0.5rem',
|
||||
background: selectedIds.has(product.id) ? '#f0f7ff' : undefined,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.6rem' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(product.id)}
|
||||
onChange={() => toggleSelect(product.id)}
|
||||
style={{ marginTop: '3px', flexShrink: 0 }}
|
||||
/>
|
||||
<div>
|
||||
<strong>{product.canonicalName || product.name}</strong>
|
||||
{product.canonicalName && product.canonicalName !== product.name && (
|
||||
<span style={{ color: '#666', fontSize: '0.85rem', marginLeft: '0.5rem' }}>({product.name})</span>
|
||||
)}
|
||||
{product.category && (
|
||||
{product.categoryRef ? (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#e0f2fe', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#0369a1' }}>
|
||||
{[product.categoryRef.parent?.parent?.name, product.categoryRef.parent?.name, product.categoryRef.name].filter(Boolean).join(' › ')}
|
||||
</span>
|
||||
) : product.category ? (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', background: '#eee', borderRadius: '999px', padding: '0.15rem 0.5rem', color: '#555' }}>
|
||||
{product.category}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ marginLeft: '0.5rem', fontSize: '0.8rem', color: '#f59e0b', fontStyle: 'italic' }}>Okategoriserad</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span style={{ color: '#aaa', fontSize: '0.8rem' }}>ID: {product.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||
@@ -129,3 +266,15 @@ export default function AdminProductList({ products }: Props) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
</div>
|
||||
<div style={{ fontSize: '0.8rem', color: '#888' }}>
|
||||
Normalized: {product.normalizedName}
|
||||
</div>
|
||||
<EditProductForm product={product} />
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,3 +88,20 @@ export async function resetAllProducts() {
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
export async function bulkSetCategory(ids: number[], categoryId: number | null) {
|
||||
if (ids.length === 0) return;
|
||||
const res = await fetch(`${API_BASE}/api/products/bulk-update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...(await getAuthHeaders()) },
|
||||
body: JSON.stringify({ ids, categoryId }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Kunde inte uppdatera produkter: ${text}`);
|
||||
}
|
||||
|
||||
revalidatePath('/admin/products');
|
||||
}
|
||||
|
||||
@@ -9,7 +9,9 @@ const DAYS_SV = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag',
|
||||
type MealPlanEntry = {
|
||||
id: number;
|
||||
date: string;
|
||||
servings: number | null;
|
||||
recipe: Pick<Recipe, 'id' | 'name' | 'imageUrl'> & {
|
||||
servings: number | null;
|
||||
ingredients: { quantity: string; unit: string; note: string | null; product: { id: number; name: string; canonicalName: string | null } }[];
|
||||
};
|
||||
};
|
||||
@@ -89,10 +91,11 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
||||
if (!recipeId) {
|
||||
await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' });
|
||||
} else {
|
||||
const existing = entryForDate(date);
|
||||
await fetch('/api/meal-plan-proxy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date, recipeId: Number(recipeId) }),
|
||||
body: JSON.stringify({ date, recipeId: Number(recipeId), servings: existing?.servings ?? null }),
|
||||
});
|
||||
}
|
||||
await load();
|
||||
@@ -103,6 +106,17 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
||||
|
||||
const plannedCount = weekDates.filter((d) => entryForDate(d)).length;
|
||||
|
||||
const handleServingsChange = async (date: string, servings: number | null) => {
|
||||
const entry = entryForDate(date);
|
||||
if (!entry) return;
|
||||
await fetch('/api/meal-plan-proxy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ date, recipeId: entry.recipe.id, servings }),
|
||||
});
|
||||
await load();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Veckonavigering */}
|
||||
@@ -173,6 +187,29 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
||||
Visa recept →
|
||||
</Link>
|
||||
)}
|
||||
{entry && entry.recipe.servings && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem', color: '#555' }}>
|
||||
<span>Port.:</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={entry.servings ?? entry.recipe.servings}
|
||||
onChange={(e) => handleServingsChange(date, e.target.value ? Number(e.target.value) : null)}
|
||||
style={{ width: '52px', padding: '0.25rem 0.4rem', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '0.82rem' }}
|
||||
/>
|
||||
{entry.servings && entry.servings !== entry.recipe.servings && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleServingsChange(date, null)}
|
||||
title={`Återställ till ${entry.recipe.servings} portioner`}
|
||||
style={{ fontSize: '0.75rem', color: '#888', background: 'none', border: 'none', cursor: 'pointer', padding: '0 0.2rem' }}
|
||||
>
|
||||
↩ {entry.recipe.servings}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -42,6 +42,7 @@ export default function WriteRecipePage() {
|
||||
const [editedName, setEditedName] = useState('');
|
||||
const [editedDescription, setEditedDescription] = useState('');
|
||||
const [editedInstructions, setEditedInstructions] = useState('');
|
||||
const [editedServings, setEditedServings] = useState<number | null>(null);
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null);
|
||||
const [ingredients, setIngredients] = useState<ParsedIngredientRow[]>([]);
|
||||
const [allProducts, setAllProducts] = useState<Product[]>([]);
|
||||
@@ -180,6 +181,7 @@ export default function WriteRecipePage() {
|
||||
description: editedDescription || undefined,
|
||||
instructions: editedInstructions || undefined,
|
||||
imageUrl: imageUrl || undefined,
|
||||
servings: editedServings ?? undefined,
|
||||
ingredients: validIngredients.map((ing) => ({
|
||||
productId: ing.selectedProductId,
|
||||
quantity: Number(ing.editedQuantity),
|
||||
@@ -358,6 +360,20 @@ Stek löken i lite smör. Tillsätt köttfärsen...`}</pre>
|
||||
style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '150px', fontFamily: 'monospace', boxSizing: 'border-box' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>Portioner</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={editedServings ?? ''}
|
||||
onChange={(e) => setEditedServings(e.target.value ? Number(e.target.value) : null)}
|
||||
placeholder="t.ex. 4"
|
||||
style={{ width: '120px', padding: '0.5rem 0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem' }}
|
||||
/>
|
||||
<p style={{ fontSize: '0.82rem', color: '#888', margin: '0.3rem 0 0' }}>Anges portioner kan mängderna skalas på receptsidan.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ingredienser */}
|
||||
|
||||
@@ -21,6 +21,13 @@ export type Nutrition = {
|
||||
fiber: number | null;
|
||||
};
|
||||
|
||||
export type Category = {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
parent: Category | null;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -35,6 +42,8 @@ export type Product = {
|
||||
updatedAt: string;
|
||||
tags?: ProductTag[];
|
||||
nutrition?: Nutrition | null;
|
||||
categoryId?: number | null;
|
||||
categoryRef?: Category | null;
|
||||
};
|
||||
|
||||
export type InventoryItem = {
|
||||
|
||||
Reference in New Issue
Block a user