227 lines
10 KiB
TypeScript
227 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>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|