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:
Nils-Johan Gynther
2026-04-19 20:06:33 +02:00
parent 7e296acb60
commit 31b7da82cd
8 changed files with 86 additions and 11 deletions
-341
View File
@@ -1,341 +0,0 @@
'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,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default async function MealPlanPage() {
return (
<main style={{ padding: '1rem', maxWidth: '900px', margin: '0 auto' }}>
<Navigation />
<h1 style={{ marginBottom: '0.25rem' }}>Matplanering</h1>
<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>