Files
Dashboard/features/dashboard/components/layout/AppShell.tsx
T
dejanandClaude Opus 5 b3d71c1f19 docs: Rangfolge beim Einbau festhalten — HiveGrid gewinnt
Klarstellung, damit beim Port keine globalen Regeln aus diesem Repo in die
Software wandern.

Verbindlich ist aus diesem Repo nur das Analytics-Eigene und das Fachliche:
Kennzahl-Definitionen, Diagrammregeln, Verhalten — und die Navigationsstruktur
als Verabredung (zwei Einträge in der Seitenleiste, Analytics mit innerer
Leiste).

Nicht verbindlich ist alles Globale: Schrift, Schriftgrößen, Abstände, Farben,
Radien, Control-Höhen, App-Shell, Kopfzeile, i18n, Formatierung. Das kommt
ausschließlich aus HiveGrid. Dass dieser Prototyp dieselbe Schrift, dieselbe
Seitenleisten-Komponente und dieselben Token benutzt, dient allein der
Lesbarkeit neben der Software — es ist keine Vorlage, an der sich die Software
auszurichten hätte. Weicht ein Wert hier von HiveGrid ab, ist der Wert hier
falsch.

Ebenso festgehalten, was Gerüst ist und wegfällt: AppShell, unsere Kopie der
Seitenleiste, PageHeader, die Kopfzeile, die Routen unter app/ und die
Dashboard-Platzhalterseite. Der Nachbau der inneren Leiste wird nicht portiert;
in HiveGrid entsteht sie über das dortige SidebarIconsLayout.

Vermerkt in ZIELSYSTEM.md (neuer Abschnitt 0 mit Zuständigkeitstabelle), im
Kopf von HANDOVER.md und README.md, bei der Struktur in NAVIGATION.md, als
Vorbemerkung zu den bewussten Abweichungen in LEITPLANKEN.md — und als
Kommentarblock in AppShell.tsx und AnalyticsInnerNav.tsx, weil beim Port oft
zuerst der Code gelesen wird.

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

164 lines
7.6 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 { 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.
*
* Jeder Eintrag führt auf eine echte Seite. „Dashboard" landet auf einer
* Platzhalterseite, weil es ein eigenes Feature ist und hier nicht gestaltet
* wird — aber ein Menüpunkt, der nur einen Hinweis auslöst, lässt offen, ob
* etwas fehlt oder etwas kaputt ist.
*
* ────────────────────────────────────────────────────────────────────────────
* BEIM EINBAU: Diese Datei wird NICHT portiert.
*
* Sie existiert, damit der Prototyp alleine lauffähig ist. In HiveGrid Cloud
* übernehmen `SubframeAppShell` und `AppSidebar`. Verbindlich ist aus dieser
* Datei allein die **Struktur**: zwei Einträge in der Seitenleiste, Dashboard
* und Analytics — mehr nicht, weil alles Übrige dort schon steht.
*
* Globale Regeln (Schrift, Größen, Abstände, Farben, Control-Höhen) kommen
* ausschließlich aus HiveGrid und werden von hier aus nicht überschrieben.
* Weicht ein Wert hier von dort ab, ist der Wert hier falsch.
* Rangfolge: docs/ZIELSYSTEM.md Abschnitt 0.
* ────────────────────────────────────────────────────────────────────────────
*/
export function AppShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const [eingeklappt, setEingeklappt] = React.useState(false);
const istAktiv = (eintrag: NavEintrag) =>
eintrag.ziel.href === "/analytics"
? pathname.startsWith("/analytics")
: pathname === eintrag.ziel.href;
const renderEintrag = (eintrag: NavEintrag) => (
<Link
key={eintrag.id}
href={eintrag.ziel.href}
title={eingeklappt ? eintrag.label : undefined}
className="w-full no-underline"
>
<SidebarWithSections.NavItem
icon={<FeatherIcon name={eintrag.icon} />}
selected={istAktiv(eintrag)}
variant={eingeklappt ? "icon-only" : "icon-and-text"}
>
{eintrag.label}
</SidebarWithSections.NavItem>
</Link>
);
const aktiveAnalyse = ANALYSE_SEITEN.find((s) =>
s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href)
);
const bereich = pathname.startsWith("/komponenten")
? "Komponenten"
: pathname.startsWith("/dashboard")
? "Dashboard"
: "Analytics";
return (
<div className="flex min-h-screen w-full bg-neutral-50">
{/* z-50 ist nötig, nicht dekorativ: `position: sticky` erzeugt einen
eigenen Stapelkontext. Ohne z-Wert am Rahmen bleibt das z-index des
Einklapp-Knopfes darin gefangen, und die Inhaltsspalte — ein späteres
Geschwisterelement — malt über den Teil, der aus der Leiste
herausragt. Die Software setzt an derselben Stelle ebenfalls z-50. */}
<div className="dv-no-print sticky top-0 z-50 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>
);
}