Files
recipe-app/frontend/app/matplan/MealPlanClient.tsx
T

284 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 };
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();
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 [inventoryCompare, setInventoryCompare] = useState<InventoryCompareItem[]>([]);
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, 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);
}
}, [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>
{/* 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>
);
}
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,
};
}