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

353 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
servings: number | null;
recipe: Pick<Recipe, 'id' | 'name' | 'imageUrl'> & {
servings: number | null;
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' | 'pantry';
};
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 {
const existing = entryForDate(date);
await fetch('/api/meal-plan-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, recipeId: Number(recipeId), servings: existing?.servings ?? null }),
});
}
await load();
} finally {
setSaving(null);
}
};
const plannedCount = weekDates.filter((d) => entryForDate(d)).length;
const handleServingsChange = async (date: string, servings: number | null) => {
const entry = entryForDate(date);
if (!entry) return;
await fetch('/api/meal-plan-proxy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ date, recipeId: entry.recipe.id, servings }),
});
await load();
};
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>
)}
{entry && entry.recipe.servings && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', fontSize: '0.82rem', color: '#555' }}>
<span>Port.:</span>
<input
type="number"
min={1}
step={1}
value={entry.servings ?? entry.recipe.servings}
onChange={(e) => handleServingsChange(date, e.target.value ? Number(e.target.value) : null)}
style={{ width: '52px', padding: '0.25rem 0.4rem', border: '1px solid #ced4da', borderRadius: '4px', fontSize: '0.82rem' }}
/>
{entry.servings && entry.servings !== entry.recipe.servings && (
<button
type="button"
onClick={() => handleServingsChange(date, null)}
title={`Återställ till ${entry.recipe.servings} portioner`}
style={{ fontSize: '0.75rem', color: '#888', background: 'none', border: 'none', cursor: 'pointer', padding: '0 0.2rem' }}
>
{entry.recipe.servings}
</button>
)}
</div>
)}
{isSaving && <span style={{ fontSize: '0.8rem', color: '#888' }}>Sparar...</span>}
</div>
</div>
);
})}
</div>
{/* Inköpslista med lagerstatus */}
<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>
) : (() => {
// Berika varje rad med inventariestatus
type DisplayStatus = 'enough' | 'partial' | 'missing' | 'pantry';
const enriched = shopping.map((item) => {
const cmp = inventoryCompare.find(
(c) => c.productId === item.productId && c.unit === item.unit,
);
let displayStatus: DisplayStatus = 'missing';
let buyQty = item.quantity;
if (cmp) {
if (cmp.status === 'pantry') {
displayStatus = 'pantry';
buyQty = 0;
} else if (cmp.available >= cmp.required) {
displayStatus = 'enough';
buyQty = 0;
} else if (cmp.available > 0) {
displayStatus = 'partial';
buyQty = cmp.missing;
}
}
return { ...item, cmp, displayStatus, buyQty };
});
const order: Record<DisplayStatus, number> = { missing: 0, partial: 1, enough: 2, pantry: 3 };
enriched.sort((a, b) => order[a.displayStatus] - order[b.displayStatus] || a.name.localeCompare(b.name, 'sv'));
const missingCount = enriched.filter((e) => e.displayStatus === 'missing').length;
const partialCount = enriched.filter((e) => e.displayStatus === 'partial').length;
const enoughCount = enriched.filter((e) => e.displayStatus === 'enough').length;
const pantryCount = enriched.filter((e) => e.displayStatus === 'pantry').length;
const hasCompare = inventoryCompare.length > 0;
const fmtQty = (n: number) => (n % 1 === 0 ? String(n) : n.toFixed(1));
return (
<>
{/* Sammanfattning */}
{hasCompare && (
<div style={{ display: 'flex', gap: '1rem', marginBottom: '0.75rem', flexWrap: 'wrap', fontSize: '0.85rem' }}>
{missingCount > 0 && <span style={{ color: '#8b0000', fontWeight: 600 }}> {missingCount} saknas</span>}
{partialCount > 0 && <span style={{ color: '#7a5000', fontWeight: 600 }}> {partialCount} delvis hemma</span>}
{enoughCount > 0 && <span style={{ color: '#1f5f2c', fontWeight: 600 }}> {enoughCount} hemma</span>}
{pantryCount > 0 && <span style={{ color: '#555', fontWeight: 600 }}>📦 {pantryCount} baslager</span>}
{missingCount === 0 && partialCount === 0 && (
<span style={{ color: '#1f5f2c', fontWeight: 600 }}> Du har allt hemma!</span>
)}
</div>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: 0, display: 'grid', gap: '0.4rem' }}>
{enriched.map((item) => {
const isMissing = item.displayStatus === 'missing';
const isPartial = item.displayStatus === 'partial';
const isEnough = item.displayStatus === 'enough';
const isPantry = item.displayStatus === 'pantry';
const bg = isMissing ? '#ffeaea' : isPartial ? '#fff8e6' : isPantry ? '#f5f5f5' : '#ecf8ee';
const icon = isMissing ? '❌' : isPartial ? '⚠️' : isPantry ? '📦' : '✅';
return (
<li
key={`${item.productId}-${item.unit}`}
style={{
display: 'grid',
gridTemplateColumns: hasCompare ? '1.5rem 1fr auto' : '1fr auto',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4rem 0.6rem',
borderRadius: '6px',
background: hasCompare ? bg : 'transparent',
fontSize: '0.88rem',
}}
>
{hasCompare && <span title={isEnough ? 'Finns hemma' : isPartial ? 'Delvis hemma' : isPantry ? 'Baslager — alltid hemma' : 'Saknas'}>{icon}</span>}
<span style={{ color: (isEnough || isPantry) ? '#555' : '#111' }}>
<strong>{item.name}</strong>
{isPartial && item.cmp && (
<span style={{ color: '#7a5000', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
({fmtQty(item.cmp.available)} av {fmtQty(item.cmp.required)} {item.unit} hemma)
</span>
)}
{isEnough && (
<span style={{ color: '#888', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
(finns hemma)
</span>
)}
{isPantry && (
<span style={{ color: '#888', fontSize: '0.8rem', marginLeft: '0.4rem' }}>
(baslager)
</span>
)}
</span>
<span style={{ fontWeight: 600, whiteSpace: 'nowrap', color: (isEnough || isPantry) ? '#888' : '#111' }}>
{(isEnough || isPantry)
? '—'
: `${fmtQty(item.buyQty)} ${item.unit}`}
</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,
};
}