feat: add servings field to Recipe model and implement inventory comparison functionality

This commit is contained in:
Nils-Johan Gynther
2026-04-17 18:48:08 +02:00
parent 8a86b0aebd
commit 8e0aed032c
10 changed files with 260 additions and 20 deletions
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `Recipe` ADD COLUMN `servings` INTEGER NULL;
+1
View File
@@ -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
@@ -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);
@@ -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<string, { productId: number; name: string; required: number; unit: string }>();
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');
});
}
}
+9 -4
View File
@@ -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 })
+7 -5
View File
@@ -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 } },
},
},
},
@@ -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' },
});
}
+74 -1
View File
@@ -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<MealPlanEntry[]>([]);
const [shopping, setShopping] = useState<ShoppingItem[]>([]);
const [inventoryCompare, setInventoryCompare] = useState<InventoryCompareItem[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(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[] }) {
</ul>
)}
</section>
{/* Inventariejämförelse */}
{plannedCount > 0 && inventoryCompare.length > 0 && (
<section style={{ border: '1px solid #dee2e6', borderRadius: '8px', padding: '1rem' }}>
<h2 style={{ margin: '0 0 0.5rem', fontSize: '1.1rem' }}>Inventariegranskning</h2>
<p style={{ margin: '0 0 0.75rem', fontSize: '0.85rem', color: '#666' }}>
Vad du har hemma vs. vad veckans recept kräver.
</p>
{(() => {
const missingCount = inventoryCompare.filter((i) => i.status === 'missing').length;
return missingCount === 0 ? (
<p style={{ color: '#1f5f2c', fontWeight: 600, margin: '0 0 0.75rem' }}>
Du har allt hemma!
</p>
) : (
<p style={{ color: '#8b0000', fontWeight: 600, margin: '0 0 0.75rem' }}>
{missingCount} ingrediens{missingCount !== 1 ? 'er' : ''} saknas eller räcker inte
</p>
);
})()}
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.4rem' }}>
{inventoryCompare.map((item) => (
<li
key={`${item.productId}-${item.unit}`}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.4rem 0.6rem',
borderRadius: '6px',
background: item.status === 'missing' ? '#ffeaea' : '#ecf8ee',
fontSize: '0.88rem',
flexWrap: 'wrap',
gap: '0.25rem',
}}
>
<span>
<strong>{item.name}</strong>
{' '}
<span style={{ color: '#555' }}>
{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
</span>
</span>
{item.status === 'missing' && item.missing > 0 && (
<span style={{ color: '#8b0000', fontWeight: 600, whiteSpace: 'nowrap' }}>
Saknar {item.missing % 1 === 0 ? item.missing : item.missing.toFixed(1)} {item.unit}
</span>
)}
{item.status === 'enough' && (
<span style={{ color: '#1f5f2c', fontWeight: 600 }}></span>
)}
</li>
))}
</ul>
</section>
)}
</>
)}
</div>
@@ -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<string | null>(null);
const [servings, setServings] = useState<number>(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 */}
<section style={sectionStyle}>
<h2 style={sectionTitle}>Ingredienser</h2>
{recipe.servings && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '1rem', fontSize: '0.9rem', flexWrap: 'wrap' }}>
<span style={{ color: '#555' }}>Portioner:</span>
<button type="button" onClick={() => setServings((s) => Math.max(1, s - 1))} style={btnStyle()}></button>
<span style={{ fontWeight: 700, minWidth: '2rem', textAlign: 'center' }}>{servings}</span>
<button type="button" onClick={() => setServings((s) => s + 1)} style={btnStyle()}>+</button>
{servings !== recipe.servings && (
<button type="button" onClick={() => setServings(recipe.servings!)} style={{ ...btnStyle(), fontSize: '0.8rem', padding: '0.3rem 0.6rem' }}>
Återställ ({recipe.servings})
</button>
)}
</div>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.4rem' }}>
{recipe.ingredients.map((ing) => (
{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 (
<li key={ing.id} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline' }}>
<span style={{ fontWeight: 600, minWidth: '60px', textAlign: 'right' }}>
{Number(ing.quantity)} {ing.unit}
{displayQty} {ing.unit}
</span>
<span>{ing.product.canonicalName || ing.product.name}</span>
{ing.note && <span style={{ color: '#868e96', fontSize: '0.875rem' }}>({ing.note})</span>}
</li>
))}
);
})}
</ul>
</section>
@@ -329,6 +349,58 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
)}
</section>
)}
{/* 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 (
<section style={{ ...sectionStyle, marginTop: '1.5rem' }}>
<h2 style={sectionTitle}>Näringsvärden ({portionLabel})</h2>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(110px, 1fr))', gap: '0.75rem' }}>
{[
{ 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 }) => (
<div key={label} style={{ padding: '0.75rem', background: '#f8f9fa', borderRadius: '6px', textAlign: 'center' }}>
<div style={{ fontSize: '1.2rem', fontWeight: 700 }}>
{value < 1 ? value.toFixed(1) : Math.round(value)}{unit}
</div>
<div style={{ fontSize: '0.8rem', color: '#666' }}>{label}</div>
</div>
))}
</div>
<p style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.5rem', marginBottom: 0 }}>
* Endast ingredienser med viktenhet (g/kg) och registrerade näringsvärden inkluderas.
</p>
</section>
);
})()}
</div>
);
}
@@ -377,7 +449,18 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
<textarea value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} rows={3} style={{ ...inputStyle, fontFamily: 'inherit', resize: 'vertical', marginBottom: '1rem' }} />
<label style={labelStyle}>Instruktioner</label>
<textarea value={form.instructions} onChange={(e) => setForm((f) => ({ ...f, instructions: e.target.value }))} rows={8} style={{ ...inputStyle, fontFamily: 'inherit', resize: 'vertical' }} />
<textarea value={form.instructions} onChange={(e) => setForm((f) => ({ ...f, instructions: e.target.value }))} rows={8} style={{ ...inputStyle, fontFamily: 'inherit', resize: 'vertical', marginBottom: '1rem' }} />
<label style={labelStyle}>Portioner</label>
<input
type="number"
min={1}
step={1}
value={form.servings ?? ''}
onChange={(e) => setForm((f) => ({ ...f, servings: e.target.value ? Number(e.target.value) : null }))}
style={{ ...inputStyle, width: '120px' }}
placeholder="t.ex. 4"
/>
</section>
{/* Ingredienser */}
+1
View File
@@ -100,6 +100,7 @@ export type Recipe = {
description: string | null;
instructions: string | null;
imageUrl: string | null;
servings: number | null;
createdAt: string;
updatedAt: string;
ingredients: RecipeIngredient[];