Files
recipe-app/frontend/app/recipes/RecipePreview.tsx
T
2026-04-10 17:45:24 +02:00

329 lines
12 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.
'use client';
import { useState, useTransition } from 'react';
import Link from 'next/link';
import type {
Recipe,
RecipeInventoryPreview,
} from '../../features/inventory/types';
type Props = {
recipes: Recipe[];
};
function getStatusStyle(status: 'enough' | 'missing' | 'unit_mismatch') {
if (status === 'enough') {
return {
label: 'Räcker',
color: '#1f5f2c',
background: '#ecf8ee',
border: '#b9e0bf',
};
}
if (status === 'missing') {
return {
label: 'Saknas',
color: '#8b0000',
background: '#ffeaea',
border: '#f1b5b5',
};
}
return {
label: 'Enhetskonflikt',
color: '#8a4b00',
background: '#fff4e5',
border: '#f0cf9b',
};
}
function formatDate(value: string | null) {
if (!value) return null;
return new Date(value).toLocaleDateString('sv-SE');
}
function isWeightUnit(unit: string): boolean {
return ['kg', 'g', 'mg', 'ml', 'l'].includes(unit.trim().toLowerCase());
}
function isPieceUnit(unit: string): boolean {
return ['st', 'stycke'].includes(unit.trim().toLowerCase());
}
export default function RecipePreview({ recipes }: Props) {
const [selectedRecipeId, setSelectedRecipeId] = useState('');
const [preview, setPreview] = useState<RecipeInventoryPreview | null>(null);
const [error, setError] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const loadPreview = () => {
setError(null);
setPreview(null);
if (!selectedRecipeId) {
setError('Välj ett recept.');
return;
}
startTransition(async () => {
try {
const res = await fetch(`/api/recipe-preview-proxy?id=${selectedRecipeId}`, {
method: 'GET',
cache: 'no-store',
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || 'Kunde inte hämta recept-preview.');
}
const data: RecipeInventoryPreview = await res.json();
setPreview(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Okänt fel');
}
});
};
return (
<section style={{ display: 'grid', gap: '1rem' }}>
<div
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
}}
>
<h2 style={{ margin: 0 }}>Recept mot hemmavaror</h2>
<label>
Recept
<br />
<select
value={selectedRecipeId}
onChange={(e) => setSelectedRecipeId(e.target.value)}
style={{ width: '100%', padding: '0.5rem' }}
>
<option value="">Välj recept</option>
{recipes.map((recipe) => (
<option key={recipe.id} value={recipe.id}>
{recipe.name}
</option>
))}
</select>
</label>
<div style={{ display: 'flex', gap: '0.75rem' }}>
<button
type="button"
onClick={loadPreview}
disabled={isPending}
style={{ padding: '0.6rem 1rem' }}
>
{isPending ? 'Hämtar preview...' : 'Visa preview'}
</button>
{selectedRecipeId && (
<Link
href={`/recipes/${selectedRecipeId}/edit`}
style={{
padding: '0.6rem 1rem',
background: '#f0f0f0',
color: '#333',
border: '1px solid #ccc',
borderRadius: '4px',
textDecoration: 'none',
cursor: 'pointer',
display: 'inline-block',
}}
>
Redigera recept
</Link>
)}
</div>
{error ? <p style={{ color: 'crimson', margin: 0 }}>{error}</p> : null}
</div>
{preview ? (
<section style={{ display: 'grid', gap: '1rem' }}>
<article
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.5rem',
}}
>
<h3 style={{ margin: 0 }}>{preview.recipe.name}</h3>
{preview.recipe.description ? <div>{preview.recipe.description}</div> : null}
<div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap' }}>
<span>Ingredienser: {preview.summary.totalIngredients}</span>
<span>Räcker: {preview.summary.enoughCount}</span>
<span>Saknas: {preview.summary.missingCount}</span>
<span>Enhetskonflikter: {preview.summary.unitMismatchCount}</span>
<strong>
{preview.summary.canCookExactly
? 'Kan lagas exakt'
: 'Kan inte lagas exakt ännu'}
</strong>
</div>
{preview.summary.unitMismatchCount > 0 && (
<div
style={{
padding: '0.75rem',
background: '#fff4e5',
border: '1px solid #f0cf9b',
borderRadius: '4px',
color: '#8a4b00',
fontSize: '0.9rem',
marginTop: '0.5rem',
}}
>
<strong> Enhetskonflikt!</strong> {preview.summary.unitMismatchCount} ingrediens
{preview.summary.unitMismatchCount !== 1 ? 'er har' : ' har'} olika enheter än vad som finns i hemmavaror.
<br />
<span style={{ fontSize: '0.85rem', marginTop: '0.25rem', display: 'block' }}>
T.ex. receptet säger "0.5 st" men du har lagrat "1.3 kg". Du kan antingen:
<ul style={{ margin: '0.5rem 0 0 1rem', paddingLeft: '1rem' }}>
<li>Redigera receptet för att matcha dina enheter</li>
<li>Lagra ingrediensen med samma enhet som receptet använder</li>
</ul>
</span>
</div>
)}
</article>
<div style={{ display: 'grid', gap: '0.75rem' }}>
{preview.ingredients.map((ingredient) => {
const statusStyle = getStatusStyle(ingredient.status);
return (
<article
key={ingredient.ingredientId}
style={{
border: `1px solid ${statusStyle.border}`,
borderRadius: '8px',
padding: '1rem',
display: 'grid',
gap: '0.75rem',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '1rem',
flexWrap: 'wrap',
}}
>
<div>
<strong>{ingredient.productName}</strong>
<div>
Krävs: {ingredient.requiredQuantity} {ingredient.requiredUnit}
</div>
{ingredient.note ? <div>Notering: {ingredient.note}</div> : null}
</div>
<div
style={{
padding: '0.3rem 0.6rem',
borderRadius: '999px',
background: statusStyle.background,
color: statusStyle.color,
border: `1px solid ${statusStyle.border}`,
fontSize: '0.85rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
{statusStyle.label}
</div>
</div>
<div style={{ display: 'grid', gap: '0.35rem' }}>
<div>
Tillgängligt i jämförbar enhet: {ingredient.availableQuantity}{' '}
{ingredient.availableUnit || ''}
</div>
{ingredient.status === 'missing' ? (
<div>
Saknas: {ingredient.missingQuantity} {ingredient.requiredUnit}
</div>
) : null}
{ingredient.status === 'unit_mismatch' ? (
<div
style={{
padding: '0.5rem',
background: '#fff4e5',
border: '1px solid #f0cf9b',
borderRadius: '4px',
fontSize: '0.9rem',
marginTop: '0.25rem',
}}
>
<strong>Enhetsproblem:</strong> Receptet kräver {ingredient.requiredUnit} men hemmavaror lagras i andra enheter.
Uppdatera receptet eller lagra ingrediensen med rätt enhet.
</div>
) : null}
</div>
{ingredient.matchingInventoryItems.length > 0 ? (
<div style={{ display: 'grid', gap: '0.35rem' }}>
<strong>Matchande inventory</strong>
{ingredient.matchingInventoryItems.map((item) => (
<div key={item.id}>
#{item.id}: {item.quantity} {item.unit}
{item.brand ? `, ${item.brand}` : ''}
{item.location ? `, ${item.location}` : ''}
{item.bestBeforeDate ? `, bäst före ${formatDate(item.bestBeforeDate)}` : ''}
</div>
))}
</div>
) : null}
{ingredient.otherInventoryItems && ingredient.otherInventoryItems.length > 0 ? (
<div style={{ display: 'grid', gap: '0.35rem' }}>
<strong>Andra enheter:</strong>
{ingredient.otherInventoryItems.map((item) => {
const weight = isWeightUnit(item.unit);
const pieces = isPieceUnit(ingredient.requiredUnit);
const pieces2 = isPieceUnit(item.unit);
const weight2 = isWeightUnit(ingredient.requiredUnit);
return (
<div key={item.id}>
#{item.id}: {item.quantity} {item.unit}
{item.canConvert ? (
<span> {(item.convertedQuantity || 0).toFixed(2)} {ingredient.requiredUnit}</span>
) : (
<span>
{weight && pieces
? ' (kan inte konvertera vikt till stycken)'
: pieces2 && weight2
? ' (kan inte konvertera stycken till vikt)'
: ' (kan inte konvertera)'}
</span>
)}
</div>
);
})}
</div>
) : null}
</article>
);
})}
</div>
</section>
) : null}
</section>
);
}