399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useTransition } from 'react';
|
||
import Link from 'next/link';
|
||
import { parseErrorResponse } from '../../lib/error-handler';
|
||
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 selectAndLoad = (id: string) => {
|
||
setSelectedRecipeId(id);
|
||
setError(null);
|
||
setPreview(null);
|
||
|
||
startTransition(async () => {
|
||
try {
|
||
const res = await fetch(`/api/recipe-preview-proxy?id=${id}`, {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const errorMessage = await parseErrorResponse(res);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const data: RecipeInventoryPreview = await res.json();
|
||
setPreview(data);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
|
||
setError(message);
|
||
}
|
||
});
|
||
};
|
||
|
||
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 errorMessage = await parseErrorResponse(res);
|
||
throw new Error(errorMessage);
|
||
}
|
||
|
||
const data: RecipeInventoryPreview = await res.json();
|
||
setPreview(data);
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : 'Ett okänt fel inträffade.';
|
||
setError(message);
|
||
}
|
||
});
|
||
};
|
||
|
||
const listedRecipes = recipes.slice(0, 10);
|
||
|
||
return (
|
||
<section style={{ display: 'grid', gap: '1rem' }}>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: '1rem', alignItems: 'start' }}>
|
||
<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>
|
||
|
||
{/* Receptlista till höger */}
|
||
<div
|
||
style={{
|
||
border: '1px solid #ddd',
|
||
borderRadius: '8px',
|
||
padding: '1rem',
|
||
minWidth: '180px',
|
||
maxWidth: '220px',
|
||
}}
|
||
>
|
||
<h3 style={{ margin: '0 0 0.75rem 0', fontSize: '1rem' }}>Mina recept</h3>
|
||
<ul style={{ margin: 0, padding: 0, listStyle: 'none', display: 'grid', gap: '0.4rem' }}>
|
||
{listedRecipes.map((recipe) => (
|
||
<li key={recipe.id}>
|
||
<button
|
||
type="button"
|
||
disabled={isPending}
|
||
onClick={() => selectAndLoad(String(recipe.id))}
|
||
style={{
|
||
width: '100%',
|
||
textAlign: 'left',
|
||
padding: '0.4rem 0.6rem',
|
||
background: String(recipe.id) === selectedRecipeId ? '#e8f0fe' : 'transparent',
|
||
border: `1px solid ${String(recipe.id) === selectedRecipeId ? '#4285f4' : '#eee'}`,
|
||
borderRadius: '4px',
|
||
cursor: 'pointer',
|
||
fontWeight: String(recipe.id) === selectedRecipeId ? 600 : 400,
|
||
color: String(recipe.id) === selectedRecipeId ? '#1a56db' : '#333',
|
||
fontSize: '0.9rem',
|
||
}}
|
||
>
|
||
{recipe.name}
|
||
</button>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</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>
|
||
);
|
||
} |