hellth-hub/apps/admin/app/(authed)/rezepte/pruefung/page.tsx
Sebastian Mayer 2436dd9cac Initial commit: Hellth Hub monorepo
Mobile-first Web-App fuer persoenliches Health-, Ernaehrungs-,
Aktivitaets- und Einkaufs-Tracking im Home-Lab-Betrieb.

Stack: pnpm Monorepo - Next.js 16 + React 19 + Tailwind (apps/admin) -
Hono + tRPC + Better Auth mit 2FA und Passkeys (packages/api) -
PostgreSQL + Drizzle ORM (packages/db) - Docker fuer Postgres und pgAdmin.

Module:
- Auth mit Login, 2FA (TOTP + Backup-Codes), Passkeys und Admin-Recovery
- Tagesansicht mit Essen, Trinken, Gewicht, Aktivitaeten und Coach-Hinweisen
- Lebensmittelkatalog mit Naehrwerten, Portionen, OCR/Barcode/Open Food Facts
- Rezepte mit Live-Naehrwertberechnung und Community-Vorschlaegen
- Essensplanung und Wochenbudget mit Sportbonus
- Einkaufslisten mit Maerkten und Laufweg-Sortierung
- Hellfireclub mit Aktivitaetspunkten und gemeinsamer Aktivitaetsplanung
- Wearables-Vorbereitung (Fitbit / Google Fit / Health Connect)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:15:16 +02:00

226 lines
10 KiB
TypeScript

