feat: enhance product model with subcategory, brand, tags, and nutrition; update related DTOs and services
This commit is contained in:
+49
-18
@@ -16,22 +16,22 @@
|
|||||||
| Matplanering (veckovy, inköpslista) | ✅ Klart |
|
| Matplanering (veckovy, inköpslista) | ✅ Klart |
|
||||||
| Baslager (lista, lägg till, ta bort) | ✅ Klart |
|
| Baslager (lista, lägg till, ta bort) | ✅ Klart |
|
||||||
| Admin: Produkter (edit, merge, duplicate, restore) | ✅ Klart |
|
| Admin: Produkter (edit, merge, duplicate, restore) | ✅ Klart |
|
||||||
| Receptredigering (frontend UX) | ⚠️ Delvis |
|
| Receptredigering (frontend UX) | ✅ Klart |
|
||||||
| Receptbilder (upload UI) | ⚠️ Delvis |
|
| Receptbilder (upload URL) | ✅ Klart |
|
||||||
| Portionsjustering | ❌ Saknas |
|
| Portionsjustering | ❌ Saknas |
|
||||||
| Produktkategorier — fast lista | ❌ Saknas |
|
| Produktkategorier — fast lista | ❌ Saknas |
|
||||||
| Receptlista — filtrering & kortvy | ❌ Saknas |
|
| Receptlista — filtrering & kortvy | ✅ Klart |
|
||||||
| Matplan — inventariejämförelse | ❌ Saknas |
|
| Matplan — inventariejämförelse | ❌ Saknas |
|
||||||
| Taggning av produkter | ❌ 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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Prioriterade förbättringar
|
## Prioriterade förbättringar
|
||||||
|
|
||||||
### 1. Receptredigering — verifiera och slutför frontend-flödet
|
### 1. Portionsjustering av recept
|
||||||
Backend (`PATCH /api/recipes/:id`) är fullt implementerat och hanterar namn, beskrivning, instruktioner, `imageUrl` och ingredienser. Redigeringskoden i `app/recipes/[id]/RecipeDetailClient.tsx` finns men flödet för spara/avbryt behöver verifieras och eventuellt slutföras. `/recipes/[id]/edit/page.tsx` redirectar i dag tillbaka till detaljsidan — ta bort den omdirigering om redigering sker inline.
|
|
||||||
|
|
||||||
### 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).
|
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
|
- **Databas:** `servings Int?` på `Recipe` i Prisma + migration
|
||||||
- **Backend:** `servings` exponeras i `RecipeDto`, sätts vid create/update
|
- **Backend:** `servings` exponeras i `RecipeDto`, sätts vid create/update
|
||||||
@@ -45,16 +45,7 @@ Veckovy och inköpslista fungerar. Nästa steg är att visa vilka ingredienser p
|
|||||||
### 4. Produktkategorier — definiera en fast lista
|
### 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.
|
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. Receptbilder — upload-UI i frontend
|
### 5. Utökad databas med taggning
|
||||||
Backend har `POST /api/recipes/:id/image` som tar emot en URL, laddar ner och optimerar bilden. `imageUrl` finns i databasen och formuläret i `write/WriteRecipePage.tsx` har redan ett `imageUrl`-fält. Saknas: ett upload-flöde eller URL-inmatning med förhandsgranskning i receptdetaljvyn (`app/recipes/[id]/RecipeDetailClient.tsx`).
|
|
||||||
|
|
||||||
### 6. Filtrering och kortvy för receptlistan
|
|
||||||
Receptlistan (`app/recipes/RecipeGrid.tsx`) är en platt lista utan filter. Lägg till:
|
|
||||||
- Söka på namn — klientside
|
|
||||||
- Sortera på namn A–Ö eller senast tillagd — klientside
|
|
||||||
- Kortrutnät med receptbild, namn och eventuellt portionsantal (efter att #2 är klar)
|
|
||||||
|
|
||||||
### 7. 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.
|
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):**
|
**Schemaändringar (Prisma):**
|
||||||
@@ -73,6 +64,46 @@ Lägg till stöd för taggar, underkategorier och varumärke direkt på produkte
|
|||||||
|
|
||||||
**Rekommenderade taggar:** `ekologisk`, `svensk`, `laktosfri`, `glutenfri`, `vegan`, `nötfri`, `säsong`, `rökt`, `premium`, `lamm`, `korv`, `färs`, m.fl.
|
**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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Teknisk skuld och städning
|
## Teknisk skuld och städning
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ model Product {
|
|||||||
name String
|
name String
|
||||||
normalizedName String @unique
|
normalizedName String @unique
|
||||||
category String?
|
category String?
|
||||||
|
subcategory String?
|
||||||
|
brand String?
|
||||||
canonicalName String?
|
canonicalName String?
|
||||||
isActive Boolean @default(true)
|
isActive Boolean @default(true)
|
||||||
deletedAt DateTime?
|
deletedAt DateTime?
|
||||||
@@ -22,6 +24,8 @@ model Product {
|
|||||||
recipeIngredients RecipeIngredient[]
|
recipeIngredients RecipeIngredient[]
|
||||||
pantryItems PantryItem[]
|
pantryItems PantryItem[]
|
||||||
receiptAliases ReceiptAlias[]
|
receiptAliases ReceiptAlias[]
|
||||||
|
tags ProductTag[]
|
||||||
|
nutrition Nutrition?
|
||||||
}
|
}
|
||||||
|
|
||||||
model InventoryItem {
|
model InventoryItem {
|
||||||
@@ -116,3 +120,32 @@ model MealPlanEntry {
|
|||||||
@@unique([date])
|
@@unique([date])
|
||||||
@@index([date])
|
@@index([date])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Tag {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
name String @unique
|
||||||
|
products ProductTag[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model ProductTag {
|
||||||
|
productId Int
|
||||||
|
tagId Int
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([productId, tagId])
|
||||||
|
@@index([tagId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Nutrition {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
productId Int @unique
|
||||||
|
calories Float?
|
||||||
|
protein Float?
|
||||||
|
fat Float?
|
||||||
|
carbohydrates Float?
|
||||||
|
salt Float?
|
||||||
|
sugar Float?
|
||||||
|
fiber Float?
|
||||||
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { IsArray, IsString, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SetTagsDto {
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@MaxLength(100, { each: true })
|
||||||
|
tags!: string[];
|
||||||
|
}
|
||||||
@@ -16,4 +16,14 @@ export class UpdateProductDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(191)
|
@MaxLength(191)
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(191)
|
||||||
|
subcategory?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(191)
|
||||||
|
brand?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { IsNumber, IsOptional, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpsertNutritionDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
calories?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
protein?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fat?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
carbohydrates?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
salt?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
sugar?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
fiber?: number;
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ParseIntPipe,
|
ParseIntPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { CreateProductDto } from './dto/create-product.dto';
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
@@ -14,14 +15,24 @@ import { UpdateProductDto } from './dto/update-product.dto';
|
|||||||
import { ProductsService } from './products.service';
|
import { ProductsService } from './products.service';
|
||||||
import { MergeProductsDto } from './dto/merge-products.dto';
|
import { MergeProductsDto } from './dto/merge-products.dto';
|
||||||
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
import { UpdateCanonicalNameDto } from './dto/update-canonical-name.dto';
|
||||||
|
import { SetTagsDto } from './dto/set-tags.dto';
|
||||||
|
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
||||||
|
|
||||||
@Controller('products')
|
@Controller('products')
|
||||||
export class ProductsController {
|
export class ProductsController {
|
||||||
constructor(private readonly productsService: ProductsService) {}
|
constructor(private readonly productsService: ProductsService) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
findAll() {
|
findAll(
|
||||||
return this.productsService.findAll();
|
@Query('tag') tag?: string,
|
||||||
|
@Query('subcategory') subcategory?: string,
|
||||||
|
) {
|
||||||
|
return this.productsService.findAll({ tag, subcategory });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('tags')
|
||||||
|
findAllTags() {
|
||||||
|
return this.productsService.findAllTags();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('duplicates')
|
@Get('duplicates')
|
||||||
@@ -65,6 +76,22 @@ export class ProductsController {
|
|||||||
return this.productsService.updateCanonicalName(id, body.canonicalName);
|
return this.productsService.updateCanonicalName(id, body.canonicalName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':id/tags')
|
||||||
|
setTags(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: SetTagsDto,
|
||||||
|
) {
|
||||||
|
return this.productsService.setTags(id, body.tags);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id/nutrition')
|
||||||
|
upsertNutrition(
|
||||||
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
@Body() body: UpsertNutritionDto,
|
||||||
|
) {
|
||||||
|
return this.productsService.upsertNutrition(id, body);
|
||||||
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
update(
|
update(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
|
|||||||
@@ -3,19 +3,26 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { normalizeName } from '../common/utils/normalize-name';
|
import { normalizeName } from '../common/utils/normalize-name';
|
||||||
import { CreateProductDto } from './dto/create-product.dto';
|
import { CreateProductDto } from './dto/create-product.dto';
|
||||||
import { UpdateProductDto } from './dto/update-product.dto';
|
import { UpdateProductDto } from './dto/update-product.dto';
|
||||||
|
import { UpsertNutritionDto } from './dto/upsert-nutrition.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductsService {
|
export class ProductsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
async findAll() {
|
async findAll(filters?: { tag?: string; subcategory?: string }) {
|
||||||
return this.prisma.product.findMany({
|
return this.prisma.product.findMany({
|
||||||
where: {
|
where: {
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
...(filters?.subcategory ? { subcategory: filters.subcategory } : {}),
|
||||||
|
...(filters?.tag
|
||||||
|
? { tags: { some: { tag: { name: filters.tag } } } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
orderBy: {
|
include: {
|
||||||
name: 'asc',
|
tags: { include: { tag: true } },
|
||||||
|
nutrition: true,
|
||||||
},
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +112,8 @@ export class ProductsService {
|
|||||||
normalizedName?: string;
|
normalizedName?: string;
|
||||||
canonicalName?: string;
|
canonicalName?: string;
|
||||||
category?: string | null;
|
category?: string | null;
|
||||||
|
subcategory?: string | null;
|
||||||
|
brand?: string | null;
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (typeof data.name === 'string') {
|
if (typeof data.name === 'string') {
|
||||||
@@ -140,12 +149,21 @@ export class ProductsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.category === 'string') {
|
if (typeof data.category === 'string') {
|
||||||
updateData.category = data.category.trim() || undefined;
|
updateData.category = data.category.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.subcategory === 'string') {
|
||||||
|
updateData.subcategory = data.subcategory.trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof data.brand === 'string') {
|
||||||
|
updateData.brand = data.brand.trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.prisma.product.update({
|
return this.prisma.product.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: updateData,
|
data: updateData,
|
||||||
|
include: { tags: { include: { tag: true } }, nutrition: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,4 +331,48 @@ export class ProductsService {
|
|||||||
products: results,
|
products: results,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async setTags(productId: number, tagNames: string[]) {
|
||||||
|
await this.findOne(productId);
|
||||||
|
|
||||||
|
// Skapa taggar som inte finns och hämta ID för alla
|
||||||
|
const tags = await this.prisma.$transaction(
|
||||||
|
tagNames.map((name) =>
|
||||||
|
this.prisma.tag.upsert({
|
||||||
|
where: { name },
|
||||||
|
create: { name },
|
||||||
|
update: {},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Ersätt alla taggkopplingar för produkten
|
||||||
|
await this.prisma.productTag.deleteMany({ where: { productId } });
|
||||||
|
|
||||||
|
if (tags.length > 0) {
|
||||||
|
await this.prisma.productTag.createMany({
|
||||||
|
data: tags.map((tag) => ({ productId, tagId: tag.id })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prisma.product.findUnique({
|
||||||
|
where: { id: productId },
|
||||||
|
include: { tags: { include: { tag: true } }, nutrition: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsertNutrition(productId: number, data: UpsertNutritionDto) {
|
||||||
|
await this.findOne(productId);
|
||||||
|
|
||||||
|
return this.prisma.nutrition.upsert({
|
||||||
|
where: { productId },
|
||||||
|
create: { productId, ...data },
|
||||||
|
update: { ...data },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findAllTags() {
|
||||||
|
return this.prisma.tag.findMany({ orderBy: { name: 'asc' } });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,238 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react';
|
import { useState, useTransition } from 'react';
|
||||||
import type { Product } from '../../../features/inventory/types';
|
import type { Product } from '../../../features/inventory/types';
|
||||||
import { updateProduct, deleteProduct } from './actions';
|
import { updateProduct, deleteProduct, setProductTags } from './actions';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
product: Product;
|
product: Product;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CATEGORIES: Record<string, string[]> = {
|
||||||
|
'Bröd & Kakor': ['Bröd', 'Kakor & bullar', 'Bageriprodukter'],
|
||||||
|
'Dryck': ['Kaffe & te', 'Juice & läsk', 'Vatten', 'Alkohol'],
|
||||||
|
'Fisk & Skaldjur': ['Fisk', 'Skaldjur', 'Bläckfisk & kalmar', 'Rökt fisk'],
|
||||||
|
'Frukt & Grönt': ['Frukt', 'Grönsaker', 'Bär', 'Rotfrukter', 'Kål'],
|
||||||
|
'Fryst': ['Fryst frukt & grönt', 'Frysta färdigrätter', 'Fryst kött & fisk', 'Glass'],
|
||||||
|
'Färdigmat': ['Färdigrätter', 'Snabbmat', 'Sallader & wrap'],
|
||||||
|
'Glass, godis & snacks': ['Glass', 'Godis', 'Snacks'],
|
||||||
|
'Kött, chark & fågel': ['Nötkött', 'Fläsk', 'Fågel', 'Charkuteri', 'Vilt'],
|
||||||
|
'Mejeri, ost & ägg': ['Mjölk', 'Grädde', 'Ost', 'Yoghurt & fil', 'Smör & margarin', 'Ägg'],
|
||||||
|
'Skafferi': ['Mjöl & bakning', 'Pasta & ris', 'Baljväxter', 'Nötter & frön', 'Socker & sötningsmedel', 'Kryddor & örter', 'Konserver & burkar'],
|
||||||
|
'Vegetariskt': ['Vegetariska proteinkällor', 'Vegetariska färdigrätter', 'Vegetariska korvar & burgare'],
|
||||||
|
'Övrigt': [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputStyle: React.CSSProperties = {
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
fontSize: '1rem',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EditProductForm({ product }: Props) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [isPending, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(product.category ?? '');
|
||||||
|
const [tagInput, setTagInput] = useState(
|
||||||
|
product.tags?.map((pt) => pt.tag.name).join(', ') ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const rawTags = tagInput.split(',').map((t) => t.trim().toLowerCase()).filter(Boolean);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await updateProduct(formData);
|
||||||
|
await setProductTags(product.id, rawTags);
|
||||||
|
setSuccess(true);
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDelete() {
|
||||||
|
if (!confirm(`Ta bort "${product.name}"? Detta är en mjukradering och kan återställas.`)) return;
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await deleteProduct(product.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Okänt fel');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const subcategories = CATEGORIES[selectedCategory] ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setIsOpen(!isOpen); setError(null); setSuccess(false); }}
|
||||||
|
style={{
|
||||||
|
padding: '0.4rem 1rem',
|
||||||
|
border: '1px solid #0070f3',
|
||||||
|
borderRadius: '4px',
|
||||||
|
background: isOpen ? '#0070f3' : '#fff',
|
||||||
|
color: isOpen ? '#fff' : '#0070f3',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isOpen ? 'Stäng' : 'Redigera'}
|
||||||
|
</button>
|
||||||
|
{success && <span style={{ color: 'green', fontSize: '0.9rem' }}>✓ Sparat!</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: 'crimson', marginTop: '0.5rem', fontSize: '0.9rem' }}>{error}</div>}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
style={{ marginTop: '0.75rem', display: 'grid', gap: '0.75rem', maxWidth: '480px' }}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="id" value={product.id} />
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Namn</span>
|
||||||
|
<input
|
||||||
|
name="name"
|
||||||
|
type="text"
|
||||||
|
defaultValue={product.name}
|
||||||
|
required
|
||||||
|
style={inputStyle}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Canonical name</span>
|
||||||
|
<input
|
||||||
|
name="canonicalName"
|
||||||
|
type="text"
|
||||||
|
defaultValue={product.canonicalName ?? ''}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="Lämna tomt för att använda namn"
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.8rem' }}>
|
||||||
|
Används för att gruppera liknande produkter (t.ex. "Kyckling" för alla kycklingvarianter)
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Kategori</span>
|
||||||
|
<select
|
||||||
|
name="category"
|
||||||
|
value={selectedCategory}
|
||||||
|
onChange={(e) => { setSelectedCategory(e.target.value); }}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— Ingen kategori —</option>
|
||||||
|
{Object.keys(CATEGORIES).map((cat) => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{subcategories.length > 0 && (
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Underkategori</span>
|
||||||
|
<select
|
||||||
|
name="subcategory"
|
||||||
|
defaultValue={product.subcategory ?? ''}
|
||||||
|
style={inputStyle}
|
||||||
|
>
|
||||||
|
<option value="">— Ingen underkategori —</option>
|
||||||
|
{subcategories.map((sub) => (
|
||||||
|
<option key={sub} value={sub}>{sub}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Varumärke</span>
|
||||||
|
<input
|
||||||
|
name="brand"
|
||||||
|
type="text"
|
||||||
|
defaultValue={product.brand ?? ''}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="T.ex. Arla, ICA, Överlopps"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label style={{ display: 'grid', gap: '0.25rem', fontSize: '0.9rem' }}>
|
||||||
|
<span style={{ fontWeight: 600 }}>Taggar</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
style={inputStyle}
|
||||||
|
placeholder="t.ex. svensk, ekologisk, glutenfri"
|
||||||
|
/>
|
||||||
|
<span style={{ color: '#666', fontSize: '0.8rem' }}>Kommaseparerade taggar (gemener)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '0.25rem', fontSize: '0.85rem', color: '#888' }}>
|
||||||
|
<span><strong style={{ color: '#555' }}>Normaliserat namn:</strong> {product.normalizedName}</span>
|
||||||
|
<span><strong style={{ color: '#555' }}>Aktiv:</strong> {product.isActive ? 'Ja' : 'Nej'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.6rem 1.25rem',
|
||||||
|
background: '#0070f3',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
opacity: isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isPending ? 'Sparar...' : 'Spara'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{
|
||||||
|
padding: '0.6rem 1.25rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: '#c00',
|
||||||
|
border: '1px solid #c00',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
opacity: isPending ? 0.7 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ta bort (mjukradering)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const inputStyle: React.CSSProperties = {
|
const inputStyle: React.CSSProperties = {
|
||||||
padding: '0.5rem 0.75rem',
|
padding: '0.5rem 0.75rem',
|
||||||
border: '1px solid #ddd',
|
border: '1px solid #ddd',
|
||||||
|
|||||||
@@ -8,11 +8,15 @@ export async function updateProduct(formData: FormData) {
|
|||||||
const name = String(formData.get('name') || '').trim();
|
const name = String(formData.get('name') || '').trim();
|
||||||
const canonicalName = String(formData.get('canonicalName') || '').trim();
|
const canonicalName = String(formData.get('canonicalName') || '').trim();
|
||||||
const category = String(formData.get('category') || '').trim();
|
const category = String(formData.get('category') || '').trim();
|
||||||
|
const subcategory = String(formData.get('subcategory') || '').trim();
|
||||||
|
const brand = String(formData.get('brand') || '').trim();
|
||||||
|
|
||||||
if (!name) throw new Error('Namn får inte vara tomt.');
|
if (!name) throw new Error('Namn får inte vara tomt.');
|
||||||
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
|
if (name.length > 100) throw new Error('Namn får inte vara längre än 100 tecken.');
|
||||||
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
|
if (canonicalName.length > 100) throw new Error('Canonical name får inte vara längre än 100 tecken.');
|
||||||
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
|
if (category.length > 100) throw new Error('Kategori får inte vara längre än 100 tecken.');
|
||||||
|
if (subcategory.length > 100) throw new Error('Underkategori får inte vara längre än 100 tecken.');
|
||||||
|
if (brand.length > 100) throw new Error('Varumärke får inte vara längre än 100 tecken.');
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
@@ -21,6 +25,8 @@ export async function updateProduct(formData: FormData) {
|
|||||||
name: name || undefined,
|
name: name || undefined,
|
||||||
canonicalName: canonicalName || undefined,
|
canonicalName: canonicalName || undefined,
|
||||||
category: category || null,
|
category: category || null,
|
||||||
|
subcategory: subcategory || null,
|
||||||
|
brand: brand || null,
|
||||||
}),
|
}),
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
});
|
});
|
||||||
@@ -33,6 +39,22 @@ export async function updateProduct(formData: FormData) {
|
|||||||
revalidatePath('/admin/products');
|
revalidatePath('/admin/products');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setProductTags(productId: number, tags: string[]) {
|
||||||
|
const res = await fetch(`${API_BASE}/api/products/${productId}/tags`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ tags }),
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Kunde inte uppdatera taggar: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath('/admin/products');
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteProduct(id: number) {
|
export async function deleteProduct(id: number) {
|
||||||
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
const res = await fetch(`${API_BASE}/api/products/${id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -1,13 +1,40 @@
|
|||||||
|
export type Tag = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductTag = {
|
||||||
|
productId: number;
|
||||||
|
tagId: number;
|
||||||
|
tag: Tag;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Nutrition = {
|
||||||
|
id: number;
|
||||||
|
productId: number;
|
||||||
|
calories: number | null;
|
||||||
|
protein: number | null;
|
||||||
|
fat: number | null;
|
||||||
|
carbohydrates: number | null;
|
||||||
|
salt: number | null;
|
||||||
|
sugar: number | null;
|
||||||
|
fiber: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type Product = {
|
export type Product = {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
normalizedName: string;
|
normalizedName: string;
|
||||||
category: string | null;
|
category: string | null;
|
||||||
|
subcategory: string | null;
|
||||||
|
brand: string | null;
|
||||||
canonicalName: string | null;
|
canonicalName: string | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
|
tags?: ProductTag[];
|
||||||
|
nutrition?: Nutrition | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type InventoryItem = {
|
export type InventoryItem = {
|
||||||
|
|||||||
Reference in New Issue
Block a user