Files
Dashboard/features/dashboard/components/layout/AppShell.tsx
T
dejanandClaude Opus 5 e85ceb6f9d feat(nav): Seitenleiste der Software spiegeln, Analytics als ein Menüpunkt
Der Prototyp hatte eine eigene Seitenleiste mit den fünf Analyse-Seiten als
Menüpunkten. Die Software hat eine andere Navigation, in der Analytics noch gar
nicht vorkommt. Beides zusammen ergab kein Bild davon, wo die Analyse später
sitzt.

Jetzt:
- Die Seitenleiste spiegelt die von HiveGrid Cloud — dieselbe Komponente
  (SidebarWithSections aus der Cloud-Fassung portiert, inkl. der dortigen
  lokalen Fixes: Collapse-Breite, verdrahteter Toggle, Logo-Slot, Hover-Sweep),
  dieselben Gruppen, dieselbe Reihenfolge, dasselbe Logo.
- 'Analytics' ist ein einzelner Menüpunkt, gleichrangig neben 'Dashboard'.
- Die fünf Analyse-Seiten sind darin über Reiter erreichbar, nicht als
  Unterpunkte in der Navigation. Zwei Navigationsebenen für dieselbe Sache
  würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus
  SettingsHubShell (Link um Tabs.Item), damit jeder Reiter eine echte Adresse
  hat und mittelklickbar bleibt.
- Routen: /analytics, /analytics/finanzen, /auftraege, /kunden, /team.
  '/' leitet auf /analytics; das Dashboard liegt in der Software unter '/'.
- Alle übrigen Einträge sind Kontext: Sie zeigen den Ort in der fertigen
  Anwendung und melden beim Klick, dass sie zur Software gehören.

Kopfzeile und Filterleiste sind aus den fünf Seiten in die Analytics-Hülle
gewandert — sie sind für alle gleich, und der Filterzustand gilt bereichsweit.

Nebenbei: @radix-ui/react-slot war eine Phantom-Dependency (nur transitiv über
@subframe/core vorhanden, vom portierten IconButton direkt importiert) und ist
jetzt deklariert. verify-layout prüfte noch die alten Routen und lief damit auf
404-Seiten, die naturgemäß keine Kollisionen haben — Routen korrigiert und eine
Absicherung ergänzt, die bei Statuscode >= 400 abbricht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX
2026-08-29 20:25:04 +02:00

166 lines
6.7 KiB
TypeScript

