hellth-hub/apps/admin/app/_components/sidebar.tsx
Sebastian Mayer e264765876 Feature: Ziele in Einstellungen-Tab, Sportbonus-Anzeige, Profil-Fix
Health-/Ernährungslogik überarbeitet:

- Fix: upsertProfile speichert fehlende Größe/Gewicht als null statt 0
  (Round-Trip-Bug: gespeicherte 0 scheiterte beim erneuten Speichern an
  z.number().positive()). Frontend clampt zudem mealsPerDay/cooksPerDay
  auf die Zod-Grenzen. 3 neue API-Tests.
- Ziele-Bearbeitung vom Standalone /ziele in den Einstellungen-Tab
  ?tab=ziele gezogen; /ziele-Seite + Sidebar-Eintrag entfernt. Liest/
  schreibt jetzt aus der DB (vorher las der Tab veraltetes localStorage).
- Wochenziel wird automatisch als Tagesziel × 7 berechnet (Feld entfällt).
- Sportbonus klar dargestellt: Tagesansicht addiert den heutigen Sport
  aufs Budget (nur wenn sportAddsCalories aktiv) mit Bolt-Badge; Wochen-
  Box mit Icon und Info "ganze Woche". Dashboard invalidiert alle
  statsSummary-Varianten.
- Belohnende Banner bei Sport heute und bei Kaloriendefizit.
- Doku: rules.md-Verweis auf docs/projektreferenz.md korrigiert.
- Neuer Skill: ernaehrungswissenschaftler (fachliche Abnahme der
  Health-Logik).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:12:34 +02:00

262 lines
11 KiB
TypeScript

