feat: implement meal planning feature with CRUD operations and UI integration
This commit is contained in:
@@ -28,6 +28,14 @@ Receptlistan (`app/recipes/RecipeGrid.tsx`) är en enkel lista. Förbättra pres
|
|||||||
### 7. Matplanering
|
### 7. Matplanering
|
||||||
Lägg till en enkel veckomenylista: välj ett recept per dag, se en samlad ingredienslista och jämför mot inventariet. Kräver en ny `MealPlan`-modell i Prisma.
|
Lägg till en enkel veckomenylista: välj ett recept per dag, se en samlad ingredienslista och jämför mot inventariet. Kräver en ny `MealPlan`-modell i Prisma.
|
||||||
|
|
||||||
|
### 8. Portionsjustering av recept
|
||||||
|
Recept lagras utan portionsangivelse. Lägg till ett `servings`-fält (heltal, t.ex. 4) på `Recipe`-modellen och låt användaren ange önskat antal portioner i receptvyn. Alla ingrediensmängder räknas då om proportionellt (t.ex. recept för 4 → 6 pers: × 1,5). Implementationen berör:
|
||||||
|
- **Databas:** `servings Int?` på `Recipe` i Prisma + migration
|
||||||
|
- **Backend:** `servings` exponeras i `RecipeDto` och kan sättas vid create/update
|
||||||
|
- **Frontend (`app/recipes/[id]/`):** räknare för portioner (+ / −) bredvid ingredienslistan — beräkningen sker rent i klientkomponenten utan extra API-anrop
|
||||||
|
- **Receptskapande (`app/recipes/create/` och `write/`):** lägg till ett fält för grundportioner
|
||||||
|
- **Matplaneringen (`app/matplan/`):** inköpslistan bör ta hänsyn till önskat portionsantal per dag
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Teknisk skuld och städning
|
## Teknisk skuld och städning
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE `MealPlanEntry` (
|
||||||
|
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||||
|
`date` DATE NOT NULL,
|
||||||
|
`recipeId` INTEGER NOT NULL,
|
||||||
|
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
`updatedAt` DATETIME(3) NOT NULL,
|
||||||
|
|
||||||
|
UNIQUE INDEX `MealPlanEntry_date_key`(`date`),
|
||||||
|
INDEX `MealPlanEntry_date_idx`(`date`),
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE `MealPlanEntry` ADD CONSTRAINT `MealPlanEntry_recipeId_fkey` FOREIGN KEY (`recipeId`) REFERENCES `Recipe`(`id`) ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@@ -69,7 +69,8 @@ model Recipe {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
ingredients RecipeIngredient[]
|
ingredients RecipeIngredient[]
|
||||||
|
mealPlanEntries MealPlanEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model RecipeIngredient {
|
model RecipeIngredient {
|
||||||
@@ -92,4 +93,16 @@ model PantryItem {
|
|||||||
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model MealPlanEntry {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
date DateTime @db.Date
|
||||||
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
||||||
|
recipeId Int
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([date])
|
||||||
|
@@index([date])
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import { InventoryModule } from './inventory/inventory.module';
|
|||||||
import { RecipesModule } from './recipes/recipes.module';
|
import { RecipesModule } from './recipes/recipes.module';
|
||||||
import { QuickImportModule } from './quick-import/quick-import.module';
|
import { QuickImportModule } from './quick-import/quick-import.module';
|
||||||
import { PantryModule } from './pantry/pantry.module';
|
import { PantryModule } from './pantry/pantry.module';
|
||||||
|
import { MealPlanModule } from './meal-plan/meal-plan.module';
|
||||||
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -17,6 +18,7 @@ import { PantryModule } from './pantry/pantry.module';
|
|||||||
RecipesModule,
|
RecipesModule,
|
||||||
QuickImportModule,
|
QuickImportModule,
|
||||||
PantryModule,
|
PantryModule,
|
||||||
|
MealPlanModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { IsDateString, IsInt, IsPositive } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateMealPlanEntryDto {
|
||||||
|
@IsDateString()
|
||||||
|
date: string; // YYYY-MM-DD
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
recipeId: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||||
|
import { MealPlanService } from './meal-plan.service';
|
||||||
|
import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto';
|
||||||
|
|
||||||
|
@Controller('meal-plan')
|
||||||
|
export class MealPlanController {
|
||||||
|
constructor(private readonly mealPlanService: MealPlanService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
findByRange(@Query('from') from: string, @Query('to') to: string) {
|
||||||
|
return this.mealPlanService.findByRange(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('shopping-list')
|
||||||
|
shoppingList(@Query('from') from: string, @Query('to') to: string) {
|
||||||
|
return this.mealPlanService.shoppingList(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
upsert(@Body() dto: CreateMealPlanEntryDto) {
|
||||||
|
return this.mealPlanService.upsert(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':date')
|
||||||
|
removeByDate(@Param('date') date: string) {
|
||||||
|
return this.mealPlanService.removeByDate(date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MealPlanController } from './meal-plan.controller';
|
||||||
|
import { MealPlanService } from './meal-plan.service';
|
||||||
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [MealPlanController],
|
||||||
|
providers: [MealPlanService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
})
|
||||||
|
export class MealPlanModule {}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateMealPlanEntryDto } from './dto/create-meal-plan-entry.dto';
|
||||||
|
|
||||||
|
const recipeSelect = {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
imageUrl: true,
|
||||||
|
ingredients: {
|
||||||
|
select: {
|
||||||
|
quantity: true,
|
||||||
|
unit: true,
|
||||||
|
note: true,
|
||||||
|
product: { select: { id: true, name: true, canonicalName: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MealPlanService {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
/** Hämta matplan för ett datumintervall (default: nuvarande vecka) */
|
||||||
|
async findByRange(from: string, to: string) {
|
||||||
|
return this.prisma.mealPlanEntry.findMany({
|
||||||
|
where: {
|
||||||
|
date: { gte: new Date(from), lte: new Date(to) },
|
||||||
|
},
|
||||||
|
include: { recipe: { select: recipeSelect } },
|
||||||
|
orderBy: { date: 'asc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sätt recept för ett datum (upsert — ett recept per dag) */
|
||||||
|
async upsert(dto: CreateMealPlanEntryDto) {
|
||||||
|
const date = new Date(dto.date);
|
||||||
|
return this.prisma.mealPlanEntry.upsert({
|
||||||
|
where: { date },
|
||||||
|
create: { date, recipeId: dto.recipeId },
|
||||||
|
update: { recipeId: dto.recipeId },
|
||||||
|
include: { recipe: { select: recipeSelect } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ta bort matplanspost för ett datum */
|
||||||
|
async removeByDate(date: string) {
|
||||||
|
const entry = await this.prisma.mealPlanEntry.findUnique({
|
||||||
|
where: { date: new Date(date) },
|
||||||
|
});
|
||||||
|
if (!entry) throw new NotFoundException('Ingen matplanspost för detta datum');
|
||||||
|
return this.prisma.mealPlanEntry.delete({ where: { id: entry.id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Samlad ingredienslista för ett datumintervall */
|
||||||
|
async shoppingList(from: string, to: string) {
|
||||||
|
const entries = await this.findByRange(from, to);
|
||||||
|
|
||||||
|
// Summera ingredienser per produkt+enhet
|
||||||
|
const map = new Map<string, { productId: number; name: string; quantity: number; unit: string }>();
|
||||||
|
for (const entry of entries) {
|
||||||
|
for (const ing of entry.recipe.ingredients) {
|
||||||
|
const key = `${ing.product.id}-${ing.unit}`;
|
||||||
|
const existing = map.get(key);
|
||||||
|
const qty = Number(ing.quantity);
|
||||||
|
if (existing) {
|
||||||
|
existing.quantity += qty;
|
||||||
|
} else {
|
||||||
|
map.set(key, {
|
||||||
|
productId: ing.product.id,
|
||||||
|
name: ing.product.canonicalName || ing.product.name,
|
||||||
|
quantity: qty,
|
||||||
|
unit: ing.unit,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(map.values()).sort((a, b) => a.name.localeCompare(b.name, 'sv'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,6 +104,21 @@ export default function Navigation() {
|
|||||||
>
|
>
|
||||||
⚡ Snabbimport recept
|
⚡ Snabbimport recept
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/matplan"
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
background: '#fff',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
textDecoration: 'none',
|
||||||
|
color: '#0070f3',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 Matplan
|
||||||
|
</Link>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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 query = searchParams.toString();
|
||||||
|
const res = await fetch(`${API_BASE}/api/meal-plan${query ? `?${query}` : ''}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const body = await request.text();
|
||||||
|
const res = await fetch(`${API_BASE}/api/meal-plan`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: NextRequest) {
|
||||||
|
const date = request.nextUrl.searchParams.get('date');
|
||||||
|
const res = await fetch(`${API_BASE}/api/meal-plan/${date}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
return new NextResponse(null, { status: res.status });
|
||||||
|
}
|
||||||
@@ -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/shopping-list?from=${from}&to=${to}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { Recipe } from '../../features/inventory/types';
|
||||||
|
|
||||||
|
const DAYS_SV = ['Måndag', 'Tisdag', 'Onsdag', 'Torsdag', 'Fredag', 'Lördag', 'Söndag'];
|
||||||
|
|
||||||
|
type MealPlanEntry = {
|
||||||
|
id: number;
|
||||||
|
date: string;
|
||||||
|
recipe: Pick<Recipe, 'id' | 'name' | 'imageUrl'> & {
|
||||||
|
ingredients: { quantity: string; unit: string; note: string | null; product: { id: number; name: string; canonicalName: string | null } }[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ShoppingItem = { productId: number; name: string; quantity: number; unit: string };
|
||||||
|
|
||||||
|
function getWeekDates(offset = 0): string[] {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.getDay();
|
||||||
|
const monday = new Date(now);
|
||||||
|
monday.setDate(now.getDate() - (day === 0 ? 6 : day - 1) + offset * 7);
|
||||||
|
return Array.from({ length: 7 }, (_, i) => {
|
||||||
|
const d = new Date(monday);
|
||||||
|
d.setDate(monday.getDate() + i);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MealPlanClient({ recipes }: { recipes: Recipe[] }) {
|
||||||
|
const [weekOffset, setWeekOffset] = useState(0);
|
||||||
|
const [entries, setEntries] = useState<MealPlanEntry[]>([]);
|
||||||
|
const [shopping, setShopping] = useState<ShoppingItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null); // date being saved
|
||||||
|
|
||||||
|
const weekDates = getWeekDates(weekOffset);
|
||||||
|
const from = weekDates[0];
|
||||||
|
const to = weekDates[6];
|
||||||
|
|
||||||
|
const weekLabel = (() => {
|
||||||
|
const f = new Date(from);
|
||||||
|
const t = new Date(to);
|
||||||
|
return `${f.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })} – ${t.toLocaleDateString('sv-SE', { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [entriesRes, shoppingRes] = await Promise.all([
|
||||||
|
fetch(`/api/meal-plan-proxy?from=${from}&to=${to}`),
|
||||||
|
fetch(`/api/meal-plan-proxy/shopping?from=${from}&to=${to}`),
|
||||||
|
]);
|
||||||
|
const entriesData = await entriesRes.json();
|
||||||
|
setEntries(Array.isArray(entriesData) ? entriesData : []);
|
||||||
|
if (shoppingRes.ok) setShopping(await shoppingRes.json());
|
||||||
|
else setShopping([]);
|
||||||
|
} catch {
|
||||||
|
setEntries([]);
|
||||||
|
setShopping([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [from, to]);
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
|
const entryForDate = (date: string) => entries.find((e) => e.date.slice(0, 10) === date);
|
||||||
|
|
||||||
|
const handleSelect = async (date: string, recipeId: string) => {
|
||||||
|
setSaving(date);
|
||||||
|
try {
|
||||||
|
if (!recipeId) {
|
||||||
|
await fetch(`/api/meal-plan-proxy?date=${date}`, { method: 'DELETE' });
|
||||||
|
} else {
|
||||||
|
await fetch('/api/meal-plan-proxy', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ date, recipeId: Number(recipeId) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const plannedCount = weekDates.filter((d) => entryForDate(d)).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Veckonavigering */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', marginBottom: '1.5rem' }}>
|
||||||
|
<button onClick={() => setWeekOffset((w) => w - 1)} style={btnStyle()}>← Förra veckan</button>
|
||||||
|
<span style={{ fontWeight: 600 }}>{weekLabel}</span>
|
||||||
|
<button onClick={() => setWeekOffset((w) => w + 1)} style={btnStyle()}>Nästa vecka →</button>
|
||||||
|
{weekOffset !== 0 && (
|
||||||
|
<button onClick={() => setWeekOffset(0)} style={btnStyle('#0070f3')}>Denna vecka</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p style={{ color: '#888' }}>Laddar...</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Veckovy */}
|
||||||
|
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '2rem' }}>
|
||||||
|
{weekDates.map((date, i) => {
|
||||||
|
const entry = entryForDate(date);
|
||||||
|
const isSaving = saving === date;
|
||||||
|
const isToday = date === new Date().toISOString().slice(0, 10);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={date}
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: '100px 1fr',
|
||||||
|
gap: '1rem',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0.75rem 1rem',
|
||||||
|
border: `1px solid ${isToday ? '#0070f3' : '#dee2e6'}`,
|
||||||
|
borderRadius: '8px',
|
||||||
|
background: isToday ? '#f0f7ff' : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 700, fontSize: '0.9rem' }}>{DAYS_SV[i]}</div>
|
||||||
|
<div style={{ fontSize: '0.78rem', color: '#888' }}>
|
||||||
|
{new Date(date).toLocaleDateString('sv-SE', { day: 'numeric', month: 'short' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
|
||||||
|
<select
|
||||||
|
value={entry?.recipe.id ?? ''}
|
||||||
|
onChange={(e) => handleSelect(date, e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
style={{
|
||||||
|
flex: '1 1 200px',
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
border: '1px solid #ced4da',
|
||||||
|
borderRadius: '6px',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
background: '#fff',
|
||||||
|
color: entry ? '#111' : '#888',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Inget planerat —</option>
|
||||||
|
{recipes.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>{r.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{entry && (
|
||||||
|
<Link
|
||||||
|
href={`/recipes/${entry.recipe.id}`}
|
||||||
|
style={{ fontSize: '0.8rem', color: '#0070f3', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Visa recept →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Samlad ingredienslista */}
|
||||||
|
<section style={{ border: '1px solid #dee2e6', borderRadius: '8px', padding: '1rem' }}>
|
||||||
|
<h2 style={{ margin: '0 0 0.75rem', fontSize: '1.1rem' }}>
|
||||||
|
Inköpslista ({plannedCount} {plannedCount === 1 ? 'recept' : 'recept'} planerade)
|
||||||
|
</h2>
|
||||||
|
{plannedCount === 0 ? (
|
||||||
|
<p style={{ color: '#888', margin: 0 }}>Välj recept ovan för att se en samlad ingredienslista.</p>
|
||||||
|
) : shopping.length === 0 ? (
|
||||||
|
<p style={{ color: '#888', margin: 0 }}>Laddar ingredienser...</p>
|
||||||
|
) : (
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.35rem' }}>
|
||||||
|
{shopping.map((item) => (
|
||||||
|
<li
|
||||||
|
key={`${item.productId}-${item.unit}`}
|
||||||
|
style={{ display: 'flex', gap: '0.5rem', alignItems: 'baseline' }}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 600, minWidth: '70px', textAlign: 'right' }}>
|
||||||
|
{item.quantity % 1 === 0 ? item.quantity : item.quantity.toFixed(1)} {item.unit}
|
||||||
|
</span>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function btnStyle(bg?: string): React.CSSProperties {
|
||||||
|
return {
|
||||||
|
padding: '0.45rem 0.9rem',
|
||||||
|
background: bg || '#f0f0f0',
|
||||||
|
color: bg ? '#fff' : '#333',
|
||||||
|
border: '1px solid ' + (bg || '#ccc'),
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { fetchJson } from '../../../lib/api';
|
||||||
|
import type { Recipe } from '../../../features/inventory/types';
|
||||||
|
import Navigation from '../../Navigation';
|
||||||
|
import MealPlanClient from './MealPlanClient';
|
||||||
|
|
||||||
|
export default async function MealPlanPage() {
|
||||||
|
const recipes = await fetchJson<Recipe[]>('/api/recipes').catch(() => [] as Recipe[]);
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
|
||||||
|
<Navigation />
|
||||||
|
<h1 style={{ marginBottom: '0.25rem' }}>Matplanering</h1>
|
||||||
|
<p style={{ color: '#666', marginBottom: '1.5rem' }}>
|
||||||
|
Välj ett recept per dag — se en samlad ingredienslista i slutet.
|
||||||
|
</p>
|
||||||
|
<MealPlanClient recipes={recipes} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user