feat: add servings field to Recipe model and implement inventory comparison functionality
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `Recipe` ADD COLUMN `servings` INTEGER NULL;
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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' },
|
||||
});
|
||||
}
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user