feat: enhance product model with subcategory, brand, tags, and nutrition; update related DTOs and services

This commit is contained in:
Nils-Johan Gynther
2026-04-17 18:11:06 +02:00
parent a05d907608
commit a4ea9be7a1
10 changed files with 517 additions and 33 deletions
+49 -18
View File
@@ -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?``Recipe` i Prisma + migration - **Databas:** `servings Int?``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
+33
View File
@@ -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)
}
+8
View File
@@ -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;
}
+29 -2
View File
@@ -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,
+66 -4
View File
@@ -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' } });
}
} }
+227 -1
View File
@@ -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',
+22
View File
@@ -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',
+27
View File
@@ -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 = {