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>
169 lines
7.3 KiB
TypeScript
169 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { CheckIcon } from "@heroicons/react/24/outline";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { trpc } from "@/lib/trpc";
|
|
|
|
type TemplateDraft = {
|
|
id: string;
|
|
name: string;
|
|
category: string;
|
|
defaultMinutes: number;
|
|
defaultCalories: number;
|
|
defaultPoints: string;
|
|
};
|
|
|
|
function numberInput(value: unknown) {
|
|
return String(Number(value) || 0);
|
|
}
|
|
|
|
export default function PunkteEinstellungenPage() {
|
|
const utils = trpc.useUtils();
|
|
const settingsQuery = trpc.health.pointSettings.useQuery();
|
|
const [recipeSubmissionPoints, setRecipeSubmissionPoints] = useState("25");
|
|
const [clubActivityDefaultPoints, setClubActivityDefaultPoints] = useState("100");
|
|
const [templates, setTemplates] = useState<TemplateDraft[]>([]);
|
|
const [saved, setSaved] = useState(false);
|
|
|
|
const updateMutation = trpc.health.updatePointSettings.useMutation({
|
|
onSuccess: async () => {
|
|
await Promise.all([
|
|
utils.health.pointSettings.invalidate(),
|
|
utils.health.pointDefaults.invalidate(),
|
|
utils.health.listActivityTemplates.invalidate(),
|
|
]);
|
|
setSaved(true);
|
|
window.setTimeout(() => setSaved(false), 1600);
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (!settingsQuery.data) return;
|
|
setRecipeSubmissionPoints(numberInput(settingsQuery.data.recipeSubmissionPoints));
|
|
setClubActivityDefaultPoints(numberInput(settingsQuery.data.clubActivityDefaultPoints));
|
|
setTemplates(settingsQuery.data.activityTemplates.map((template) => ({
|
|
id: template.id,
|
|
name: template.name,
|
|
category: template.category,
|
|
defaultMinutes: template.defaultMinutes,
|
|
defaultCalories: template.defaultCalories,
|
|
defaultPoints: numberInput(template.defaultPoints),
|
|
})));
|
|
}, [settingsQuery.data]);
|
|
|
|
const groupedTemplates = useMemo(() => {
|
|
const groups = new Map<string, TemplateDraft[]>();
|
|
for (const template of templates) {
|
|
const current = groups.get(template.category) ?? [];
|
|
current.push(template);
|
|
groups.set(template.category, current);
|
|
}
|
|
return [...groups.entries()].sort(([a], [b]) => a.localeCompare(b, "de-DE"));
|
|
}, [templates]);
|
|
|
|
function updateTemplate(id: string, value: string) {
|
|
setTemplates((current) => current.map((template) => (
|
|
template.id === id ? { ...template, defaultPoints: value } : template
|
|
)));
|
|
}
|
|
|
|
function save() {
|
|
updateMutation.mutate({
|
|
recipeSubmissionPoints: Number(recipeSubmissionPoints) || 0,
|
|
clubActivityDefaultPoints: Number(clubActivityDefaultPoints) || 0,
|
|
activityTemplates: templates.map((template) => ({
|
|
id: template.id,
|
|
defaultPoints: Number(template.defaultPoints) || 0,
|
|
})),
|
|
});
|
|
}
|
|
|
|
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]">Admin</p>
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal text-gray-950">Punktevergabe</h1>
|
|
<p className="mt-2 max-w-2xl text-sm font-semibold text-gray-400">
|
|
Globale Punktwerte für Hellfireclub, Rezeptvorschläge und Aktivitätskatalog.
|
|
</p>
|
|
</header>
|
|
|
|
<section className="grid gap-4 lg:grid-cols-2">
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Systempunkte</h2>
|
|
<div className="mt-4 grid gap-3">
|
|
<label className="grid gap-2">
|
|
<span className="text-sm font-black text-gray-500">Rezeptvorschlag einreichen</span>
|
|
<input
|
|
type="number"
|
|
value={recipeSubmissionPoints}
|
|
onChange={(event) => setRecipeSubmissionPoints(event.target.value)}
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
|
/>
|
|
</label>
|
|
<label className="grid gap-2">
|
|
<span className="text-sm font-black text-gray-500">Hellfireclub-Aktivität erstellen, Default</span>
|
|
<input
|
|
type="number"
|
|
value={clubActivityDefaultPoints}
|
|
onChange={(event) => setClubActivityDefaultPoints(event.target.value)}
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
|
/>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|
<h2 className="text-xl font-black text-gray-950">Hinweis</h2>
|
|
<p className="mt-4 text-sm font-semibold text-gray-500">
|
|
Bestehende Punkte-Logs bleiben unverändert. Änderungen gelten für neue Aktivitäten,
|
|
neue Rezeptvorschläge und neue Einträge aus dem Aktivitätskatalog.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
onClick={save}
|
|
disabled={updateMutation.isPending}
|
|
className="mt-5 inline-flex w-full items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white disabled:opacity-50"
|
|
>
|
|
<CheckIcon className="h-5 w-5" />
|
|
Punkte speichern
|
|
</button>
|
|
{saved ? <p className="mt-3 rounded-2xl bg-emerald-50 px-4 py-3 text-sm font-bold text-emerald-800">Punkte gespeichert.</p> : null}
|
|
{updateMutation.error ? <p className="mt-3 rounded-2xl bg-red-50 px-4 py-3 text-sm font-bold text-red-800">{updateMutation.error.message}</p> : null}
|
|
</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">Aktivitätskatalog</h2>
|
|
<div className="mt-5 grid gap-5">
|
|
{groupedTemplates.map(([category, entries]) => (
|
|
<div key={category}>
|
|
<h3 className="text-sm font-black uppercase text-gray-400">{category}</h3>
|
|
<div className="mt-2 grid gap-2">
|
|
{entries.map((template) => (
|
|
<div key={template.id} className="grid gap-3 rounded-3xl bg-gray-50 p-4 md:grid-cols-[1fr_120px_120px_130px] md:items-center">
|
|
<div>
|
|
<p className="text-sm font-black text-gray-950">{template.name}</p>
|
|
<p className="text-xs font-bold text-gray-400">{template.defaultMinutes} min · {template.defaultCalories} kcal</p>
|
|
</div>
|
|
<span className="text-xs font-bold text-gray-400">{template.id}</span>
|
|
<span className="text-xs font-bold text-gray-400">{template.category}</span>
|
|
<input
|
|
type="number"
|
|
value={template.defaultPoints}
|
|
onChange={(event) => updateTemplate(template.id, event.target.value)}
|
|
className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-semibold"
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
{!templates.length && !settingsQuery.isLoading ? (
|
|
<p className="rounded-3xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Keine globalen Aktivitätsvorlagen gefunden.</p>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|