From 8e0aed032c1a2527f44e46026aca46fecbf89a2b Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Fri, 17 Apr 2026 18:48:08 +0200 Subject: [PATCH] feat: add servings field to Recipe model and implement inventory comparison functionality --- .../migration.sql | 2 + backend/prisma/schema.prisma | 1 + backend/src/meal-plan/meal-plan.controller.ts | 5 + backend/src/meal-plan/meal-plan.service.ts | 51 +++++++++ backend/src/recipes/dto/create-recipe.dto.ts | 13 ++- backend/src/recipes/recipes.service.ts | 12 +- .../inventory-compare/route.ts | 17 +++ frontend/app/matplan/MealPlanClient.tsx | 75 ++++++++++++- .../app/recipes/[id]/RecipeDetailClient.tsx | 103 ++++++++++++++++-- frontend/features/inventory/types.ts | 1 + 10 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 backend/prisma/migrations/20260417100000_add_recipe_servings/migration.sql create mode 100644 frontend/app/api/meal-plan-proxy/inventory-compare/route.ts diff --git a/backend/prisma/migrations/20260417100000_add_recipe_servings/migration.sql b/backend/prisma/migrations/20260417100000_add_recipe_servings/migration.sql new file mode 100644 index 00000000..fea5eb64 --- /dev/null +++ b/backend/prisma/migrations/20260417100000_add_recipe_servings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Recipe` ADD COLUMN `servings` INTEGER NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index c661aae8..56f169e3 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -72,6 +72,7 @@ model Recipe { description String? @db.Text instructions String? @db.Text imageUrl String? + servings Int? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/backend/src/meal-plan/meal-plan.controller.ts b/backend/src/meal-plan/meal-plan.controller.ts index e78584df..70b36a88 100644 --- a/backend/src/meal-plan/meal-plan.controller.ts +++ b/backend/src/meal-plan/meal-plan.controller.ts @@ -16,6 +16,11 @@ export class MealPlanController { return this.mealPlanService.shoppingList(from, to); } + @Get('inventory-compare') + inventoryCompare(@Query('from') from: string, @Query('to') to: string) { + return this.mealPlanService.inventoryCompare(from, to); + } + @Post() upsert(@Body() dto: CreateMealPlanEntryDto) { return this.mealPlanService.upsert(dto); diff --git a/backend/src/meal-plan/meal-plan.service.ts b/backend/src/meal-plan/meal-plan.service.ts index f8d7362e..e63b140e 100644 --- a/backend/src/meal-plan/meal-plan.service.ts +++ b/backend/src/meal-plan/meal-plan.service.ts @@ -77,4 +77,55 @@ export class MealPlanService { return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'sv')); } + + /** Jämför veckans ingrediensbehov mot inventariet */ + async inventoryCompare(from: string, to: string) { + const entries = await this.findByRange(from, to); + + // Aggregera ingredienser per produkt+enhet + const map = new Map(); + for (const entry of entries) { + for (const ing of entry.recipe.ingredients) { + const key = `${ing.product.id}-${ing.unit}`; + const qty = Number(ing.quantity); + const existing = map.get(key); + if (existing) { + existing.required += qty; + } else { + map.set(key, { + productId: ing.product.id, + name: ing.product.canonicalName || ing.product.name, + required: qty, + unit: ing.unit, + }); + } + } + } + + // Kontrollera inventariet för varje ingrediens + const result = await Promise.all( + Array.from(map.values()).map(async (item) => { + const inventoryItems = await this.prisma.inventoryItem.findMany({ + where: { productId: item.productId }, + }); + const available = inventoryItems + .filter((i: any) => i.unit.trim().toLowerCase() === item.unit.trim().toLowerCase()) + .reduce((sum: number, i: any) => sum + Number(i.quantity), 0); + return { + productId: item.productId, + name: item.name, + required: item.required, + unit: item.unit, + available, + missing: Math.max(0, item.required - available), + status: (available >= item.required ? 'enough' : 'missing') as 'enough' | 'missing', + }; + }), + ); + + return result.sort((a, b) => { + if (a.status !== b.status) return a.status === 'missing' ? -1 : 1; + return a.name.localeCompare(b.name, 'sv'); + }); + } } diff --git a/backend/src/recipes/dto/create-recipe.dto.ts b/backend/src/recipes/dto/create-recipe.dto.ts index d8f2bb45..d89dbb3b 100644 --- a/backend/src/recipes/dto/create-recipe.dto.ts +++ b/backend/src/recipes/dto/create-recipe.dto.ts @@ -1,12 +1,12 @@ import { IsArray, - IsOptional, - IsString, - ValidateNested, - ArrayMinSize, IsInt, IsNumber, + IsOptional, + IsString, Min, + ValidateNested, + ArrayMinSize, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -42,6 +42,11 @@ export class CreateRecipeDto { @IsString() imageUrl?: string; + @IsOptional() + @IsInt() + @Min(1) + servings?: number; + @IsArray() @ArrayMinSize(1) @ValidateNested({ each: true }) diff --git a/backend/src/recipes/recipes.service.ts b/backend/src/recipes/recipes.service.ts index 3c877aea..53119e8c 100644 --- a/backend/src/recipes/recipes.service.ts +++ b/backend/src/recipes/recipes.service.ts @@ -295,7 +295,7 @@ export class RecipesService { include: { ingredients: { include: { - product: true, + product: { include: { nutrition: true } }, }, }, }, @@ -308,7 +308,7 @@ export class RecipesService { include: { ingredients: { include: { - product: true, + product: { include: { nutrition: true } }, }, }, }, @@ -343,6 +343,7 @@ export class RecipesService { name: updateRecipeDto.name, description: updateRecipeDto.description || null, instructions: updateRecipeDto.instructions || null, + servings: updateRecipeDto.servings ?? null, ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), ingredients: { create: updateRecipeDto.ingredients.map((ingredient) => ({ @@ -356,7 +357,7 @@ export class RecipesService { include: { ingredients: { include: { - product: true, + product: { include: { nutrition: true } }, }, }, }, @@ -389,7 +390,7 @@ export class RecipesService { return this.prisma.recipe.update({ where: { id }, data: { imageUrl }, - include: { ingredients: { include: { product: true } } }, + include: { ingredients: { include: { product: { include: { nutrition: true } } } } }, }); } @@ -411,6 +412,7 @@ export class RecipesService { description: createRecipeDto.description || null, instructions: createRecipeDto.instructions || null, imageUrl, + servings: createRecipeDto.servings ?? null, ingredients: { create: createRecipeDto.ingredients.map((ingredient) => ({ productId: ingredient.productId, @@ -423,7 +425,7 @@ export class RecipesService { include: { ingredients: { include: { - product: true, + product: { include: { nutrition: true } }, }, }, }, diff --git a/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts b/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts new file mode 100644 index 00000000..a6530213 --- /dev/null +++ b/frontend/app/api/meal-plan-proxy/inventory-compare/route.ts @@ -0,0 +1,17 @@ +import { NextRequest, NextResponse } from 'next/server'; + +const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080'; + +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl; + const from = searchParams.get('from'); + const to = searchParams.get('to'); + const res = await fetch(`${API_BASE}/api/meal-plan/inventory-compare?from=${from}&to=${to}`, { + cache: 'no-store', + }); + const text = await res.text(); + return new NextResponse(text, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/frontend/app/matplan/MealPlanClient.tsx b/frontend/app/matplan/MealPlanClient.tsx index d1a88163..d6a5f5ca 100644 --- a/frontend/app/matplan/MealPlanClient.tsx +++ b/frontend/app/matplan/MealPlanClient.tsx @@ -16,6 +16,16 @@ type MealPlanEntry = { type ShoppingItem = { productId: number; name: string; quantity: number; unit: string }; +type InventoryCompareItem = { + productId: number; + name: string; + required: number; + unit: string; + available: number; + missing: number; + status: 'enough' | 'missing'; +}; + function getWeekDates(offset = 0): string[] { const now = new Date(); const day = now.getDay(); @@ -32,6 +42,7 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { const [weekOffset, setWeekOffset] = useState(0); const [entries, setEntries] = useState([]); const [shopping, setShopping] = useState([]); + const [inventoryCompare, setInventoryCompare] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(null); // date being saved @@ -48,17 +59,21 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { const load = useCallback(async () => { setLoading(true); try { - const [entriesRes, shoppingRes] = await Promise.all([ + const [entriesRes, shoppingRes, compareRes] = await Promise.all([ fetch(`/api/meal-plan-proxy?from=${from}&to=${to}`), fetch(`/api/meal-plan-proxy/shopping?from=${from}&to=${to}`), + fetch(`/api/meal-plan-proxy/inventory-compare?from=${from}&to=${to}`), ]); const entriesData = await entriesRes.json(); setEntries(Array.isArray(entriesData) ? entriesData : []); if (shoppingRes.ok) setShopping(await shoppingRes.json()); else setShopping([]); + if (compareRes.ok) setInventoryCompare(await compareRes.json()); + else setInventoryCompare([]); } catch { setEntries([]); setShopping([]); + setInventoryCompare([]); } finally { setLoading(false); } @@ -190,6 +205,64 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) { )} + + {/* Inventariejämförelse */} + {plannedCount > 0 && inventoryCompare.length > 0 && ( +
+

Inventariegranskning

+

+ Vad du har hemma vs. vad veckans recept kräver. +

+ {(() => { + const missingCount = inventoryCompare.filter((i) => i.status === 'missing').length; + return missingCount === 0 ? ( +

+ ✓ Du har allt hemma! +

+ ) : ( +

+ {missingCount} ingrediens{missingCount !== 1 ? 'er' : ''} saknas eller räcker inte +

+ ); + })()} +
    + {inventoryCompare.map((item) => ( +
  • + + {item.name} + {' '} + + {item.required % 1 === 0 ? item.required : item.required.toFixed(1)} {item.unit} behövs + {' · '} + {item.available % 1 === 0 ? item.available : item.available.toFixed(1)} {item.unit} hemma + + + {item.status === 'missing' && item.missing > 0 && ( + + Saknar {item.missing % 1 === 0 ? item.missing : item.missing.toFixed(1)} {item.unit} + + )} + {item.status === 'enough' && ( + + )} +
  • + ))} +
+
+ )} )} diff --git a/frontend/app/recipes/[id]/RecipeDetailClient.tsx b/frontend/app/recipes/[id]/RecipeDetailClient.tsx index 3d44f22e..b344c06d 100644 --- a/frontend/app/recipes/[id]/RecipeDetailClient.tsx +++ b/frontend/app/recipes/[id]/RecipeDetailClient.tsx @@ -70,6 +70,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: const [isSaving, setIsSaving] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [error, setError] = useState(null); + const [servings, setServings] = useState(initialRecipe.servings ?? 1); // Redigeringsformulär-state const [form, setForm] = useState({ @@ -77,6 +78,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: description: initialRecipe.description || '', instructions: initialRecipe.instructions || '', imageUrl: initialRecipe.imageUrl || '', + servings: initialRecipe.servings as number | null, ingredients: initialRecipe.ingredients.map((ing) => ({ productId: ing.productId, quantity: String(ing.quantity), @@ -269,16 +271,34 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: {/* Ingredienser */}

Ingredienser

+ {recipe.servings && ( +
+ Portioner: + + {servings} + + {servings !== recipe.servings && ( + + )} +
+ )}
    - {recipe.ingredients.map((ing) => ( -
  • - - {Number(ing.quantity)} {ing.unit} - - {ing.product.canonicalName || ing.product.name} - {ing.note && ({ing.note})} -
  • - ))} + {recipe.ingredients.map((ing) => { + const scale = recipe.servings ? servings / recipe.servings : 1; + const qty = Number(ing.quantity) * scale; + const displayQty = qty % 1 === 0 ? qty : parseFloat(qty.toFixed(2)); + return ( +
  • + + {displayQty} {ing.unit} + + {ing.product.canonicalName || ing.product.name} + {ing.note && ({ing.note})} +
  • + ); + })}
@@ -329,6 +349,58 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: )} )} + + {/* Näringsvärden */} + {(() => { + const scale = recipe.servings ? servings / recipe.servings : 1; + const totals = recipe.ingredients.reduce( + (acc, ing) => { + const n = ing.product.nutrition; + if (!n) return acc; + const qty = Number(ing.quantity) * scale; + const qtyInGrams = ing.unit === 'g' ? qty : ing.unit === 'kg' ? qty * 1000 : null; + if (qtyInGrams === null) return acc; + const f = qtyInGrams / 100; + return { + calories: acc.calories + (n.calories ?? 0) * f, + protein: acc.protein + (n.protein ?? 0) * f, + fat: acc.fat + (n.fat ?? 0) * f, + carbohydrates: acc.carbohydrates + (n.carbohydrates ?? 0) * f, + }; + }, + { calories: 0, protein: 0, fat: 0, carbohydrates: 0 }, + ); + const hasAny = recipe.ingredients.some( + (i) => i.product.nutrition && (i.unit === 'g' || i.unit === 'kg'), + ); + if (!hasAny) return null; + const portionLabel = recipe.servings + ? `${servings} ${servings === 1 ? 'portion' : 'portioner'}` + : 'hela receptet'; + return ( +
+

Näringsvärden ({portionLabel})

+
+ {[ + { label: 'Energi', value: totals.calories, unit: 'kcal' }, + { label: 'Protein', value: totals.protein, unit: 'g' }, + { label: 'Fett', value: totals.fat, unit: 'g' }, + { label: 'Kolhydrater', value: totals.carbohydrates, unit: 'g' }, + ].map(({ label, value, unit }) => ( +
+
+ {value < 1 ? value.toFixed(1) : Math.round(value)}{unit} +
+
{label}
+
+ ))} +
+

+ * Endast ingredienser med viktenhet (g/kg) och registrerade näringsvärden inkluderas. +

+
+ ); + })()} ); } @@ -377,7 +449,18 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe: