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
@@ -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) => (
<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}
</span>
<span>{ing.product.canonicalName || ing.product.name}</span>
{ing.note && <span style={{ color: '#868e96', fontSize: '0.875rem' }}>({ing.note})</span>}
</li>
))}
{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' }}>
{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 */}