"use client";
import { CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import { useMemo, useState } from "react";
import { CatalogFood, PortionUnit, Recipe, calcRecipe, classifyRecipe } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
type SubmissionStatus = "pending" | "accepted" | "rejected";
type SubmissionForReview = {
id: string;
name: string;
portions: number;
minutes: number;
notes: string | null;
ingredients: Array<{
id: string;
foodId: string;
amountG: unknown;
displayAmount: unknown | null;
displayUnit: PortionUnit | null;
portionId: string | null;
food: Parameters<typeof foodFromSubmission>[0] | null;
}>;
};
function toNumber(value: unknown) {
return Number(value) || 0;
}
function foodFromSubmission(food: {
id: string;
name: string;
brand: string | null;
category: string;
kcalPer100g: unknown;
proteinPer100g: unknown;
carbsPer100g: unknown;
fatPer100g: unknown;
servingG: unknown;
preferredUnit: PortionUnit | null;
densityGPerMl: unknown | null;
source: "seed" | "manual" | "openfoodfacts" | "ocr" | "community";
barcode: string | null;
availableEverywhere: boolean;
}): CatalogFood {
return {
id: food.id,
name: food.name,
brand: food.brand ?? undefined,
category: food.category,
kcalPer100g: toNumber(food.kcalPer100g),
proteinPer100g: toNumber(food.proteinPer100g),
carbsPer100g: toNumber(food.carbsPer100g),
fatPer100g: toNumber(food.fatPer100g),
servingG: toNumber(food.servingG) || 100,
preferredUnit: food.preferredUnit ?? "g",
densityGPerMl: food.densityGPerMl == null ? undefined : toNumber(food.densityGPerMl),
source: food.source,
barcode: food.barcode ?? undefined,
availableEverywhere: food.availableEverywhere,
};
}
function SubmissionNutrition({ submission }: { submission: SubmissionForReview }) {
const foods = submission.ingredients.map((ingredient) => ingredient.food ? foodFromSubmission(ingredient.food) : null).filter(Boolean) as CatalogFood[];
const recipe: Recipe = {
id: submission.id,
name: submission.name,
portions: submission.portions,
minutes: submission.minutes,
notes: submission.notes ?? undefined,
ingredients: submission.ingredients.map((ingredient) => ({
foodId: ingredient.foodId,
amountG: toNumber(ingredient.amountG),
amount: ingredient.displayAmount == null ? undefined : toNumber(ingredient.displayAmount),
unit: ingredient.displayUnit ?? undefined,
portionId: ingredient.portionId ?? undefined,
})),
};
const totals = calcRecipe(recipe, foods);
const classification = classifyRecipe(recipe, foods);
return (
<div className="mt-5 rounded-3xl bg-gray-50 p-4">
<div className="flex flex-wrap gap-2">
{classification.tags.map((tag) => <span key={tag} className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-600">{tag}</span>)}
</div>
<div className="mt-4 flex h-3 overflow-hidden rounded-full bg-white">
<div className="bg-amber-400" style={{ width: `${classification.distribution.carbs}%` }} />
<div className="bg-blue-500" style={{ width: `${classification.distribution.fat}%` }} />
<div className="bg-emerald-500" style={{ width: `${classification.distribution.protein}%` }} />
</div>
<div className="mt-3 grid grid-cols-4 gap-2 text-center">
<div><p className="text-lg font-black text-gray-950">{totals.kcal}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.carbs} g</p><p className="text-xs font-bold text-gray-400">KH {classification.distribution.carbs}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.fat} g</p><p className="text-xs font-bold text-gray-400">Fett {classification.distribution.fat}%</p></div>
<div><p className="text-lg font-black text-gray-950">{totals.protein} g</p><p className="text-xs font-bold text-gray-400">Eiweiß {classification.distribution.protein}%</p></div>
</div>
<div className="mt-4 grid gap-2">
{classification.criteria.map((criterion) => (
<div key={criterion.label} className="flex items-center justify-between gap-3 text-sm font-bold">
<span className={criterion.ok ? "text-emerald-700" : "text-amber-700"}>{criterion.ok ? "OK" : "!"} {criterion.label}</span>
<span className="text-gray-400">{criterion.detail}</span>
</div>
))}
</div>
</div>
);
}
export default function RezeptPruefungPage() {
const utils = trpc.useUtils();
const [status, setStatus] = useState<SubmissionStatus>("pending");
const [reviewNotes, setReviewNotes] = useState<Record<string, string>>({});
const submissionsQuery = trpc.health.recipeSubmissionsForReview.useQuery({ status });
const reviewMutation = trpc.health.reviewRecipeSubmission.useMutation({
onSuccess: async () => {
await utils.health.recipeSubmissionsForReview.invalidate({ status });
await utils.health.listRecipes.invalidate();
},
});
const submissions = useMemo(() => submissionsQuery.data ?? [], [submissionsQuery.data]);
function review(id: string, action: "accept" | "reject") {
reviewMutation.mutate({
id,
action,
reviewNote: reviewNotes[id]?.trim() || null,
});
}
return (
<div className="mx-auto max-w-6xl space-y-5 pb-24">
<header className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<p className="text-sm font-black uppercase text-[#0F766E]">Moderation</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Rezeptvorschläge prüfen</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Akzeptierte Vorschläge werden als öffentliche Rezepte in den Rezeptkatalog übernommen.
</p>
</header>
<section className="rounded-[2rem] bg-white p-4 shadow-sm ring-1 ring-gray-100">
<div className="grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
{(["pending", "accepted", "rejected"] as const).map((item) => (
<button
key={item}
type="button"
onClick={() => setStatus(item)}
className={`rounded-full px-4 py-2 text-sm font-black ${
status === item ? "bg-white text-gray-950 shadow-sm" : "text-gray-400"
}`}
>
{item === "pending" ? "Offen" : item === "accepted" ? "Akzeptiert" : "Abgelehnt"}
</button>
))}
</div>
</section>
<div className="grid gap-4">
{submissions.map((submission) => (
<article key={submission.id} className="rounded-[2rem] bg-white p-6 shadow-sm ring-1 ring-gray-100">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<p className="text-2xl font-black text-gray-950">{submission.name}</p>
<p className="mt-1 text-sm font-bold text-gray-400">
{submission.minutes} min · {submission.portions} Portionen
{submission.mealPrepDays ? ` · ${submission.mealPrepDays} Meal-Prep-Tag(e)` : ""}
</p>
{submission.notes ? <p className="mt-3 max-w-2xl text-sm font-semibold text-gray-500">{submission.notes}</p> : null}
</div>
<span className={`rounded-full px-4 py-2 text-sm font-black ${
submission.status === "accepted"
? "bg-emerald-50 text-emerald-700"
: submission.status === "rejected"
? "bg-red-50 text-red-700"
: "bg-amber-50 text-amber-700"
}`}>
{submission.status === "accepted" ? "akzeptiert" : submission.status === "rejected" ? "abgelehnt" : "offen"}
</span>
</div>
<div className="mt-5 divide-y divide-gray-100 rounded-3xl bg-gray-50 px-4">
{submission.ingredients.map((ingredient) => (
<div key={ingredient.id} className="flex justify-between gap-3 py-3 text-sm font-bold text-gray-500">
<span className="min-w-0 truncate">{ingredient.food?.name ?? "Unbekanntes Lebensmittel"}</span>
<span>{toNumber(ingredient.displayAmount) || toNumber(ingredient.amountG)} {ingredient.displayUnit ?? "g"} · {Math.round(toNumber(ingredient.amountG))} g</span>
</div>
))}
</div>
<SubmissionNutrition submission={submission} />
{submission.status === "pending" ? (
<div className="mt-5 grid gap-3 lg:grid-cols-[1fr_150px_150px]">
<input
value={reviewNotes[submission.id] ?? ""}
onChange={(event) => setReviewNotes((current) => ({ ...current, [submission.id]: event.target.value }))}
placeholder="Prüfnotiz optional"
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
/>
<button type="button" onClick={() => review(submission.id, "reject")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-red-50 px-4 py-3 text-sm font-black text-red-700 disabled:opacity-50">
<XMarkIcon className="h-5 w-5" />
Ablehnen
</button>
<button type="button" onClick={() => review(submission.id, "accept")} disabled={reviewMutation.isPending} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-[#0F766E] px-4 py-3 text-sm font-black text-white disabled:opacity-50">
<CheckIcon className="h-5 w-5" />
Akzeptieren
</button>
</div>
) : submission.reviewNote ? (
<p className="mt-4 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-bold text-gray-500">{submission.reviewNote}</p>
) : null}
</article>
))}
{!submissions.length && !submissionsQuery.isLoading ? (
<p className="rounded-[2rem] bg-white p-6 text-sm font-bold text-gray-400 shadow-sm ring-1 ring-gray-100">
Keine Rezeptvorschläge in dieser Ansicht.
</p>
) : null}
</div>
</div>
);
}