From 21dc06829aeaa8a55d2f315676847aadfca6b54b Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 17 Apr 2026 22:50:41 +0200 Subject: [PATCH] feat(meal-plan): add servings field to MealPlanEntry and update related functionality feat(products): implement bulk update for product categories feat(recipes): add servings input to WriteRecipePage and update MealPlanClient for servings management refactor(types): enhance Product and Category types with additional properties --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + .../dto/create-meal-plan-entry.dto.ts | 7 +- backend/src/meal-plan/meal-plan.service.ts | 19 +- .../products/dto/bulk-update-products.dto.ts | 12 + backend/src/products/products.controller.ts | 8 +- backend/src/products/products.service.ts | 10 + .../app/admin/products/AdminProductList.tsx | 235 ++++++++++++++---- frontend/app/admin/products/actions.ts | 17 ++ frontend/app/matplan/MealPlanClient.tsx | 39 ++- .../app/recipes/write/WriteRecipePage.tsx | 16 ++ frontend/features/inventory/types.ts | 9 + 12 files changed, 323 insertions(+), 52 deletions(-) create mode 100644 backend/prisma/migrations/20260417400000_add_meal_plan_servings/migration.sql create mode 100644 backend/src/products/dto/bulk-update-products.dto.ts diff --git a/backend/prisma/migrations/20260417400000_add_meal_plan_servings/migration.sql b/backend/prisma/migrations/20260417400000_add_meal_plan_servings/migration.sql new file mode 100644 index 00000000..5b08a429 --- /dev/null +++ b/backend/prisma/migrations/20260417400000_add_meal_plan_servings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `MealPlanEntry` ADD COLUMN `servings` INTEGER NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 68dc3657..c7f04e6c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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 diff --git a/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts b/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts index 67fc5a84..8c0f1b16 100644 --- a/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts +++ b/backend/src/meal-plan/dto/create-meal-plan-entry.dto.ts @@ -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; } diff --git a/backend/src/meal-plan/meal-plan.service.ts b/backend/src/meal-plan/meal-plan.service.ts index e63b140e..bf3e9d0b 100644 --- a/backend/src/meal-plan/meal-plan.service.ts +++ b/backend/src/meal-plan/meal-plan.service.ts @@ -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(); 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(); 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; diff --git a/backend/src/products/dto/bulk-update-products.dto.ts b/backend/src/products/dto/bulk-update-products.dto.ts new file mode 100644 index 00000000..9352151d --- /dev/null +++ b/backend/src/products/dto/bulk-update-products.dto.ts @@ -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; +} diff --git a/backend/src/products/products.controller.ts b/backend/src/products/products.controller.ts index 27ec7fe5..a5a1fe59 100644 --- a/backend/src/products/products.controller.ts +++ b/backend/src/products/products.controller.ts @@ -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 }); + } } \ No newline at end of file diff --git a/backend/src/products/products.service.ts b/backend/src/products/products.service.ts index e6e26bae..05aeb841 100644 --- a/backend/src/products/products.service.ts +++ b/backend/src/products/products.service.ts @@ -397,4 +397,14 @@ export class ProductsService { ]); return { ok: true }; } + + async bulkUpdate(ids: number[], data: { categoryId?: number | null }) { + const updateData: Record = {}; + 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 }; + } } \ No newline at end of file diff --git a/frontend/app/admin/products/AdminProductList.tsx b/frontend/app/admin/products/AdminProductList.tsx index a834bc18..d6093cf4 100644 --- a/frontend/app/admin/products/AdminProductList.tsx +++ b/frontend/app/admin/products/AdminProductList.tsx @@ -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>(new Set()); + const [bulkCategoryId, setBulkCategoryId] = useState(''); + const [categoryTree, setCategoryTree] = useState([]); + const [isPending, startTransition] = useTransition(); + const [bulkError, setBulkError] = useState(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) => - p.name.toLowerCase().includes(q) || - (p.canonicalName ?? '').toLowerCase().includes(q) || - (p.normalizedName ?? '').toLowerCase().includes(q), - ) - : [...products]; + 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) + ); + } + 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 ( <> -
+ {/* Sök + sortering + filter */} +
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' }} />
@@ -84,38 +144,115 @@ export default function AdminProductList({ products }: Props) { {opt.label} ))} +
- {search && ( - - {filtered.length} av {products.length} produkter - - )} + + {filtered.length} av {products.length} produkter +
+ {/* Bulk-åtgärd */} + {selectedIds.size > 0 && ( +
+ {selectedIds.size} valda + + + + {bulkError && {bulkError}} +
+ )} + + {/* Välj alla synliga */} + {filtered.length > 0 && ( +
+ +
+ )} +
{filtered.map((product) => (
-
- {product.canonicalName || product.name} - {product.canonicalName && product.canonicalName !== product.name && ( - ({product.name}) - )} - {product.category && ( - - {product.category} - - )} +
+ toggleSelect(product.id)} + style={{ marginTop: '3px', flexShrink: 0 }} + /> +
+ {product.canonicalName || product.name} + {product.canonicalName && product.canonicalName !== product.name && ( + ({product.name}) + )} + {product.categoryRef ? ( + + {[product.categoryRef.parent?.parent?.name, product.categoryRef.parent?.name, product.categoryRef.name].filter(Boolean).join(' › ')} + + ) : product.category ? ( + + {product.category} + + ) : ( + Okategoriserad + )} +
ID: {product.id}
@@ -129,3 +266,15 @@ export default function AdminProductList({ products }: Props) { ); } + +
+
+ Normalized: {product.normalizedName} +
+ +
+ ))} +
+ + ); +} diff --git a/frontend/app/admin/products/actions.ts b/frontend/app/admin/products/actions.ts index 50765cf5..32ba8bdb 100644 --- a/frontend/app/admin/products/actions.ts +++ b/frontend/app/admin/products/actions.ts @@ -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'); +} diff --git a/frontend/app/matplan/MealPlanClient.tsx b/frontend/app/matplan/MealPlanClient.tsx index d6a5f5ca..82d7d7ce 100644 --- a/frontend/app/matplan/MealPlanClient.tsx +++ b/frontend/app/matplan/MealPlanClient.tsx @@ -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 & { + 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 (
{/* Veckonavigering */} @@ -173,6 +187,29 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { Visa recept → )} + {entry && entry.recipe.servings && ( +
+ Port.: + 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 && ( + + )} +
+ )} {isSaving && Sparar...}
diff --git a/frontend/app/recipes/write/WriteRecipePage.tsx b/frontend/app/recipes/write/WriteRecipePage.tsx index 6584101c..6b5b135f 100644 --- a/frontend/app/recipes/write/WriteRecipePage.tsx +++ b/frontend/app/recipes/write/WriteRecipePage.tsx @@ -42,6 +42,7 @@ export default function WriteRecipePage() { const [editedName, setEditedName] = useState(''); const [editedDescription, setEditedDescription] = useState(''); const [editedInstructions, setEditedInstructions] = useState(''); + const [editedServings, setEditedServings] = useState(null); const [imageUrl, setImageUrl] = useState(null); const [ingredients, setIngredients] = useState([]); const [allProducts, setAllProducts] = useState([]); @@ -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...`} style={{ width: '100%', padding: '0.75rem', border: '1px solid #ddd', borderRadius: '4px', fontSize: '1rem', minHeight: '150px', fontFamily: 'monospace', boxSizing: 'border-box' }} /> + +
+ + 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' }} + /> +

Anges portioner kan mängderna skalas på receptsidan.

+
{/* Ingredienser */} diff --git a/frontend/features/inventory/types.ts b/frontend/features/inventory/types.ts index 96734660..6b58410b 100644 --- a/frontend/features/inventory/types.ts +++ b/frontend/features/inventory/types.ts @@ -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 = {