feat: implement meal planning feature with CRUD operations and UI integration
This commit is contained in:
@@ -104,6 +104,21 @@ export default function Navigation() {
|
||||
>
|
||||
⚡ Snabbimport recept
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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