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
166 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|