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 (
|
||||
<main style={{ padding: '2rem' }}>
|
||||
<h1>Recipe App</h1>
|
||||
<p>Next.js-frontend fungerar.</p>
|
||||
<p>Det här är första riktiga grunden för projektet.</p>
|
||||
|
||||
<p>
|
||||
<Link href="/inventory">Gå till hemmavaror</Link>
|
||||
</p>
|
||||
<p>
|
||||
<Link href="/admin/products">Gå till admingränssnitt</Link>
|
||||
</p>
|
||||
<p><Link href="/inventory">Gå till varor som finns hemma</Link></p>
|
||||
<p><Link href="/admin/products">Gå till produktadmin</Link></p>
|
||||
<p><Link href="/recipes">Gå till recept</Link></p>
|
||||
</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;
|
||||
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,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
Reference in New Issue
Block a user