hellth-hub/apps/admin/app/(authed)/lebensmittel/page.tsx

452 lines
23 KiB
TypeScript
Raw Normal View History

"use client";
import {
ArrowPathIcon,
CameraIcon,
CheckCircleIcon,
HandThumbDownIcon,
HandThumbUpIcon,
MagnifyingGlassIcon,
PlusIcon,
ScaleIcon,
} from "@heroicons/react/24/outline";
import { useState } from "react";
import { parseNutritionLabel, PortionUnit, unitLabel } from "@/lib/hellth-data";
import { trpc } from "@/lib/trpc";
const portionUnits: PortionUnit[] = ["g", "ml", "tsp", "tbsp", "piece", "slice", "cup", "pack", "bottle"];
type FoodForm = {
name: string;
brand: string;
category: string;
kcalPer100g: string;
proteinPer100g: string;
carbsPer100g: string;
fatPer100g: string;
servingG: string;
preferredUnit: PortionUnit;
densityGPerMl: string;
portionLabel: string;
portionUnit: PortionUnit;
portionAmount: string;
portionGramsEquivalent: string;
barcode: string;
availableEverywhere: boolean;
};
const emptyForm: FoodForm = {
name: "",
brand: "",
category: "Sonstiges",
kcalPer100g: "",
proteinPer100g: "",
carbsPer100g: "",
fatPer100g: "",
servingG: "100",
preferredUnit: "g",
densityGPerMl: "",
portionLabel: "",
portionUnit: "piece",
portionAmount: "1",
portionGramsEquivalent: "",
barcode: "",
availableEverywhere: true,
};
function toNumber(value: string) {
return Number(value.replace(",", ".")) || 0;
}
function formFromFood(food: Record<string, unknown>): FoodForm {
return {
...emptyForm,
name: String(food.name ?? ""),
brand: String(food.brand ?? ""),
category: String(food.category ?? "Sonstiges"),
kcalPer100g: food.kcalPer100g != null ? String(food.kcalPer100g) : "",
proteinPer100g: food.proteinPer100g != null ? String(food.proteinPer100g) : "",
carbsPer100g: food.carbsPer100g != null ? String(food.carbsPer100g) : "",
fatPer100g: food.fatPer100g != null ? String(food.fatPer100g) : "",
servingG: food.servingG != null ? String(food.servingG) : "100",
barcode: String(food.barcode ?? ""),
};
}
export default function LebensmittelPage() {
const utils = trpc.useUtils();
const [query, setQuery] = useState("");
const [form, setForm] = useState<FoodForm>(emptyForm);
const [barcode, setBarcode] = useState("");
const [ocrText, setOcrText] = useState("");
const [status, setStatus] = useState<string | null>(null);
const [ocrBusy, setOcrBusy] = useState(false);
const [lookupBusy, setLookupBusy] = useState(false);
const [sourceFoodId, setSourceFoodId] = useState<string | null>(null);
const [sourceQuery, setSourceQuery] = useState("");
const foodsQuery = trpc.health.listFoods.useQuery({ query });
const sourceSearchQuery = trpc.health.searchOpenFoodFacts.useQuery(
{ query: sourceQuery, pageSize: 8 },
{ enabled: sourceQuery.trim().length >= 2 },
);
const saveFoodMutation = trpc.health.saveFood.useMutation({
onSuccess: async (result) => {
await utils.health.listFoods.invalidate();
setForm(emptyForm);
setStatus(`Lebensmittel gespeichert (${result.id}).`);
},
});
const addNutritionSourceMutation = trpc.health.addNutritionSource.useMutation({
onSuccess: async (result) => {
await utils.health.listFoods.invalidate();
setStatus(`Quelle gespeichert. Neuer Durchschnitt aus ${result.sourceCount} Quelle(n): ${result.average.kcalPer100g} kcal/100 g.`);
},
});
const setFoodPreferenceMutation = trpc.health.setFoodPreference.useMutation({
onSuccess: async () => {
await utils.health.listFoods.invalidate();
await utils.health.recipeRecommendations.invalidate();
},
});
const foods = foodsQuery.data ?? [];
function saveFood(source: "manual" | "openfoodfacts" | "ocr" = "manual") {
if (!form.name.trim()) return;
saveFoodMutation.mutate({
name: form.name.trim(),
brand: form.brand.trim() || null,
category: form.category.trim() || "Sonstiges",
kcalPer100g: toNumber(form.kcalPer100g),
proteinPer100g: toNumber(form.proteinPer100g),
carbsPer100g: toNumber(form.carbsPer100g),
fatPer100g: toNumber(form.fatPer100g),
servingG: toNumber(form.servingG) || 100,
preferredUnit: form.preferredUnit,
densityGPerMl: form.densityGPerMl.trim() ? toNumber(form.densityGPerMl) : null,
barcode: form.barcode.trim() || null,
source,
availableEverywhere: form.availableEverywhere,
portions: form.portionLabel.trim()
? [{
label: form.portionLabel.trim(),
unit: form.portionUnit,
amount: toNumber(form.portionAmount) || 1,
gramsEquivalent: toNumber(form.portionGramsEquivalent) || toNumber(form.servingG) || 100,
}]
: [],
});
}
async function importFromOpenFoodFacts() {
const cleanBarcode = barcode.replace(/\D/g, "");
if (!cleanBarcode) return;
setLookupBusy(true);
setStatus(null);
try {
const response = await fetch(`https://world.openfoodfacts.org/api/v2/product/${cleanBarcode}.json?fields=product_name,brands,categories,nutriments,serving_quantity`);
const result = await response.json() as {
status?: number;
product?: {
product_name?: string;
brands?: string;
categories?: string;
serving_quantity?: number;
nutriments?: Record<string, number>;
};
};
if (result.status !== 1 || !result.product) {
setStatus("Kein Produkt für diesen Barcode gefunden.");
return;
}
const nutriments = result.product.nutriments ?? {};
setForm(formFromFood({
name: result.product.product_name || "Unbenanntes Produkt",
brand: result.product.brands?.split(",")[0]?.trim(),
category: result.product.categories?.split(",")[0]?.trim() || "Supermarkt",
kcalPer100g: nutriments["energy-kcal_100g"] ?? 0,
proteinPer100g: nutriments.proteins_100g ?? 0,
carbsPer100g: nutriments.carbohydrates_100g ?? 0,
fatPer100g: nutriments.fat_100g ?? 0,
servingG: result.product.serving_quantity ?? 100,
barcode: cleanBarcode,
}));
setStatus("Open-Food-Facts-Daten geladen. Bitte prüfen und speichern.");
} catch {
setStatus("Open Food Facts konnte gerade nicht abgefragt werden.");
} finally {
setLookupBusy(false);
}
}
async function readLabelImage(file: File) {
setOcrBusy(true);
setStatus("OCR läuft im Browser. Das kann einen Moment dauern.");
try {
const { recognize } = await import("tesseract.js");
const result = await recognize(file, "deu+eng");
const text = result.data.text;
const parsed = parseNutritionLabel(text);
setOcrText(text);
setForm((current) => ({
...current,
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
}));
setStatus("OCR abgeschlossen. Werte bitte kontrollieren und speichern.");
} catch {
setStatus("OCR konnte nicht abgeschlossen werden. Du kannst den Labeltext unten einfügen.");
} finally {
setOcrBusy(false);
}
}
function parsePastedOcr() {
const parsed = parseNutritionLabel(ocrText);
setForm((current) => ({
...current,
...formFromFood({ ...parsed, name: current.name, brand: current.brand, category: current.category, servingG: current.servingG || "100" }),
}));
setStatus("Text ausgewertet. Werte bitte kontrollieren und speichern.");
}
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]">Health-API</p>
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Lebensmittelkatalog</h1>
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
Eigene Basisdaten, Supermarktprodukte, Barcode-Import und Label-OCR. Lebensmittel werden jetzt serverseitig pro Benutzer gespeichert.
</p>
</header>
<section className="grid gap-4 xl:grid-cols-[1fr_0.9fr]">
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Lebensmittel, Marke, Kategorie oder Barcode suchen"
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
/>
</div>
<div className="mt-5 grid gap-3 md:grid-cols-2">
{foods.map((food) => (
<article key={food.id} className="rounded-3xl bg-gray-50 p-4">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-lg font-black text-gray-950">{food.name}</p>
<p className="text-sm font-bold text-gray-400">{food.brand || food.category} - {Number(food.servingG)} g Portion</p>
</div>
{food.isVerified ? <CheckCircleIcon className="h-6 w-6 text-emerald-600" /> : null}
</div>
<div className="mt-4 grid grid-cols-4 gap-2 text-center">
<div><p className="text-xl font-black">{Number(food.kcalPer100g)}</p><p className="text-xs font-bold text-gray-400">kcal</p></div>
<div><p className="text-xl font-black">{Number(food.proteinPer100g)}</p><p className="text-xs font-bold text-gray-400">Protein</p></div>
<div><p className="text-xl font-black">{Number(food.carbsPer100g)}</p><p className="text-xs font-bold text-gray-400">KH</p></div>
<div><p className="text-xl font-black">{Number(food.fatPer100g)}</p><p className="text-xs font-bold text-gray-400">Fett</p></div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className="rounded-full bg-white px-3 py-1 text-xs font-bold uppercase text-gray-300">{food.source}</span>
<span className="rounded-full bg-emerald-50 px-3 py-1 text-xs font-bold text-emerald-700">
{food.availableEverywhere ? "überall erhältlich" : "Marktzuordnung"}
</span>
{food.portions?.map((portion) => (
<span key={portion.id} className="rounded-full bg-teal-50 px-3 py-1 text-xs font-bold text-teal-700">
{portion.label}: {Number(portion.amount)} {unitLabel(portion.unit)} = {Number(portion.gramsEquivalent)} g
</span>
))}
</div>
<div className="mt-3 flex gap-2">
<button
type="button"
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === 1 ? 0 : 1 })}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
food.userVote === 1 ? "bg-emerald-600 text-white" : "bg-white text-emerald-700"
}`}
>
<HandThumbUpIcon className="h-4 w-4" />
Mag ich
</button>
<button
type="button"
onClick={() => setFoodPreferenceMutation.mutate({ foodId: food.id, vote: food.userVote === -1 ? 0 : -1 })}
className={`inline-flex flex-1 items-center justify-center gap-2 rounded-full px-3 py-2 text-xs font-black ${
food.userVote === -1 ? "bg-red-600 text-white" : "bg-white text-red-700"
}`}
>
<HandThumbDownIcon className="h-4 w-4" />
Nicht meins
</button>
</div>
<div className="mt-3 rounded-2xl bg-white p-3">
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-black uppercase text-gray-400">Quellen</p>
<button
type="button"
onClick={() => {
setSourceFoodId(sourceFoodId === food.id ? null : food.id);
setSourceQuery(food.name);
}}
className="inline-flex items-center gap-1 rounded-full bg-gray-950 px-3 py-1 text-xs font-black text-white"
>
<ScaleIcon className="h-4 w-4" />
Mittelwert
</button>
</div>
{food.nutritionSources?.length ? (
<div className="mt-2 space-y-1">
{food.nutritionSources.map((source) => (
<a
key={source.id}
href={source.url ?? "#"}
target="_blank"
rel="noreferrer"
className="block rounded-xl bg-gray-50 px-3 py-2 text-xs font-bold text-gray-500 hover:bg-gray-100"
>
{source.provider}: {Number(source.kcalPer100g)} kcal/100 g
</a>
))}
</div>
) : (
<p className="mt-2 text-xs font-bold text-gray-300">Noch keine einzelnen Quellen hinterlegt.</p>
)}
{sourceFoodId === food.id ? (
<div className="mt-3 space-y-2">
<input
value={sourceQuery}
onChange={(event) => setSourceQuery(event.target.value)}
placeholder="Open Food Facts suchen"
className="w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-semibold"
/>
{(sourceSearchQuery.data ?? []).map((source) => (
<button
key={`${source.provider}-${source.providerFoodId ?? source.name}`}
type="button"
onClick={() => addNutritionSourceMutation.mutate({ foodId: food.id, ...source })}
className="block w-full rounded-2xl bg-emerald-50 px-3 py-2 text-left text-xs font-bold text-emerald-900 hover:bg-emerald-100"
>
{source.name} · {source.brand || "ohne Marke"} · {source.kcalPer100g} kcal/100 g
</button>
))}
{sourceSearchQuery.isFetching ? <p className="text-xs font-bold text-gray-300">Suche läuft...</p> : null}
</div>
) : null}
</div>
</article>
))}
{!foods.length && !foodsQuery.isLoading ? (
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Lebensmittel gefunden.</p>
) : null}
</div>
</div>
<aside className="space-y-4">
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Produkt importieren</h2>
<div className="mt-4 grid grid-cols-[1fr_52px] gap-2">
<input
value={barcode}
onChange={(event) => setBarcode(event.target.value)}
placeholder="Barcode / EAN"
className="rounded-full border border-gray-200 px-4 py-3 text-sm"
/>
<button
type="button"
onClick={() => void importFromOpenFoodFacts()}
disabled={lookupBusy}
className="grid place-items-center rounded-full bg-gray-950 text-white disabled:opacity-50"
aria-label="Barcode suchen"
title="Barcode suchen"
>
<ArrowPathIcon className="h-5 w-5" />
</button>
</div>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Label per OCR</h2>
<label className="mt-4 flex cursor-pointer items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
<CameraIcon className="h-5 w-5" />
Foto auswählen
<input
type="file"
accept="image/*"
className="hidden"
onChange={(event) => {
const file = event.target.files?.[0];
if (file) void readLabelImage(file);
event.currentTarget.value = "";
}}
/>
</label>
<textarea
value={ocrText}
onChange={(event) => setOcrText(event.target.value)}
placeholder="Oder OCR-/Labeltext hier einfügen..."
className="mt-3 min-h-28 w-full rounded-3xl border border-gray-200 px-4 py-3 text-sm"
/>
<button type="button" onClick={parsePastedOcr} disabled={!ocrText.trim() || ocrBusy} className="mt-3 w-full rounded-full bg-gray-100 px-4 py-3 text-sm font-black text-gray-950 disabled:opacity-40">
Text auswerten
</button>
</section>
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
<h2 className="text-xl font-black text-gray-950">Lebensmittel speichern</h2>
<div className="mt-4 grid gap-3">
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} placeholder="Name" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.brand} onChange={(event) => setForm((current) => ({ ...current, brand: event.target.value }))} placeholder="Marke optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))} placeholder="Kategorie" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<label className="flex items-center gap-3 rounded-2xl bg-gray-50 px-4 py-3 text-sm font-black text-gray-950">
<input
type="checkbox"
checked={form.availableEverywhere}
onChange={(event) => setForm((current) => ({ ...current, availableEverywhere: event.target.checked }))}
/>
Überall erhältlich
</label>
<div className="grid grid-cols-2 gap-2">
<input value={form.kcalPer100g} onChange={(event) => setForm((current) => ({ ...current, kcalPer100g: event.target.value }))} placeholder="kcal/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.servingG} onChange={(event) => setForm((current) => ({ ...current, servingG: event.target.value }))} placeholder="Portion g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.proteinPer100g} onChange={(event) => setForm((current) => ({ ...current, proteinPer100g: event.target.value }))} placeholder="Protein/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.carbsPer100g} onChange={(event) => setForm((current) => ({ ...current, carbsPer100g: event.target.value }))} placeholder="KH/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.fatPer100g} onChange={(event) => setForm((current) => ({ ...current, fatPer100g: event.target.value }))} placeholder="Fett/100g" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.barcode} onChange={(event) => setForm((current) => ({ ...current, barcode: event.target.value }))} placeholder="Barcode" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
</div>
<div className="rounded-3xl bg-gray-50 p-4">
<p className="text-sm font-black text-gray-950">Alltagseinheiten</p>
<div className="mt-3 grid grid-cols-2 gap-2">
<select value={form.preferredUnit} onChange={(event) => setForm((current) => ({ ...current, preferredUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>Standard: {unitLabel(portionUnit)}</option>)}
</select>
<input value={form.densityGPerMl} onChange={(event) => setForm((current) => ({ ...current, densityGPerMl: event.target.value }))} placeholder="Dichte g/ml optional" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.portionLabel} onChange={(event) => setForm((current) => ({ ...current, portionLabel: event.target.value }))} placeholder="Portion, z. B. 1 Becher" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<select value={form.portionUnit} onChange={(event) => setForm((current) => ({ ...current, portionUnit: event.target.value as PortionUnit }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm">
{portionUnits.map((portionUnit) => <option key={portionUnit} value={portionUnit}>{unitLabel(portionUnit)}</option>)}
</select>
<input value={form.portionAmount} onChange={(event) => setForm((current) => ({ ...current, portionAmount: event.target.value }))} placeholder="Menge, z. B. 1" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
<input value={form.portionGramsEquivalent} onChange={(event) => setForm((current) => ({ ...current, portionGramsEquivalent: event.target.value }))} placeholder="entspricht g/ml-Basis" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm" />
</div>
</div>
<button
type="button"
onClick={() => saveFood(form.barcode ? "openfoodfacts" : "manual")}
disabled={saveFoodMutation.isPending}
className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
>
<PlusIcon className="h-5 w-5" />
In Katalog aufnehmen
</button>
</div>
</section>
{status ? <div className="rounded-3xl bg-emerald-50 px-5 py-4 text-sm font-bold text-emerald-800">{status}</div> : null}
{saveFoodMutation.error ? <div className="rounded-3xl bg-red-50 px-5 py-4 text-sm font-bold text-red-800">{saveFoodMutation.error.message}</div> : null}
</aside>
</section>
</div>
);
}