feat(Navigation): update navigation links and rename 'Matplan' to 'Matsedel'
feat(matsedel): add MealPlanClient and page for meal planning feat(profil): add AI and suggestion tabs for admin users
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
'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';
|
||||
};
|
||||
|
||||
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';
|
||||
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.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 };
|
||||
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 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>}
|
||||
{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 bg = isMissing ? '#ffeaea' : isPartial ? '#fff8e6' : '#ecf8ee';
|
||||
const icon = isMissing ? '❌' : isPartial ? '⚠️' : '✅';
|
||||
|
||||
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' : 'Saknas'}>{icon}</span>}
|
||||
<span style={{ color: isEnough ? '#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>
|
||||
)}
|
||||
</span>
|
||||
<span style={{ fontWeight: 600, whiteSpace: 'nowrap', color: isEnough ? '#888' : '#111' }}>
|
||||
{isEnough
|
||||
? '—'
|
||||
: `${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,
|
||||
};
|
||||
}
|
||||
@@ -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' }}>Matsedel</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