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 description String? @db.Text
instructions String? @db.Text instructions String? @db.Text
imageUrl String? imageUrl String?
servings Int?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -16,6 +16,11 @@ export class MealPlanController {
return this.mealPlanService.shoppingList(from, to); 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() @Post()
upsert(@Body() dto: CreateMealPlanEntryDto) { upsert(@Body() dto: CreateMealPlanEntryDto) {
return this.mealPlanService.upsert(dto); 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')); 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 { import {
IsArray, IsArray,
IsOptional,
IsString,
ValidateNested,
ArrayMinSize,
IsInt, IsInt,
IsNumber, IsNumber,
IsOptional,
IsString,
Min, Min,
ValidateNested,
ArrayMinSize,
} from 'class-validator'; } from 'class-validator';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
@@ -42,6 +42,11 @@ export class CreateRecipeDto {
@IsString() @IsString()
imageUrl?: string; imageUrl?: string;
@IsOptional()
@IsInt()
@Min(1)
servings?: number;
@IsArray() @IsArray()
@ArrayMinSize(1) @ArrayMinSize(1)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
+7 -5
View File
@@ -295,7 +295,7 @@ export class RecipesService {
include: { include: {
ingredients: { ingredients: {
include: { include: {
product: true, product: { include: { nutrition: true } },
}, },
}, },
}, },
@@ -308,7 +308,7 @@ export class RecipesService {
include: { include: {
ingredients: { ingredients: {
include: { include: {
product: true, product: { include: { nutrition: true } },
}, },
}, },
}, },
@@ -343,6 +343,7 @@ export class RecipesService {
name: updateRecipeDto.name, name: updateRecipeDto.name,
description: updateRecipeDto.description || null, description: updateRecipeDto.description || null,
instructions: updateRecipeDto.instructions || null, instructions: updateRecipeDto.instructions || null,
servings: updateRecipeDto.servings ?? null,
...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }), ...(updateRecipeDto.imageUrl !== undefined && { imageUrl: updateRecipeDto.imageUrl || null }),
ingredients: { ingredients: {
create: updateRecipeDto.ingredients.map((ingredient) => ({ create: updateRecipeDto.ingredients.map((ingredient) => ({
@@ -356,7 +357,7 @@ export class RecipesService {
include: { include: {
ingredients: { ingredients: {
include: { include: {
product: true, product: { include: { nutrition: true } },
}, },
}, },
}, },
@@ -389,7 +390,7 @@ export class RecipesService {
return this.prisma.recipe.update({ return this.prisma.recipe.update({
where: { id }, where: { id },
data: { imageUrl }, 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, description: createRecipeDto.description || null,
instructions: createRecipeDto.instructions || null, instructions: createRecipeDto.instructions || null,
imageUrl, imageUrl,
servings: createRecipeDto.servings ?? null,
ingredients: { ingredients: {
create: createRecipeDto.ingredients.map((ingredient) => ({ create: createRecipeDto.ingredients.map((ingredient) => ({
productId: ingredient.productId, productId: ingredient.productId,
@@ -423,7 +425,7 @@ export class RecipesService {
include: { include: {
ingredients: { ingredients: {
include: { 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 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[] { function getWeekDates(offset = 0): string[] {
const now = new Date(); const now = new Date();
const day = now.getDay(); const day = now.getDay();
@@ -32,6 +42,7 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
const [weekOffset, setWeekOffset] = useState(0); const [weekOffset, setWeekOffset] = useState(0);
const [entries, setEntries] = useState<MealPlanEntry[]>([]); const [entries, setEntries] = useState<MealPlanEntry[]>([]);
const [shopping, setShopping] = useState<ShoppingItem[]>([]); const [shopping, setShopping] = useState<ShoppingItem[]>([]);
const [inventoryCompare, setInventoryCompare] = useState<InventoryCompareItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState<string | null>(null); // date being saved 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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { 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?from=${from}&to=${to}`),
fetch(`/api/meal-plan-proxy/shopping?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(); const entriesData = await entriesRes.json();
setEntries(Array.isArray(entriesData) ? entriesData : []); setEntries(Array.isArray(entriesData) ? entriesData : []);
if (shoppingRes.ok) setShopping(await shoppingRes.json()); if (shoppingRes.ok) setShopping(await shoppingRes.json());
else setShopping([]); else setShopping([]);
if (compareRes.ok) setInventoryCompare(await compareRes.json());
else setInventoryCompare([]);
} catch { } catch {
setEntries([]); setEntries([]);
setShopping([]); setShopping([]);
setInventoryCompare([]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -190,6 +205,64 @@ export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
</ul> </ul>
)} )}
</section> </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> </div>
@@ -70,6 +70,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [servings, setServings] = useState<number>(initialRecipe.servings ?? 1);
// Redigeringsformulär-state // Redigeringsformulär-state
const [form, setForm] = useState({ const [form, setForm] = useState({
@@ -77,6 +78,7 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
description: initialRecipe.description || '', description: initialRecipe.description || '',
instructions: initialRecipe.instructions || '', instructions: initialRecipe.instructions || '',
imageUrl: initialRecipe.imageUrl || '', imageUrl: initialRecipe.imageUrl || '',
servings: initialRecipe.servings as number | null,
ingredients: initialRecipe.ingredients.map((ing) => ({ ingredients: initialRecipe.ingredients.map((ing) => ({
productId: ing.productId, productId: ing.productId,
quantity: String(ing.quantity), quantity: String(ing.quantity),
@@ -269,16 +271,34 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
{/* Ingredienser */} {/* Ingredienser */}
<section style={sectionStyle}> <section style={sectionStyle}>
<h2 style={sectionTitle}>Ingredienser</h2> <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' }}> <ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.4rem' }}>
{recipe.ingredients.map((ing) => ( {recipe.ingredients.map((ing) => {
<li key={ing.id} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline' }}> const scale = recipe.servings ? servings / recipe.servings : 1;
<span style={{ fontWeight: 600, minWidth: '60px', textAlign: 'right' }}> const qty = Number(ing.quantity) * scale;
{Number(ing.quantity)} {ing.unit} const displayQty = qty % 1 === 0 ? qty : parseFloat(qty.toFixed(2));
</span> return (
<span>{ing.product.canonicalName || ing.product.name}</span> <li key={ing.id} style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline' }}>
{ing.note && <span style={{ color: '#868e96', fontSize: '0.875rem' }}>({ing.note})</span>} <span style={{ fontWeight: 600, minWidth: '60px', textAlign: 'right' }}>
</li> {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> </ul>
</section> </section>
@@ -329,6 +349,58 @@ export default function RecipeDetailClient({ recipe: initialRecipe }: { recipe:
)} )}
</section> </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> </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' }} /> <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> <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> </section>
{/* Ingredienser */} {/* Ingredienser */}
+1
View File
@@ -100,6 +100,7 @@ export type Recipe = {
description: string | null; description: string | null;
instructions: string | null; instructions: string | null;
imageUrl: string | null; imageUrl: string | null;
servings: number | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
ingredients: RecipeIngredient[]; ingredients: RecipeIngredient[];