From e85ceb6f9d7cf20ae570a33dc422dc9527a81597 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Sat, 29 Aug 2026 20:25:04 +0200 Subject: [PATCH] =?UTF-8?q?feat(nav):=20Seitenleiste=20der=20Software=20sp?= =?UTF-8?q?iegeln,=20Analytics=20als=20ein=20Men=C3=BCpunkt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01QPeZNwSeU7qG3RRCwaxEZX --- README.md | 5 +- app/{ => analytics}/auftraege/page.tsx | 0 app/{ => analytics}/finanzen/page.tsx | 0 app/{ => analytics}/kunden/page.tsx | 0 app/analytics/page.tsx | 5 + app/{ => analytics}/team/page.tsx | 0 app/page.tsx | 10 +- docs/NAVIGATION.md | 51 ++- docs/SCOPE-ANALYTICS-DASHBOARD.md | 20 +- docs/ZIELSYSTEM.md | 7 +- .../components/layout/AnalyticsShell.tsx | 60 ++++ .../dashboard/components/layout/AppShell.tsx | 204 ++++++------ .../dashboard/components/layout/navigation.ts | 187 ++++++++--- features/dashboard/pages/AuftraegeSeite.tsx | 27 +- features/dashboard/pages/FinanzenSeite.tsx | 27 +- features/dashboard/pages/KundenSeite.tsx | 27 +- features/dashboard/pages/TeamSeite.tsx | 27 +- features/dashboard/pages/UebersichtSeite.tsx | 27 +- features/dashboard/routes/AuftraegeRoute.tsx | 8 +- features/dashboard/routes/FinanzenRoute.tsx | 8 +- features/dashboard/routes/KundenRoute.tsx | 8 +- features/dashboard/routes/TeamRoute.tsx | 8 +- features/dashboard/routes/UebersichtRoute.tsx | 8 +- package-lock.json | 17 +- package.json | 1 + scripts/verify-layout.mjs | 15 +- ui/components/IconButton/IconButton.tsx | 35 +- ui/components/Logo/Logo.tsx | 108 +++--- ui/components/SidebarInside/SidebarInside.tsx | 308 ++++++------------ .../SidebarWithSections.tsx | 284 ++++++++-------- ui/components/Tabs/Tabs.tsx | 138 +++----- ui/sidebar-hover-sweep.ts | 72 ++++ 32 files changed, 851 insertions(+), 851 deletions(-) rename app/{ => analytics}/auftraege/page.tsx (100%) rename app/{ => analytics}/finanzen/page.tsx (100%) rename app/{ => analytics}/kunden/page.tsx (100%) create mode 100644 app/analytics/page.tsx rename app/{ => analytics}/team/page.tsx (100%) create mode 100644 features/dashboard/components/layout/AnalyticsShell.tsx create mode 100644 ui/sidebar-hover-sweep.ts diff --git a/README.md b/README.md index de4c472..9497d0c 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Entwicklung** — nicht der Produktivbetrieb. ```bash npm install -npm run dev # http://localhost:4300 +npm run dev # http://localhost:4300 → leitet auf /analytics ``` --- @@ -22,7 +22,8 @@ npm run dev # http://localhost:4300 | Bereich | Inhalt | |---|---| -| **6 Seiten** | Übersicht, Finanzen, Aufträge, Kunden, Team, Komponenten | +| **5 Analyse-Seiten** | Übersicht, Finanzen, Aufträge, Kunden, Team — als Reiter im Menüpunkt **Analytics** | +| **Seitenleiste** | spiegelt die von HiveGrid Cloud (dieselbe Komponente, dieselben Gruppen) | | **26 KPIs** | vollständig definiert in [`docs/KPI-KATALOG.md`](docs/KPI-KATALOG.md) | | **9 Chart-Bausteine** | Fläche, Säulen, liegende Balken, Ring, Trichter, Auslastung, Sparkline, Legende, Leerzustand | | **Filtersystem** | Zeitraum + 5 Dimensionen, seitenabhängig relevant, in localStorage persistiert | diff --git a/app/auftraege/page.tsx b/app/analytics/auftraege/page.tsx similarity index 100% rename from app/auftraege/page.tsx rename to app/analytics/auftraege/page.tsx diff --git a/app/finanzen/page.tsx b/app/analytics/finanzen/page.tsx similarity index 100% rename from app/finanzen/page.tsx rename to app/analytics/finanzen/page.tsx diff --git a/app/kunden/page.tsx b/app/analytics/kunden/page.tsx similarity index 100% rename from app/kunden/page.tsx rename to app/analytics/kunden/page.tsx diff --git a/app/analytics/page.tsx b/app/analytics/page.tsx new file mode 100644 index 0000000..dfa0f70 --- /dev/null +++ b/app/analytics/page.tsx @@ -0,0 +1,5 @@ +import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute"; + +export default function Page() { + return ; +} diff --git a/app/team/page.tsx b/app/analytics/team/page.tsx similarity index 100% rename from app/team/page.tsx rename to app/analytics/team/page.tsx diff --git a/app/page.tsx b/app/page.tsx index dfa0f70..05e7de5 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,5 +1,11 @@ -import { UebersichtRoute } from "@/features/dashboard/routes/UebersichtRoute"; +import { redirect } from "next/navigation"; +import { ANALYTICS_START } from "@/features/dashboard/components/layout/navigation"; +/** + * Der Prototyp bildet das Analytics-Feature ab, nicht das Dashboard. + * Die Wurzel leitet deshalb auf den Analytics-Einstieg weiter — in der + * Software liegt unter "/" das Dashboard, das hier nicht enthalten ist. + */ export default function Page() { - return ; + redirect(ANALYTICS_START); } diff --git a/docs/NAVIGATION.md b/docs/NAVIGATION.md index 2b37bfc..1ed53e3 100644 --- a/docs/NAVIGATION.md +++ b/docs/NAVIGATION.md @@ -2,22 +2,47 @@ ## Struktur +Die Seitenleiste **spiegelt die von HiveGrid Cloud** — dieselbe Komponente +(`ui/components/SidebarWithSections`), dieselben Gruppen, dieselbe Reihenfolge: + ``` -Seitenleiste (fest, einklappbar auf Icon-Breite) -├── Analyse -│ ├── Übersicht / Kernkennzahlen, Handlungsbedarf -│ ├── Finanzen /finanzen Umsatz, Forderungen, Zahlungsverhalten -│ ├── Aufträge /auftraege Pipeline, Termine, Förderung -│ ├── Kunden /kunden Akquise, Quellen, Reaktivierung -│ └── Team /team Auslastung, Stunden, Abschlüsse -└── Entwicklung - └── Komponenten /komponenten Bausteine und Regeln (nur für die Umsetzung) +HIVEGRID +├── Dashboard gehört zur Software, hier nicht enthalten +├── Analytics ◄── der Prototyp lebt hier /analytics +├── Aufgaben gehört zur Software +├── Kunden +│ ├── Kontakte · Leads +├── Projekte +│ ├── Pipelines · Plantafel · Dateien +├── Kommunikation +│ ├── Postfach · Formulare · Telefonie +├── Organisation +│ └── Termine +├───────────────────── +├── Komponenten Bausteingalerie des Prototyps /komponenten +└── Einstellungen gehört zur Software ``` -**Warum Seitenleiste statt Tab-Leiste:** Die bestehende HiveGrid-Oberfläche -navigiert links. Der Prototyp lässt sich so ohne Umbau der -Informationsarchitektur einsetzen. Einklappen schafft Platz für breite Tabellen -und das fünfspaltige KPI-Raster. +**Analytics ist ein einzelner Menüpunkt**, kein aufklappbarer Bereich. Die fünf +Analyse-Seiten sind darin über **Reiter** erreichbar: + +``` +/analytics Übersicht +/analytics/finanzen Finanzen +/analytics/auftraege Aufträge +/analytics/kunden Kunden +/analytics/team Team +``` + +Zwei Navigationsebenen für dieselbe Sache — Unterpunkte in der Seitenleiste +*und* Reiter — würden nur konkurrieren. Die Reiter folgen dem Hausmuster aus +`SettingsHubShell.tsx` im Zielsystem: ein `Link` um ein `Tabs.Item`, damit jeder +Reiter eine echte Adresse hat und mittelklickbar bleibt. + +**Nur Analytics und Komponenten führen hier irgendwohin.** Die übrigen Einträge +zeigen, wo die Analyse in der fertigen Anwendung sitzt, und melden beim Klick, +dass sie zur Software gehören. Eine tote Schaltfläche ohne Rückmeldung wäre +schlechter als gar keine. --- diff --git a/docs/SCOPE-ANALYTICS-DASHBOARD.md b/docs/SCOPE-ANALYTICS-DASHBOARD.md index c0f8d28..950f36b 100644 --- a/docs/SCOPE-ANALYTICS-DASHBOARD.md +++ b/docs/SCOPE-ANALYTICS-DASHBOARD.md @@ -1,8 +1,8 @@ # Scope: Analytics und Dashboard sind zwei Features -**Stand: 29.08.2026 · Festgehalten, nicht entschieden.** -Auf dieser Grundlage ist noch nichts gebaut. Die offenen Punkte in Abschnitt 5 -sind mit den Kollegen zu klären, bevor jemand Code anfasst. +**Stand: 29.08.2026.** Von den offenen Punkten in Abschnitt 5 ist bisher genau +einer entschieden und gebaut: die Navigation (5.5). Alles andere ist mit den +Kollegen zu klären, bevor jemand Code anfasst. --- @@ -95,7 +95,7 @@ wie sie sind. ## 4. Wie die beiden zusammenhängen ``` -Analytics (Bereich „Statistiken") Dashboard (Startseite) +Analytics (ein Menüpunkt, Reiter innen) Dashboard (eigener Menüpunkt) ├── Übersicht ← Rolle unklar, ┐ ├── Begrüßung ├── Finanzen siehe 5.1 │ ├── KPI-Kacheln ┐ aus dem ├── Aufträge ├──────┤ Trend-Charts ┤ Analytics- @@ -134,10 +134,14 @@ akzeptieren oder anders lösen. Globaler Filter wie in Analytics oder pro Widget wie in P11? Die Software hat sich für „pro Widget" entschieden; das ist beim Übertragen zu beachten. -**5.5 Navigation.** -Eigene Gruppe „Statistiken" neben Kunden/Projekte — siehe -[ZIELSYSTEM.md](ZIELSYSTEM.md) 3.1b. Hängt an 5.1: Solange unklar ist, ob es -eine Übersichtsseite gibt, ist auch der Einstiegspunkt der Gruppe offen. +**5.5 Navigation — entschieden am 29.08.2026.** +Analytics ist **ein einzelner Menüpunkt** in der Seitenleiste, gleichrangig +neben Dashboard. Die fünf Analyse-Seiten sind darin über Reiter erreichbar, +nicht als Unterpunkte in der Navigation. Das Dashboard bleibt als eigener +Menüpunkt daneben. Umgesetzt; siehe [NAVIGATION.md](NAVIGATION.md). + +Offen bleibt daran nur 5.1: Wenn die Übersichtsseite zum Dashboard wird, +verliert der Analytics-Bereich seinen ersten Reiter. **5.6 Team-Seite.** Unabhängig davon offen, weil Zeiterfassung im v2-Backlog steht — siehe diff --git a/docs/ZIELSYSTEM.md b/docs/ZIELSYSTEM.md index e0467ed..a8a9cef 100644 --- a/docs/ZIELSYSTEM.md +++ b/docs/ZIELSYSTEM.md @@ -176,7 +176,12 @@ Dimensionsfilter. Analyse „warum ist das so". Beides in eine Seite zu legen, macht aus dem Einstieg eine Halde. -**Empfehlung: Option 1**, mit dem Namen **„Statistiken"** — so heißt der +**Entschieden am 29.08.2026: Option 1**, mit dem Namen **„Analytics"** — der +Prototyp setzt das um. Die Seitenleiste spiegelt jetzt die der Software +(dieselbe Komponente, dieselben Gruppen); „Analytics" ist ein einzelner +Menüpunkt neben „Dashboard", die fünf Seiten sind darin über Reiter erreichbar. + +*Ursprüngliche Empfehlung war der Name* **„Statistiken"** — so heißt der Breadcrumb im Prototyp bereits, und „Analyse" ist im Haus durch `leads/analytics` belegt. Das bestehende `/dashboard` bleibt Startseite; unsere Seite „Übersicht" braucht dann einen anderen Namen, sonst konkurrieren zwei diff --git a/features/dashboard/components/layout/AnalyticsShell.tsx b/features/dashboard/components/layout/AnalyticsShell.tsx new file mode 100644 index 0000000..50166ea --- /dev/null +++ b/features/dashboard/components/layout/AnalyticsShell.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import Link from "next/link"; +import { Tabs } from "@/ui/components/Tabs"; +import { PageHeader } from "./PageHeader"; +import { AnalyticsToolbar } from "../filters/AnalyticsToolbar"; +import { ANALYSE_SEITEN } from "./navigation"; +import type { SeitenId } from "../../data/filtering"; +import type { FilterOptionen } from "../../view-models/filter-optionen"; + +/** + * Rahmen des Analytics-Bereichs. + * + * „Analytics" ist in der Seitenleiste **ein** Menüpunkt. Die fünf Analyse-Seiten + * sind darin über Reiter erreichbar und tauchen nicht als Unterpunkte in der + * Navigation auf — sonst konkurrieren zwei Navigationsebenen um dieselbe Sache. + * + * Die Reiter folgen dem Hausmuster aus `SettingsHubShell.tsx` im Zielsystem: + * ein `Link` um ein `Tabs.Item`. Damit ist jeder Reiter eine echte Navigation — + * mittelklickbar, kopierbar, mit eigener Adresse — und kein Klick-Handler, der + * so tut als ob. + * + * Kopfzeile und Filterleiste liegen hier und nicht in den Seiten: Sie sind für + * alle fünf gleich, und der Filterzustand gilt bereichsweit. + */ +export function AnalyticsShell({ + aktiv, + optionen, + children, +}: { + aktiv: SeitenId; + optionen: FilterOptionen; + children: React.ReactNode; +}) { + const seite = ANALYSE_SEITEN.find((s) => s.id === aktiv) ?? ANALYSE_SEITEN[0]; + + return ( + <> + + + {/* Die Subframe-Tabs strecken ihre Einträge über die volle Breite. Auf + einem breiten Analyse-Bildschirm reißt das die Beschriftungen weit + auseinander, deshalb eine Maximalbreite — dasselbe Mittel nutzt das + Zielsystem in LeadSettingsPage mit `min-w-[720px]`. */} +
+ + {ANALYSE_SEITEN.map((s) => ( + + {s.label} + + ))} + +
+ + + {children} + + ); +} diff --git a/features/dashboard/components/layout/AppShell.tsx b/features/dashboard/components/layout/AppShell.tsx index 53b3e88..cc980a7 100644 --- a/features/dashboard/components/layout/AppShell.tsx +++ b/features/dashboard/components/layout/AppShell.tsx @@ -3,134 +3,140 @@ import React from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; -import { - FeatherBell, - FeatherHelpCircle, - FeatherPanelLeft, - FeatherSearch, -} from "@subframe/core"; -import { ALLE_SEITEN, NAV_GRUPPEN } from "./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: feste Seitenleiste links, Inhalt rechts. + * Rahmen der Anwendung — Seitenleiste nach dem Vorbild von HiveGrid Cloud. * - * Warum Seitenleiste statt Tab-Leiste: Die echte HiveGrid-Oberfläche - * navigiert links; so passt der Prototyp beim Einbau ohne Umbau der - * Informationsarchitektur. Die Leiste lässt sich auf Icon-Breite einklappen, - * damit breite Tabellen und 12-Spalten-Raster Platz bekommen. + * 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 aktiveSeite = - ALLE_SEITEN.find((s) => s.href !== "/" && pathname.startsWith(s.href)) ?? - ALLE_SEITEN.find((s) => s.href === "/"); + 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 = ( + } + selected={istAktiv(eintrag)} + variant={eingeklappt ? "icon-only" : "icon-and-text"} + > + {eintrag.label} + + ); + + if (eintrag.ziel.art === "route") { + return ( + + {item} + + ); + } + + return ( + + ); + }; + + const aktiveAnalyse = ANALYSE_SEITEN.find((s) => + s.href === "/analytics" ? pathname === "/analytics" : pathname.startsWith(s.href) + ); + const bereich = pathname.startsWith("/komponenten") ? "Komponenten" : "Analytics"; return (
- +
+ +
- Statistiken - / - - {aktiveSeite?.label ?? "Übersicht"} - + {bereich} + {aktiveAnalyse ? ( + <> + / + + {aktiveAnalyse.label} + + + ) : null}
Suchen - + ⌘K
diff --git a/features/dashboard/components/layout/navigation.ts b/features/dashboard/components/layout/navigation.ts index be2c40e..b983ef1 100644 --- a/features/dashboard/components/layout/navigation.ts +++ b/features/dashboard/components/layout/navigation.ts @@ -1,75 +1,156 @@ import type { SeitenId } from "../../data/filtering"; +/** + * Navigationsstruktur — Spiegel der Seitenleiste von HiveGrid Cloud. + * + * Die Gruppen, ihre Reihenfolge und die Einträge entsprechen + * `apps/web/app/application/sidebar/AppSidebar.tsx` im Zielsystem. Der Grund + * ist die Übergabe: Wer diesen Prototyp neben der Software öffnet, soll dieselbe + * Navigation sehen und nicht erst übersetzen müssen. + * + * **Nur „Analytics" ist in diesem Prototyp echt.** Alle anderen Einträge sind + * Kontext — sie zeigen, wo die Analyse in der fertigen Anwendung sitzt. Sie + * führen bewusst nirgendwohin und melden das beim Klick, statt still zu bleiben. + * + * Icons: Die Software nutzt eigene Icon-Komponenten (`AddressBookIcon`, + * `BoardIcon` …), die auf einem hausinternen Set aufsetzen. Hier stehen die + * nächstliegenden Feather-Icons aus `@subframe/core`, das beide Repos teilen — + * optisch praktisch deckungsgleich, ohne ein ganzes Icon-Set mitzuschleppen. + */ + +export type NavZiel = { art: "route"; href: string } | { art: "software" }; + export interface NavEintrag { - id: SeitenId | "komponenten"; - href: string; + id: string; label: string; - /** Icon-Name aus @subframe/core (Feather-Set). */ + /** Icon-Name aus @subframe/core (ohne das Präfix "Feather"). */ icon: string; - beschreibung: string; + ziel: NavZiel; } -/** - * Navigationsstruktur. - * - * Vier Analyse-Seiten kommen direkt aus REQUIREMENTS-analytics.md - * (Übersicht, Finanzen, Aufträge, Kunden). "Team" war dort als v2 markiert, - * ist hier aber enthalten, weil Auslastung im Handwerk die Kennzahl ist, - * nach der Betriebe steuern. "Komponenten" ist reine Entwickler-Doku. - */ -export const NAV_GRUPPEN: { titel: string; eintraege: NavEintrag[] }[] = [ +export interface NavGruppe { + id: string; + /** Leerer Titel = Startgruppe ohne Überschrift, wie in der Software. */ + titel: string; + eintraege: NavEintrag[]; +} + +/** Einstieg des Analytics-Bereichs — auch das Ziel der Weiterleitung von „/". */ +export const ANALYTICS_START = "/analytics"; + +export const NAV_GRUPPEN: NavGruppe[] = [ { - titel: "Analyse", + id: "start", + titel: "", eintraege: [ { - id: "uebersicht", - href: "/", - label: "Übersicht", + id: "dashboard", + label: "Dashboard", icon: "LayoutDashboard", - beschreibung: "Alle Kernkennzahlen auf einen Blick", + ziel: { art: "software" }, }, { - id: "finanzen", - href: "/finanzen", - label: "Finanzen", - icon: "Euro", - beschreibung: "Umsatz, Rechnungen, Zahlungseingang", - }, - { - id: "auftraege", - href: "/auftraege", - label: "Aufträge", - icon: "ClipboardList", - beschreibung: "Pipeline, Status, Termine", - }, - { - id: "kunden", - href: "/kunden", - label: "Kunden", - icon: "Users", - beschreibung: "Akquise, Quellen, Bestand", - }, - { - id: "team", - href: "/team", - label: "Team", - icon: "HardHat", - beschreibung: "Auslastung und Produktivität der Monteure", + // Der neue Menüpunkt. In der Software gibt es ihn noch nicht — die + // fünf Analyse-Seiten sind ausschließlich hier drin navigierbar, + // nicht als Unterpunkte in der Seitenleiste. + id: "analytics", + label: "Analytics", + icon: "BarChart", + ziel: { art: "route", href: ANALYTICS_START }, }, + { id: "aufgaben", label: "Aufgaben", icon: "Clipboard", ziel: { art: "software" } }, ], }, { - titel: "Entwicklung", + id: "kunden", + titel: "Kunden", eintraege: [ - { - id: "komponenten", - href: "/komponenten", - label: "Komponenten", - icon: "Component", - beschreibung: "Bausteine, Regeln und Übergabehinweise", - }, + { id: "kontakte", label: "Kontakte", icon: "Contact", ziel: { art: "software" } }, + { id: "leads", label: "Leads", icon: "TrendingUp", ziel: { art: "software" } }, + ], + }, + { + id: "projekte", + titel: "Projekte", + eintraege: [ + { id: "pipelines", label: "Pipelines", icon: "Trello", ziel: { art: "software" } }, + { id: "plantafel", label: "Plantafel", icon: "CalendarRange", ziel: { art: "software" } }, + { id: "dateien", label: "Dateien", icon: "FileText", ziel: { art: "software" } }, + ], + }, + { + id: "kommunikation", + titel: "Kommunikation", + eintraege: [ + { id: "postfach", label: "Postfach", icon: "Mail", ziel: { art: "software" } }, + { id: "formulare", label: "Formulare", icon: "Table", ziel: { art: "software" } }, + { id: "telefonie", label: "Telefonie", icon: "Phone", ziel: { art: "software" } }, + ], + }, + { + id: "organisation", + titel: "Organisation", + eintraege: [ + { id: "termine", label: "Termine", icon: "CalendarDays", ziel: { art: "software" } }, ], }, ]; -export const ALLE_SEITEN = NAV_GRUPPEN.flatMap((g) => g.eintraege); +/** Unterer Bereich, durch einen Trenner abgesetzt — wie in der Software. */ +export const NAV_SEKUNDAER: NavEintrag[] = [ + { + // Gehört nicht zur Software, sondern zum Prototyp: die Bausteingalerie + // für die Entwicklung. + id: "komponenten", + label: "Komponenten", + icon: "Component", + ziel: { art: "route", href: "/komponenten" }, + }, + { id: "einstellungen", label: "Einstellungen", icon: "Settings", ziel: { art: "software" } }, +]; + +/** + * Die fünf Analyse-Seiten. Sie erscheinen **nicht** in der Seitenleiste, + * sondern als Reiter innerhalb des Menüpunkts „Analytics" — dem Hausmuster + * für Unter-Navigation in HiveGrid (`SettingsHubShell.tsx`: ein `Link` um ein + * `Tabs.Item`, damit jeder Reiter eine echte Navigation ist). + */ +export interface AnalyseSeite { + id: SeitenId; + label: string; + href: string; + beschreibung: string; +} + +export const ANALYSE_SEITEN: AnalyseSeite[] = [ + { + id: "uebersicht", + label: "Übersicht", + href: "/analytics", + beschreibung: "Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf.", + }, + { + id: "finanzen", + label: "Finanzen", + href: "/analytics/finanzen", + beschreibung: "Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb.", + }, + { + id: "auftraege", + label: "Aufträge", + href: "/analytics/auftraege", + beschreibung: "Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft.", + }, + { + id: "kunden", + label: "Kunden", + href: "/analytics/kunden", + beschreibung: "Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt.", + }, + { + id: "team", + label: "Team", + href: "/analytics/team", + beschreibung: "Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung.", + }, +]; diff --git a/features/dashboard/pages/AuftraegeSeite.tsx b/features/dashboard/pages/AuftraegeSeite.tsx index 7e51726..aad1b2b 100644 --- a/features/dashboard/pages/AuftraegeSeite.tsx +++ b/features/dashboard/pages/AuftraegeSeite.tsx @@ -1,9 +1,7 @@ "use client"; import React from "react"; -import { PageHeader } from "../components/layout/PageHeader"; import { PageContainer, Raster } from "../components/layout/PageContainer"; -import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar"; import { KPICard } from "../components/cards/KPICard"; import { ChartCard } from "../components/cards/ChartCard"; import { StatZeile } from "../components/cards/StatZeile"; @@ -31,26 +29,10 @@ import type { Auftrag } from "../data/types"; import { SeitenZustand } from "../components/layout/SeitenZustand"; import type { VMState } from "../view-models"; import type { AuftraegeDaten } from "../view-models/auftraege.vm"; -import type { FilterOptionen } from "../view-models/filter-optionen"; -export function AuftraegeSeite({ - vm, - optionen, -}: { - vm: VMState; - optionen: FilterOptionen; -}) { +export function AuftraegeSeite({ vm }: { vm: VMState }) { if (vm.status !== "ready") { - return ( - <> - - - - - ); + return ; } const d = vm.data; @@ -113,11 +95,6 @@ export function AuftraegeSeite({ return ( <> - - diff --git a/features/dashboard/pages/FinanzenSeite.tsx b/features/dashboard/pages/FinanzenSeite.tsx index be3ed83..8dd0d1a 100644 --- a/features/dashboard/pages/FinanzenSeite.tsx +++ b/features/dashboard/pages/FinanzenSeite.tsx @@ -1,9 +1,7 @@ "use client"; import React from "react"; -import { PageHeader } from "../components/layout/PageHeader"; import { PageContainer, Raster } from "../components/layout/PageContainer"; -import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar"; import { KPICard } from "../components/cards/KPICard"; import { ChartCard } from "../components/cards/ChartCard"; import { StatZeile } from "../components/cards/StatZeile"; @@ -36,29 +34,13 @@ import type { Rechnung } from "../data/types"; import { SeitenZustand } from "../components/layout/SeitenZustand"; import type { VMState } from "../view-models"; import type { FinanzenDaten } from "../view-models/finanzen.vm"; -import type { FilterOptionen } from "../view-models/filter-optionen"; /** Ampelfarben für die Altersstruktur: je älter die Forderung, desto kritischer. */ const ALTER_FARBEN = [...RISIKO_RAMPE]; -export function FinanzenSeite({ - vm, - optionen, -}: { - vm: VMState; - optionen: FilterOptionen; -}) { +export function FinanzenSeite({ vm }: { vm: VMState }) { if (vm.status !== "ready") { - return ( - <> - - - - - ); + return ; } const d = vm.data; @@ -116,11 +98,6 @@ export function FinanzenSeite({ return ( <> - - diff --git a/features/dashboard/pages/KundenSeite.tsx b/features/dashboard/pages/KundenSeite.tsx index 6da15f3..e8e077b 100644 --- a/features/dashboard/pages/KundenSeite.tsx +++ b/features/dashboard/pages/KundenSeite.tsx @@ -1,9 +1,7 @@ "use client"; import React from "react"; -import { PageHeader } from "../components/layout/PageHeader"; import { PageContainer, Raster } from "../components/layout/PageContainer"; -import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar"; import { KPICard } from "../components/cards/KPICard"; import { ChartCard } from "../components/cards/ChartCard"; import { BalkenVertikal } from "../components/charts/BalkenVertikal"; @@ -24,26 +22,10 @@ import type { Kunde } from "../data/types"; import { SeitenZustand } from "../components/layout/SeitenZustand"; import type { VMState } from "../view-models"; import type { KundenDaten } from "../view-models/kunden.vm"; -import type { FilterOptionen } from "../view-models/filter-optionen"; -export function KundenSeite({ - vm, - optionen, -}: { - vm: VMState; - optionen: FilterOptionen; -}) { +export function KundenSeite({ vm }: { vm: VMState }) { if (vm.status !== "ready") { - return ( - <> - - - - - ); + return ; } const d = vm.data; @@ -109,11 +91,6 @@ export function KundenSeite({ return ( <> - - diff --git a/features/dashboard/pages/TeamSeite.tsx b/features/dashboard/pages/TeamSeite.tsx index 8daa23f..3f2bbf3 100644 --- a/features/dashboard/pages/TeamSeite.tsx +++ b/features/dashboard/pages/TeamSeite.tsx @@ -1,9 +1,7 @@ "use client"; import React from "react"; -import { PageHeader } from "../components/layout/PageHeader"; import { PageContainer, Raster } from "../components/layout/PageContainer"; -import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar"; import { KPICard } from "../components/cards/KPICard"; import { ChartCard } from "../components/cards/ChartCard"; import { AuslastungsBalken } from "../components/charts/AuslastungsBalken"; @@ -14,26 +12,10 @@ import { formatEuro, formatEuroKompakt, formatProzent, formatStunden, formatZahl import { SeitenZustand } from "../components/layout/SeitenZustand"; import type { VMState } from "../view-models"; import type { TeamDaten, TeamZeile } from "../view-models/team.vm"; -import type { FilterOptionen } from "../view-models/filter-optionen"; -export function TeamSeite({ - vm, - optionen, -}: { - vm: VMState; - optionen: FilterOptionen; -}) { +export function TeamSeite({ vm }: { vm: VMState }) { if (vm.status !== "ready") { - return ( - <> - - - - - ); + return ; } const d = vm.data; @@ -123,11 +105,6 @@ export function TeamSeite({ return ( <> - - diff --git a/features/dashboard/pages/UebersichtSeite.tsx b/features/dashboard/pages/UebersichtSeite.tsx index f4a3fdd..9be4bc3 100644 --- a/features/dashboard/pages/UebersichtSeite.tsx +++ b/features/dashboard/pages/UebersichtSeite.tsx @@ -1,9 +1,7 @@ "use client"; import React from "react"; -import { PageHeader } from "../components/layout/PageHeader"; import { PageContainer, Raster } from "../components/layout/PageContainer"; -import { AnalyticsToolbar } from "../components/filters/AnalyticsToolbar"; import { KPICard } from "../components/cards/KPICard"; import { ChartCard } from "../components/cards/ChartCard"; import { AlertList } from "../components/cards/AlertList"; @@ -34,26 +32,10 @@ import type { Auftrag } from "../data/types"; import { SeitenZustand } from "../components/layout/SeitenZustand"; import type { VMState } from "../view-models"; import type { UebersichtDaten } from "../view-models/uebersicht.vm"; -import type { FilterOptionen } from "../view-models/filter-optionen"; -export function UebersichtSeite({ - vm, - optionen, -}: { - vm: VMState; - optionen: FilterOptionen; -}) { +export function UebersichtSeite({ vm }: { vm: VMState }) { if (vm.status !== "ready") { - return ( - <> - - - - - ); + return ; } const d = vm.data; @@ -99,11 +81,6 @@ export function UebersichtSeite({ return ( <> - - diff --git a/features/dashboard/routes/AuftraegeRoute.tsx b/features/dashboard/routes/AuftraegeRoute.tsx index f271e17..1aae3ac 100644 --- a/features/dashboard/routes/AuftraegeRoute.tsx +++ b/features/dashboard/routes/AuftraegeRoute.tsx @@ -2,6 +2,7 @@ import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { AnalyticsShell } from "../components/layout/AnalyticsShell"; import { baueAuftraegeVM } from "../view-models/auftraege.vm"; import { AuftraegeSeite } from "../pages/AuftraegeSeite"; @@ -22,9 +23,8 @@ import { AuftraegeSeite } from "../pages/AuftraegeSeite"; export function AuftraegeRoute() { const { daten, filter, gesamt } = useAnalyseDaten("auftraege"); return ( - + + + ); } diff --git a/features/dashboard/routes/FinanzenRoute.tsx b/features/dashboard/routes/FinanzenRoute.tsx index 492d3e5..96aea8b 100644 --- a/features/dashboard/routes/FinanzenRoute.tsx +++ b/features/dashboard/routes/FinanzenRoute.tsx @@ -2,6 +2,7 @@ import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { AnalyticsShell } from "../components/layout/AnalyticsShell"; import { baueFinanzenVM } from "../view-models/finanzen.vm"; import { FinanzenSeite } from "../pages/FinanzenSeite"; @@ -22,9 +23,8 @@ import { FinanzenSeite } from "../pages/FinanzenSeite"; export function FinanzenRoute() { const { daten, filter, gesamt } = useAnalyseDaten("finanzen"); return ( - + + + ); } diff --git a/features/dashboard/routes/KundenRoute.tsx b/features/dashboard/routes/KundenRoute.tsx index 6dae285..601efcf 100644 --- a/features/dashboard/routes/KundenRoute.tsx +++ b/features/dashboard/routes/KundenRoute.tsx @@ -2,6 +2,7 @@ import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { AnalyticsShell } from "../components/layout/AnalyticsShell"; import { baueKundenVM } from "../view-models/kunden.vm"; import { KundenSeite } from "../pages/KundenSeite"; @@ -22,9 +23,8 @@ import { KundenSeite } from "../pages/KundenSeite"; export function KundenRoute() { const { daten, filter, gesamt } = useAnalyseDaten("kunden"); return ( - + + + ); } diff --git a/features/dashboard/routes/TeamRoute.tsx b/features/dashboard/routes/TeamRoute.tsx index a007462..92e4328 100644 --- a/features/dashboard/routes/TeamRoute.tsx +++ b/features/dashboard/routes/TeamRoute.tsx @@ -2,6 +2,7 @@ import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { AnalyticsShell } from "../components/layout/AnalyticsShell"; import { baueTeamVM } from "../view-models/team.vm"; import { TeamSeite } from "../pages/TeamSeite"; @@ -22,9 +23,8 @@ import { TeamSeite } from "../pages/TeamSeite"; export function TeamRoute() { const { daten, filter, gesamt } = useAnalyseDaten("team"); return ( - + + + ); } diff --git a/features/dashboard/routes/UebersichtRoute.tsx b/features/dashboard/routes/UebersichtRoute.tsx index 5501d0f..5abc737 100644 --- a/features/dashboard/routes/UebersichtRoute.tsx +++ b/features/dashboard/routes/UebersichtRoute.tsx @@ -2,6 +2,7 @@ import { useAnalyseDaten } from "../hooks/useAnalyseDaten"; import { baueFilterOptionen } from "../view-models/filter-optionen"; +import { AnalyticsShell } from "../components/layout/AnalyticsShell"; import { baueUebersichtVM } from "../view-models/uebersicht.vm"; import { UebersichtSeite } from "../pages/UebersichtSeite"; @@ -22,9 +23,8 @@ import { UebersichtSeite } from "../pages/UebersichtSeite"; export function UebersichtRoute() { const { daten, filter, gesamt } = useAnalyseDaten("uebersicht"); return ( - + + + ); } diff --git a/package-lock.json b/package-lock.json index a3a2179..6aef006 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "hivegrid-dashboard", "version": "0.1.0", "dependencies": { + "@radix-ui/react-slot": "^1.2.4", "@subframe/core": "^1.155.0", "clsx": "^2.1.1", "next": "16.2.6", @@ -22,6 +23,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.6", + "playwright-core": "^1.57.0", "tailwindcss": "^4", "typescript": "^5" } @@ -1440,7 +1442,6 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.5.tgz", "integrity": "sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -2060,7 +2061,6 @@ "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.3.tgz", "integrity": "sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==", "license": "MIT", - "peer": true, "dependencies": { "@radix-ui/react-compose-refs": "1.1.5" }, @@ -6927,6 +6927,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", diff --git a/package.json b/package.json index 575c447..3d86581 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "verify:layout": "node scripts/verify-layout.mjs" }, "dependencies": { + "@radix-ui/react-slot": "^1.2.4", "@subframe/core": "^1.155.0", "clsx": "^2.1.1", "next": "16.2.6", diff --git a/scripts/verify-layout.mjs b/scripts/verify-layout.mjs index f79d604..58ce6a3 100644 --- a/scripts/verify-layout.mjs +++ b/scripts/verify-layout.mjs @@ -18,7 +18,14 @@ import { chromium } from "playwright-core"; const BASIS = process.env.DASHBOARD_URL ?? "http://localhost:4300"; -const SEITEN = ["/", "/finanzen", "/auftraege", "/kunden", "/team", "/komponenten"]; +const SEITEN = [ + "/analytics", + "/analytics/finanzen", + "/analytics/auftraege", + "/analytics/kunden", + "/analytics/team", + "/komponenten", +]; const BREITEN = [1024, 1152, 1280, 1366, 1440, 1600, 1920]; const MIN_ABSTAND = 8; @@ -101,7 +108,11 @@ for (const breite of BREITEN) { const ctx = await browser.newContext({ viewport: { width: breite, height: 1200 } }); const seite = await ctx.newPage(); for (const pfad of SEITEN) { - await seite.goto(BASIS + pfad, { waitUntil: "networkidle" }); + const antwort = await seite.goto(BASIS + pfad, { waitUntil: "networkidle" }); + if (!antwort || antwort.status() >= 400) { + console.error(`✗ ${pfad} antwortet mit ${antwort?.status() ?? "keiner Antwort"} — Route existiert nicht.`); + process.exit(1); + } await seite.waitForTimeout(500); const { treffer, ueberlauf } = await seite.evaluate(messen, MIN_ABSTAND); for (const t of treffer) { diff --git a/ui/components/IconButton/IconButton.tsx b/ui/components/IconButton/IconButton.tsx index 50ff7a7..3c69c05 100644 --- a/ui/components/IconButton/IconButton.tsx +++ b/ui/components/IconButton/IconButton.tsx @@ -7,10 +7,12 @@ import React from "react"; import { FeatherPlus } from "@subframe/core"; import * as SubframeCore from "@subframe/core"; +import { Slot, Slottable } from "@radix-ui/react-slot"; import * as SubframeUtils from "../../utils"; export interface IconButtonRootProps extends React.ButtonHTMLAttributes { + asChild?: boolean; disabled?: boolean; loading?: boolean; variant?: @@ -39,17 +41,30 @@ const IconButtonRoot = React.forwardRef( size = "medium", square = false, icon = , + children, className, type = "button", + asChild = false, ...otherProps }: IconButtonRootProps, ref ) { + // @subframe/sync-disable -- local accessibility fix: external links keep + // native link semantics without wrapping a button. + const Root = asChild ? Slot : "button"; return ( -
- + {asChild && children ? {children} : null} + ); } ); diff --git a/ui/components/Logo/Logo.tsx b/ui/components/Logo/Logo.tsx index d165849..88c25d7 100644 --- a/ui/components/Logo/Logo.tsx +++ b/ui/components/Logo/Logo.tsx @@ -14,65 +14,53 @@ export interface LogoRootProps extends React.HTMLAttributes { className?: string; } -const LogoRoot = React.forwardRef( - function LogoRoot( - { - size = "28", - showIcon = false, - hideText = false, - className, - ...otherProps - }: LogoRootProps, - ref - ) { - return ( -
- - -
- ); - } -); +const LogoRoot = React.forwardRef(function LogoRoot( + { size = "28", showIcon = false, hideText = false, className, ...otherProps }: LogoRootProps, + ref +) { + return ( +
+ {hideText + {showIcon +
+ ); +}); export const Logo = LogoRoot; diff --git a/ui/components/SidebarInside/SidebarInside.tsx b/ui/components/SidebarInside/SidebarInside.tsx index 485d1b0..37a51a4 100644 --- a/ui/components/SidebarInside/SidebarInside.tsx +++ b/ui/components/SidebarInside/SidebarInside.tsx @@ -1,17 +1,10 @@ "use client"; -/* - * Documentation: - * Icon Button — https://app.subframe.com/f023064a5ca0/library?component=Icon+Button_af9405b1-8c54-4e01-9786-5aad308224f6 - * Sidebar Inside — https://app.subframe.com/f023064a5ca0/library?component=Sidebar+Inside_f6538807-e4e9-4314-9b2f-aaed19760450 - */ import React from "react"; import { FeatherCircleDashed } from "@subframe/core"; -import { FeatherSidebarClose } from "@subframe/core"; -import { FeatherSidebarOpen } from "@subframe/core"; import * as SubframeCore from "@subframe/core"; import * as SubframeUtils from "../../utils"; -import { IconButton } from "../IconButton"; +import { useSidebarHoverSweep } from "../../sidebar-hover-sweep"; export interface NavItemProps extends React.HTMLAttributes { icon?: React.ReactNode; @@ -22,52 +15,44 @@ export interface NavItemProps extends React.HTMLAttributes { } const NavItem = React.forwardRef(function NavItem( - { - icon = , - children, - selected = false, - variant = "icon-and-text", - className, - ...otherProps - }: NavItemProps, + { icon = , children, selected = false, variant = "icon-and-text", className, ...otherProps }: NavItemProps, ref ) { return (
-
+ {/* @subframe/sync-disable -- local fix: no items-center flip on collapse; the icon column stays put. */} +
{icon ? ( - + {icon} ) : null} {children ? ( { className?: string; } -const NavSection = React.forwardRef( - function NavSection( - { - children, - label, - variant = "default", - showLabel = false, - showDivider = false, - className, - ...otherProps - }: NavSectionProps, - ref - ) { - return ( +const NavSection = React.forwardRef(function NavSection( + { children, label, variant = "default", showLabel = false, showDivider = false, className, ...otherProps }: NavSectionProps, + ref +) { + return ( +
-
- -
-
- -
- {children ? ( -
- {children} -
- ) : null} +
- ); - } -); +
+ +
+ {children ? ( +
+ {children} +
+ ) : null} +
+ ); +}); export interface NavLabelProps extends React.HTMLAttributes { label?: React.ReactNode; className?: string; } -const NavLabel = React.forwardRef( - function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) { - return ( -
- {label ? ( - - {label} - - ) : null} -
- ); - } -); +const NavLabel = React.forwardRef(function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) { + return ( +
+ {label ? {label} : null} +
+ ); +}); export interface NavDividerProps extends React.HTMLAttributes { className?: string; } -const NavDivider = React.forwardRef( - function NavDivider({ className, ...otherProps }: NavDividerProps, ref) { - return ( -
-
-
- ); - } -); +const NavDivider = React.forwardRef(function NavDivider({ className, ...otherProps }: NavDividerProps, ref) { + return ( +
+
+
+ ); +}); -export interface SubcomponentProps - extends React.HTMLAttributes { - sidebarButton?: "close" | "open"; - className?: string; -} - -const Subcomponent = React.forwardRef( - function Subcomponent( - { sidebarButton = "close", className, ...otherProps }: SubcomponentProps, - ref - ) { - return ( -
- - ) : ( - - ) - } - /> -
- ); - } -); - -export interface SidebarInsideRootProps - extends React.HTMLAttributes { +export interface SidebarInsideRootProps extends React.HTMLAttributes { variant?: "open" | "closed"; footer?: React.ReactNode; children?: React.ReactNode; @@ -236,64 +139,57 @@ export interface SidebarInsideRootProps className?: string; } -const SidebarInsideRoot = React.forwardRef( - function SidebarInsideRoot( - { - variant = "open", - footer, - children, - visible = false, - className, - ...otherProps - }: SidebarInsideRootProps, - ref - ) { - return ( - - ); - } -); +const SidebarInsideRoot = React.forwardRef(function SidebarInsideRoot( + { variant = "open", footer, children, visible = false, className, onPointerMove, onPointerLeave, ...otherProps }: SidebarInsideRootProps, + ref +) { + const { handlePointerMove, handlePointerLeave } = useSidebarHoverSweep({ onPointerMove, onPointerLeave }); + + return ( + + ); +}); export const SidebarInside = Object.assign(SidebarInsideRoot, { NavItem, NavSection, NavLabel, NavDivider, - Subcomponent, }); diff --git a/ui/components/SidebarWithSections/SidebarWithSections.tsx b/ui/components/SidebarWithSections/SidebarWithSections.tsx index 901e705..ace7400 100644 --- a/ui/components/SidebarWithSections/SidebarWithSections.tsx +++ b/ui/components/SidebarWithSections/SidebarWithSections.tsx @@ -12,6 +12,7 @@ import { FeatherSidebarClose } from "@subframe/core"; import { FeatherSidebarOpen } from "@subframe/core"; import * as SubframeCore from "@subframe/core"; import * as SubframeUtils from "../../utils"; +import { useSidebarHoverSweep } from "../../sidebar-hover-sweep"; import { IconButton } from "../IconButton"; import { Logo } from "../Logo"; @@ -24,57 +25,54 @@ export interface NavItemProps extends React.HTMLAttributes { } const NavItem = React.forwardRef(function NavItem( - { - icon = , - children, - selected = false, - variant = "icon-and-text", - className, - ...otherProps - }: NavItemProps, + { icon = , children, selected = false, variant = "icon-and-text", className, ...otherProps }: NavItemProps, ref ) { return (
{icon ? ( - + {icon} ) : null} {children ? ( { className?: string; } -const NavSection = React.forwardRef( - function NavSection( - { - children, - label, - variant = "default", - showLabel = false, - showDivider = false, - className, - ...otherProps - }: NavSectionProps, - ref - ) { - return ( +const NavSection = React.forwardRef(function NavSection( + { children, label, variant = "default", showLabel = false, showDivider = false, className, ...otherProps }: NavSectionProps, + ref +) { + return ( +
-
- -
-
- -
- {children ? ( -
- {children} -
- ) : null} +
- ); - } -); +
+ +
+ {children ? ( +
+ {children} +
+ ) : null} +
+ ); +}); export interface NavLabelProps extends React.HTMLAttributes { label?: React.ReactNode; className?: string; } -const NavLabel = React.forwardRef( - function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) { - return ( -
- {label ? ( - - {label} - - ) : null} -
- ); - } -); +const NavLabel = React.forwardRef(function NavLabel({ label, className, ...otherProps }: NavLabelProps, ref) { + return ( +
+ {label ? {label} : null} +
+ ); +}); export interface NavDividerProps extends React.HTMLAttributes { className?: string; } -const NavDivider = React.forwardRef( - function NavDivider({ className, ...otherProps }: NavDividerProps, ref) { - return ( -
-
-
- ); - } -); +const NavDivider = React.forwardRef(function NavDivider({ className, ...otherProps }: NavDividerProps, ref) { + return ( +
+
+
+ ); +}); -export interface SubcomponentProps - extends React.HTMLAttributes { +export interface SubcomponentProps extends React.HTMLAttributes { sidebarButton?: "close" | "open"; + buttonLabel?: string; + onToggle?: () => void; className?: string; } -const Subcomponent = React.forwardRef( - function Subcomponent( - { sidebarButton = "close", className, ...otherProps }: SubcomponentProps, - ref - ) { - return ( -
- - ) : ( - - ) - } - /> -
- ); - } -); +const Subcomponent = React.forwardRef(function Subcomponent( + { sidebarButton = "close", buttonLabel, onToggle, className, ...otherProps }: SubcomponentProps, + ref +) { + return ( +
+ : } + aria-label={buttonLabel} + onClick={onToggle} + /> +
+ ); +}); -export interface SidebarWithSectionsRootProps - extends React.HTMLAttributes { +export interface SidebarWithSectionsRootProps extends React.HTMLAttributes { variant?: "open" | "closed"; footer?: React.ReactNode; children?: React.ReactNode; className?: string; + // @subframe/sync-disable -- local fix: Collapse-Toggle verdrahtbar machen (App steuert open/closed). + onToggleCollapse?: () => void; + // @subframe/sync-disable -- local fix: native collapse button needs an accessible, translated name. + toggleLabel?: string; + // @subframe/sync-disable -- local fix: make the logo a link (click -> dashboard, same target as the Dashboard nav item). + logoHref?: string; + // @subframe/sync-disable -- logo navigation needs the caller's translated accessible name. + logoLabel?: string; + // @subframe/sync-disable -- branded surfaces can replace the default mark without forking the canonical sidebar. + logoContent?: React.ReactNode; + // @subframe/sync-disable -- callback-owned surfaces keep logo navigation client-side. + onLogoClick?: () => void; } -const SidebarWithSectionsRoot = React.forwardRef< - HTMLElement, - SidebarWithSectionsRootProps ->(function SidebarWithSectionsRoot( +const SidebarWithSectionsRoot = React.forwardRef(function SidebarWithSectionsRoot( { variant = "open", footer, children, className, + onToggleCollapse, + toggleLabel, + logoHref, + logoLabel, + logoContent, + onLogoClick, + onPointerMove, + onPointerLeave, ...otherProps }: SidebarWithSectionsRootProps, ref ) { + const { handlePointerMove, handlePointerLeave } = useSidebarHoverSweep({ onPointerMove, onPointerLeave }); + return (