21dc06829a
feat(products): implement bulk update for product categories feat(recipes): add servings input to WriteRecipePage and update MealPlanClient for servings management refactor(types): enhance Product and Category types with additional properties
321 lines
13 KiB
TypeScript
321 lines
13 KiB
TypeScript
'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>
|
||
|
||
{/* 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,
|
||
};
|
||
}
|