"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { FeatherBell, FeatherHelpCircle, FeatherSearch } from "@subframe/core";
import { SidebarWithSections } from "@/ui/components/SidebarWithSections";
import { NAV_GRUPPEN, NAV_SEKUNDAER, ANALYSE_SEITEN, type NavEintrag } from "./navigation";
import { FeatherIcon } from "./FeatherIcon";
import { useToast } from "../../context/ToastContext";
import { DATENSATZ_INFO } from "../../data/seed";
import { formatDatum, formatZahl } from "../../utils/formatters";
import { HEUTE } from "../../utils/dateUtils";
/**
* Rahmen der Anwendung — Seitenleiste nach dem Vorbild von HiveGrid Cloud.
*
* Bewusst dieselbe Komponente wie dort (`~/ui/components/SidebarWithSections`)
* mit denselben Gruppen und derselben Reihenfolge. Wer den Prototyp neben der
* Software öffnet, soll dieselbe Navigation sehen; die Übergabe wird dadurch
* eine Frage des Einsetzens und nicht des Übersetzens.
*
* **Nur „Analytics" führt hier irgendwohin.** Die übrigen Einträge zeigen den
* Ort in der fertigen Anwendung und melden beim Klick, dass sie zur Software
* gehören — eine tote Schaltfläche ohne Rückmeldung wäre schlechter als gar
* keine.
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { zeigeToast } = useToast();
const [eingeklappt, setEingeklappt] = React.useState(false);
const istAktiv = (eintrag: NavEintrag) =>
eintrag.ziel.art === "route" &&
(eintrag.ziel.href === "/analytics"
? pathname.startsWith("/analytics")
: pathname.startsWith(eintrag.ziel.href));
const renderEintrag = (eintrag: NavEintrag) => {
const item = (
<SidebarWithSections.NavItem
icon={<FeatherIcon name={eintrag.icon} />}
selected={istAktiv(eintrag)}
variant={eingeklappt ? "icon-only" : "icon-and-text"}
>
{eintrag.label}
</SidebarWithSections.NavItem>
);
if (eintrag.ziel.art === "route") {
return (
<Link
key={eintrag.id}
href={eintrag.ziel.href}
title={eingeklappt ? eintrag.label : undefined}
className="w-full no-underline"
>
{item}
</Link>
);
}
return (
<button
key={eintrag.id}
type="button"
title={eingeklappt ? eintrag.label : undefined}
onClick={() =>
zeigeToast(eintrag.label, "Gehört zur Software — in diesem Prototyp nicht enthalten")
}
className="w-full cursor-default border-0 bg-transparent p-0 text-left"
>
{item}
</button>
);
};
const aktiveAnalyse = ANALYSE_SEITEN.find((s) =>
s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href)
);
const bereich = pathname.startsWith("/komponenten") ? "Komponenten" : "Analytics";
return (
<div className="flex min-h-screen w-full bg-neutral-50">
<div className="dv-no-print sticky top-0 h-screen flex-none">
<SidebarWithSections
variant={eingeklappt ? "closed" : "open"}
onToggleCollapse={() => setEingeklappt((wert) => !wert)}
toggleLabel={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
>
<div className="flex h-full min-h-0 w-full flex-col">
<div className="flex min-h-0 w-full grow flex-col gap-1 overflow-x-hidden overflow-y-auto">
{NAV_GRUPPEN.map((gruppe) => (
<SidebarWithSections.NavSection
key={gruppe.id}
label={gruppe.titel}
showLabel={!eingeklappt && !!gruppe.titel}
>
{gruppe.eintraege.map(renderEintrag)}
</SidebarWithSections.NavSection>
))}
</div>
<div className="sticky bottom-0 z-10 mt-auto flex w-full flex-none flex-col gap-1 bg-surface-neutral-secondary pt-2">
<SidebarWithSections.NavDivider />
{NAV_SEKUNDAER.map(renderEintrag)}
{/* Einzeilig statt als Kasten: Die Seitenleiste soll die der
Software spiegeln, und dort steht kein Demo-Hinweis. Die
Information bleibt, nimmt aber keinen Navigationsplatz weg. */}
{!eingeklappt ? (
<p className="px-3 pb-2 pt-1 text-[11px] leading-tight text-text-neutral-tertiary">
Demo-Daten · {formatZahl(DATENSATZ_INFO.auftraege)} Aufträge ·{" "}
{formatDatum(HEUTE.toISOString())}
</p>
) : null}
</div>
</div>
</SidebarWithSections>
</div>
<div className="flex min-w-0 grow flex-col">
<header className="dv-no-print sticky top-0 z-30 flex h-14 flex-none items-center gap-4 border-b border-solid border-border-default bg-white/95 px-6 backdrop-blur">
<div className="flex min-w-0 items-center gap-2 text-[13px]">
<span className="text-text-neutral-tertiary">{bereich}</span>
{aktiveAnalyse ? (
<>
<span className="text-text-neutral-subtle">/</span>
<span className="truncate font-semibold text-text-neutral-primary">
{aktiveAnalyse.label}
</span>
</>
) : null}
</div>
<div className="ml-auto flex items-center gap-2">
<div className="hidden h-9 items-center gap-2 rounded-md border border-solid border-border-default px-3 text-[13px] text-text-neutral-subtle lg:flex">
<FeatherSearch className="text-[15px]" />
<span>Suchen</span>
<kbd className="ml-4 rounded border border-solid border-border-default bg-neutral-50 px-1.5 py-0.5 text-[11px] font-medium text-text-neutral-tertiary">
⌘K
</kbd>
</div>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Benachrichtigungen"
>
<FeatherBell className="text-[17px]" />
</button>
<button
className="flex h-9 w-9 items-center justify-center rounded-md text-text-neutral-tertiary hover:bg-neutral-100"
aria-label="Hilfe"
>
<FeatherHelpCircle className="text-[17px]" />
</button>
<span className="ml-1 flex h-8 w-8 items-center justify-center rounded-full bg-neutral-800 text-[12px] font-semibold text-white">
MK
</span>
</div>
</header>
<main className="min-w-0 grow">{children}</main>
</div>
</div>
);
}