11D recipe preview
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
const API_BASE = process.env.NEXT_PUBLIC_API_URL_INTERNAL || 'http://recipe-api:8080';
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const id = request.nextUrl.searchParams.get('id');
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}/api/recipes/${id}/inventory-preview`, {
|
||||||
|
method: 'GET',
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -4,15 +4,10 @@ export default function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<main style={{ padding: '2rem' }}>
|
<main style={{ padding: '2rem' }}>
|
||||||
<h1>Recipe App</h1>
|
<h1>Recipe App</h1>
|
||||||
<p>Next.js-frontend fungerar.</p>
|
|
||||||
<p>Det här är första riktiga grunden för projektet.</p>
|
<p>Det här är första riktiga grunden för projektet.</p>
|
||||||
|
<p><Link href="/inventory">Gå till varor som finns hemma</Link></p>
|
||||||
<p>
|
<p><Link href="/admin/products">Gå till produktadmin</Link></p>
|
||||||
<Link href="/inventory">Gå till hemmavaror</Link>
|
<p><Link href="/recipes">Gå till recept</Link></p>
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
<Link href="/admin/products">Gå till admingränssnitt</Link>
|
|
||||||
</p>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={loadPreview}
|
||||||
|
disabled={isPending}
|
||||||
|
style={{ padding: '0.6rem 1rem' }}
|
||||||
|
>
|
||||||
|
{isPending ? 'Hämtar preview...' : 'Visa preview'}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</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}
|
||||||
|
</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.length > 0 ? (
|
||||||
|
<div style={{ display: 'grid', gap: '0.35rem' }}>
|
||||||
|
<strong>Andra inventory-poster med annan enhet</strong>
|
||||||
|
{ingredient.otherInventoryItems.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}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { fetchJson } from '../../lib/api';
|
||||||
|
import type { Recipe } from '../../features/inventory/types';
|
||||||
|
import RecipePreview from './RecipePreview';
|
||||||
|
|
||||||
|
export default async function RecipesPage() {
|
||||||
|
const recipes = await fetchJson<Recipe[]>('/api/recipes');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ padding: '1.5rem', maxWidth: '1000px', margin: '0 auto' }}>
|
||||||
|
<h1>Recept</h1>
|
||||||
|
<p>Här kan du jämföra recept mot nuvarande hemmavaror.</p>
|
||||||
|
<RecipePreview recipes={recipes} />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -54,3 +54,64 @@ export type InventoryConsumption = {
|
|||||||
comment: string | null;
|
comment: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
};
|
};
|
||||||
|
export type RecipeIngredient = {
|
||||||
|
id: number;
|
||||||
|
recipeId: number;
|
||||||
|
productId: number;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
note: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
product: Product;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Recipe = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
instructions: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
ingredients: RecipeIngredient[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipePreviewInventoryItem = {
|
||||||
|
id: number;
|
||||||
|
quantity: string;
|
||||||
|
unit: string;
|
||||||
|
brand: string | null;
|
||||||
|
location: string | null;
|
||||||
|
bestBeforeDate: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipeInventoryPreviewIngredient = {
|
||||||
|
ingredientId: number;
|
||||||
|
productId: number;
|
||||||
|
productName: string;
|
||||||
|
requiredQuantity: number;
|
||||||
|
requiredUnit: string;
|
||||||
|
note: string | null;
|
||||||
|
availableQuantity: number;
|
||||||
|
availableUnit: string | null;
|
||||||
|
matchingInventoryItems: RecipePreviewInventoryItem[];
|
||||||
|
otherInventoryItems: RecipePreviewInventoryItem[];
|
||||||
|
status: 'enough' | 'missing' | 'unit_mismatch';
|
||||||
|
missingQuantity: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipeInventoryPreview = {
|
||||||
|
recipe: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
ingredients: RecipeInventoryPreviewIngredient[];
|
||||||
|
summary: {
|
||||||
|
totalIngredients: number;
|
||||||
|
enoughCount: number;
|
||||||
|
missingCount: number;
|
||||||
|
unitMismatchCount: number;
|
||||||
|
canCookExactly: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"module": "esnext",
|
"module": "esnext",
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
"jsx": "preserve",
|
"jsx": "preserve",
|
||||||
|
|||||||
Reference in New Issue
Block a user