feat: add servings field to Recipe model and implement inventory comparison functionality
This commit is contained in:
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user