From d1870decacf5eba5d3776228f3387260b71bdfa3 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Thu, 9 Apr 2026 15:12:54 +0200 Subject: [PATCH] 11D recipe preview --- .../app/api/recipe-preview-proxy/route.ts | 21 ++ frontend/app/page.tsx | 13 +- frontend/app/recipes/RecipePreview.tsx | 251 ++++++++++++++++++ frontend/app/recipes/page.tsx | 15 ++ frontend/features/inventory/types.ts | 61 +++++ frontend/tsconfig.json | 4 +- 6 files changed, 354 insertions(+), 11 deletions(-) create mode 100644 frontend/app/api/recipe-preview-proxy/route.ts create mode 100644 frontend/app/recipes/RecipePreview.tsx create mode 100644 frontend/app/recipes/page.tsx diff --git a/frontend/app/api/recipe-preview-proxy/route.ts b/frontend/app/api/recipe-preview-proxy/route.ts new file mode 100644 index 00000000..1f4b1c94 --- /dev/null +++ b/frontend/app/api/recipe-preview-proxy/route.ts @@ -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', + }, + }); +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 56a3e7f7..a0f0c5b9 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -4,15 +4,10 @@ export default function HomePage() { return (

Recipe App

-

Next.js-frontend fungerar.

Det här är första riktiga grunden för projektet.

- -

- Gå till hemmavaror -

-

- Gå till admingränssnitt -

+

Gå till varor som finns hemma

+

Gå till produktadmin

+

Gå till recept

); -} +} \ No newline at end of file diff --git a/frontend/app/recipes/RecipePreview.tsx b/frontend/app/recipes/RecipePreview.tsx new file mode 100644 index 00000000..55bcf60e --- /dev/null +++ b/frontend/app/recipes/RecipePreview.tsx @@ -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(null); + const [error, setError] = useState(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 ( +
+
+

Recept mot hemmavaror

+ + + +
+ +
+ + {error ?

{error}

: null} +
+ + {preview ? ( +
+
+

{preview.recipe.name}

+ {preview.recipe.description ?
{preview.recipe.description}
: null} + +
+ Ingredienser: {preview.summary.totalIngredients} + Räcker: {preview.summary.enoughCount} + Saknas: {preview.summary.missingCount} + Enhetskonflikter: {preview.summary.unitMismatchCount} + + {preview.summary.canCookExactly + ? 'Kan lagas exakt' + : 'Kan inte lagas exakt ännu'} + +
+
+ +
+ {preview.ingredients.map((ingredient) => { + const statusStyle = getStatusStyle(ingredient.status); + + return ( +
+
+
+ {ingredient.productName} +
+ Krävs: {ingredient.requiredQuantity} {ingredient.requiredUnit} +
+ {ingredient.note ?
Notering: {ingredient.note}
: null} +
+ +
+ {statusStyle.label} +
+
+ +
+
+ Tillgängligt i jämförbar enhet: {ingredient.availableQuantity}{' '} + {ingredient.availableUnit || ''} +
+ + {ingredient.status === 'missing' ? ( +
+ Saknas: {ingredient.missingQuantity} {ingredient.requiredUnit} +
+ ) : null} +
+ + {ingredient.matchingInventoryItems.length > 0 ? ( +
+ Matchande inventory + {ingredient.matchingInventoryItems.map((item) => ( +
+ #{item.id}: {item.quantity} {item.unit} + {item.brand ? `, ${item.brand}` : ''} + {item.location ? `, ${item.location}` : ''} + {item.bestBeforeDate + ? `, bäst före ${formatDate(item.bestBeforeDate)}` + : ''} +
+ ))} +
+ ) : null} + + {ingredient.otherInventoryItems.length > 0 ? ( +
+ Andra inventory-poster med annan enhet + {ingredient.otherInventoryItems.map((item) => ( +
+ #{item.id}: {item.quantity} {item.unit} + {item.brand ? `, ${item.brand}` : ''} + {item.location ? `, ${item.location}` : ''} + {item.bestBeforeDate + ? `, bäst före ${formatDate(item.bestBeforeDate)}` + : ''} +
+ ))} +
+ ) : null} +
+ ); + })} +
+
+ ) : null} +
+ ); +} \ No newline at end of file diff --git a/frontend/app/recipes/page.tsx b/frontend/app/recipes/page.tsx new file mode 100644 index 00000000..6445fdb7 --- /dev/null +++ b/frontend/app/recipes/page.tsx @@ -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('/api/recipes'); + + return ( +
+

Recept

+

Här kan du jämföra recept mot nuvarande hemmavaror.

+ +
+ ); +} \ No newline at end of file diff --git a/frontend/features/inventory/types.ts b/frontend/features/inventory/types.ts index b9f0e921..e1068af9 100644 --- a/frontend/features/inventory/types.ts +++ b/frontend/features/inventory/types.ts @@ -53,4 +53,65 @@ export type InventoryConsumption = { amountUsed: string; 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; + }; }; \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index e38927d6..692d7104 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -8,7 +8,7 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", + "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", @@ -19,4 +19,4 @@ }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"] -} +} \ No newline at end of file