"use client";
import {
ArrowRightStartOnRectangleIcon,
CalendarDaysIcon,
ChartBarIcon,
BellAlertIcon,
BoltIcon,
ChevronRightIcon,
ChevronUpDownIcon,
Cog6ToothIcon,
DevicePhoneMobileIcon,
FireIcon,
MoonIcon,
ScaleIcon,
ShoppingCartIcon,
SparklesIcon,
SunIcon,
UserIcon,
UsersIcon,
} from "@heroicons/react/24/outline";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { signOut, useSession } from "@/lib/auth-client";
import { useTheme } from "./theme-provider";
type NavItem = {
href?: string;
label: string;
icon: React.ComponentType<{ className?: string }>;
disabled?: boolean;
hint?: string;
};
type NavGroup = {
label: string;
items: NavItem[];
};
const navGroups: NavGroup[] = [
{
label: "Heute",
items: [
{ href: "/", label: "Tagesansicht", icon: CalendarDaysIcon },
{ href: "/wochenbudget", label: "Wochenbudget", icon: ChartBarIcon },
{ href: "/statistiken", label: "Statistiken", icon: ChartBarIcon },
{ href: "/erinnerungen", label: "Erinnerungen", icon: BellAlertIcon },
{ href: "/aktivitaeten", label: "Aktivitäten", icon: BoltIcon },
{ href: "/wearables", label: "Wearables", icon: DevicePhoneMobileIcon },
{ href: "/hellfireclub", label: "Hellfireclub", icon: FireIcon },
{ href: "/gewicht", label: "Gewicht", icon: ScaleIcon },
],
},
{
label: "Ernährung",
items: [
{ href: "/planung", label: "Essensplanung", icon: SparklesIcon },
{ href: "/lebensmittel", label: "Lebensmittel", icon: ShoppingCartIcon },
{ href: "/maerkte", label: "Märkte", icon: ShoppingCartIcon },
{ href: "/rezepte", label: "Rezepte", icon: SparklesIcon },
{ href: "/rezepte/pruefung", label: "Rezeptprüfung", icon: SparklesIcon },
{ href: "/einkaufsliste", label: "Einkaufsliste", icon: ShoppingCartIcon },
],
},
{
label: "System",
items: [
{ href: "/benutzer", label: "Benutzer", icon: UsersIcon },
{ href: "/aktivitaetsfeed", label: "Activity Feed", icon: BellAlertIcon },
{ href: "/einstellungen", label: "Einstellungen", icon: Cog6ToothIcon },
{ href: "/einstellungen/punkte", label: "Punktevergabe", icon: ChartBarIcon },
],
},
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { data: session } = useSession();
const { isDark, toggleDark } = useTheme();
const [menuOpen, setMenuOpen] = useState(false);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
}
}
if (menuOpen) document.addEventListener("mousedown", handleOutside);
return () => document.removeEventListener("mousedown", handleOutside);
}, [menuOpen]);
function toggleGroup(label: string) {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(`${href}/`);
};
async function handleSignOut() {
await signOut();
if (session?.user?.id) {
localStorage.removeItem(`hellth-theme:${session.user.id}`);
}
document.documentElement.classList.remove("dark");
router.push("/login");
}
return (
<aside className="sticky top-0 hidden h-screen w-64 shrink-0 flex-col border-r border-gray-200 bg-white dark:border-neutral-800 dark:bg-neutral-900 lg:flex">
<div className="border-b border-gray-100 px-5 py-5 dark:border-neutral-800">
<div className="flex items-center gap-2">
<div className="flex h-[36.8px] w-[36.8px] items-center justify-center rounded-lg bg-[#0F766E] text-xs font-bold text-white">
HH
</div>
<div>
<p className="leading-none text-sm font-semibold text-gray-900 dark:text-neutral-100">Hellth Hub</p>
<p className="mt-0.5 text-xs text-gray-400">Home-Lab Health</p>
</div>
</div>
</div>
<nav className="flex-1 space-y-6 overflow-y-auto px-3 py-4">
{navGroups.map((group) => (
<div key={group.label}>
<button
type="button"
onClick={() => toggleGroup(group.label)}
className="mb-1.5 flex w-full items-center justify-between px-2 text-xs font-semibold uppercase tracking-wider text-gray-400 hover:text-gray-600 dark:hover:text-neutral-300"
>
{group.label}
<ChevronRightIcon className={`h-3 w-3 transition-transform ${!collapsedGroups.has(group.label) ? "rotate-90" : ""}`} />
</button>
{!collapsedGroups.has(group.label) ? (
<ul className="space-y-0.5">
{group.items.map((item) => {
if (item.disabled || !item.href) {
return (
<li key={`${group.label}-${item.label}`}>
<span className="group flex cursor-not-allowed items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium text-gray-300 dark:text-neutral-600">
<item.icon className="h-4.5 w-4.5 shrink-0" />
<span className="flex-1">{item.label}</span>
{item.hint ? <span className="text-[10px] uppercase">{item.hint}</span> : null}
</span>
</li>
);
}
const active = isActive(item.href);
return (
<li key={item.href}>
<Link
href={item.href}
className={`group flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-all ${
active
? "bg-teal-50 text-[#0F766E] dark:bg-teal-950/40"
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900 dark:text-neutral-300 dark:hover:bg-neutral-800 dark:hover:text-neutral-100"
}`}
>
<item.icon className={`h-4.5 w-4.5 shrink-0 ${active ? "text-[#0F766E]" : "text-gray-400 group-hover:text-gray-600"}`} />
<span className="flex-1">{item.label}</span>
{active ? <ChevronRightIcon className="h-3.5 w-3.5 text-[#0F766E]" /> : null}
</Link>
</li>
);
})}
</ul>
) : null}
</div>
))}
</nav>
<div className="mt-auto border-t border-gray-100 px-3 py-2 dark:border-neutral-800">
<div ref={menuRef} className="relative">
{menuOpen ? (
<div className="absolute bottom-full left-0 right-0 z-50 mb-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-neutral-800 dark:bg-neutral-900">
<div className="border-b border-gray-100 px-4 py-3 dark:border-neutral-800">
<p className="truncate text-sm font-semibold text-gray-900 dark:text-neutral-100">
{session?.user?.name ?? "Administrator"}
</p>
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
</div>
<div className="border-b border-gray-100 px-4 py-2.5 dark:border-neutral-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
{isDark ? <SunIcon className="h-4 w-4 text-gray-400" /> : <MoonIcon className="h-4 w-4 text-gray-400" />}
<span className="text-sm text-gray-700 dark:text-neutral-300">
{isDark ? "Helles Design" : "Dunkles Design"}
</span>
</div>
<button
type="button"
onClick={toggleDark}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 focus:outline-none ${
isDark ? "bg-[#0F766E]" : "bg-gray-200"
}`}
role="switch"
aria-checked={isDark}
aria-label="Dunkles Design umschalten"
>
<span
className={`pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition duration-200 ${
isDark ? "translate-x-4" : "translate-x-0"
}`}
/>
</button>
</div>
</div>
<Link
href="/einstellungen"
onClick={() => setMenuOpen(false)}
className="flex items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<Cog6ToothIcon className="h-4 w-4 text-gray-400" />
Einstellungen
</Link>
<button
type="button"
onClick={handleSignOut}
className="flex w-full items-center gap-2.5 px-4 py-2.5 text-sm text-gray-700 transition-colors hover:bg-gray-50 dark:text-neutral-300 dark:hover:bg-neutral-800"
>
<ArrowRightStartOnRectangleIcon className="h-4 w-4 text-gray-400" />
Abmelden
</button>
</div>
) : null}
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 hover:bg-gray-50 dark:hover:bg-neutral-800"
>
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-full bg-[#0F766E] text-xs font-bold text-white">
<UserIcon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1 text-left">
<p className="truncate text-xs font-medium text-gray-900 dark:text-neutral-100">
{session?.user?.name ?? "Administrator"}
</p>
<p className="truncate text-xs text-gray-400">{session?.user?.email ?? ""}</p>
</div>
<ChevronUpDownIcon className="h-3.5 w-3.5 shrink-0 text-gray-400" />
</button>
</div>
</div>
</aside>
);
}