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
This commit is contained in:
1 parent
cdf9066076
commit
e85ceb6f9d
32 files changed
+851
-851
No files matched your search
@@ -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 (
|
||||
<>
|
||||
<PageHeader titel={seite.label} beschreibung={seite.beschreibung} />
|
||||
|
||||
{/* 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]`. */}
|
||||
<div className="flex w-full flex-col px-6 pb-1">
|
||||
<Tabs className="max-w-[760px]">
|
||||
{ANALYSE_SEITEN.map((s) => (
|
||||
<Link key={s.id} href={s.href} className="contents no-underline">
|
||||
<Tabs.Item active={s.id === aktiv}>{s.label}</Tabs.Item>
|
||||
</Link>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<AnalyticsToolbar seite={aktiv} optionen={optionen} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<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">
|
||||
<aside
|
||||
className={`dv-no-print sticky top-0 flex h-screen flex-none flex-col border-r border-solid border-border-default bg-white transition-[width] duration-200 ${
|
||||
eingeklappt ? "w-[68px]" : "w-[248px]"
|
||||
}`}
|
||||
>
|
||||
<div className="flex h-14 flex-none items-center gap-2 border-b border-solid border-border-default px-4">
|
||||
<span className="flex h-8 w-8 flex-none items-center justify-center rounded-lg bg-[#161616] text-[13px] font-bold text-white">
|
||||
HG
|
||||
</span>
|
||||
{!eingeklappt ? (
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-[13px] font-semibold leading-tight text-text-neutral-primary">
|
||||
HiveGrid
|
||||
</span>
|
||||
<span className="truncate text-[11px] leading-tight text-text-neutral-tertiary">
|
||||
Analytics
|
||||
</span>
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<nav className="flex min-h-0 grow flex-col gap-5 overflow-y-auto px-3 py-4">
|
||||
{NAV_GRUPPEN.map((gruppe) => (
|
||||
<div key={gruppe.titel} className="flex flex-col gap-1">
|
||||
<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 ? (
|
||||
<span className="px-2 pb-1 text-[11px] font-semibold uppercase tracking-[0.08em] text-text-neutral-tertiary">
|
||||
{gruppe.titel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="mx-2 mb-1 h-px bg-border-default" />
|
||||
)}
|
||||
{gruppe.eintraege.map((eintrag) => {
|
||||
const aktiv = eintrag.href === pathname;
|
||||
return (
|
||||
<Link
|
||||
key={eintrag.href}
|
||||
href={eintrag.href}
|
||||
title={eingeklappt ? eintrag.label : eintrag.beschreibung}
|
||||
aria-current={aktiv ? "page" : undefined}
|
||||
className={`flex items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium transition-colors ${
|
||||
aktiv
|
||||
? "bg-neutral-100 text-text-neutral-primary"
|
||||
: "text-text-neutral-secondary hover:bg-neutral-50 hover:text-text-neutral-primary"
|
||||
}`}
|
||||
>
|
||||
<FeatherIcon
|
||||
name={eintrag.icon}
|
||||
className={`text-[17px] ${aktiv ? "text-text-neutral-primary" : "text-text-neutral-tertiary"}`}
|
||||
/>
|
||||
{!eingeklappt ? <span className="truncate">{eintrag.label}</span> : null}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
<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>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex-none border-t border-solid border-border-default px-3 py-3">
|
||||
{!eingeklappt ? (
|
||||
<div className="mb-3 rounded-md bg-neutral-50 px-3 py-2.5">
|
||||
<div className="text-[11px] font-semibold uppercase tracking-wide text-text-neutral-tertiary">
|
||||
Demo-Datenstand
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] leading-relaxed text-text-neutral-secondary">
|
||||
{formatZahl(DATENSATZ_INFO.auftraege)} Aufträge
|
||||
<br />
|
||||
{formatZahl(DATENSATZ_INFO.rechnungen)} Rechnungen
|
||||
<br />
|
||||
Stichtag {formatDatum(HEUTE.toISOString())}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
onClick={() => setEingeklappt((wert) => !wert)}
|
||||
className="flex w-full items-center gap-2.5 rounded-md px-2.5 py-2 text-[13px] font-medium text-text-neutral-tertiary hover:bg-neutral-50 hover:text-text-neutral-primary"
|
||||
aria-label={eingeklappt ? "Seitenleiste ausklappen" : "Seitenleiste einklappen"}
|
||||
>
|
||||
<FeatherPanelLeft className="text-[17px]" />
|
||||
{!eingeklappt ? <span>Einklappen</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</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">Statistiken</span>
|
||||
<span className="text-text-neutral-subtle">/</span>
|
||||
<span className="truncate font-semibold text-text-neutral-primary">
|
||||
{aktiveSeite?.label ?? "Übersicht"}
|
||||
</span>
|
||||
<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-[10px] font-medium text-text-neutral-tertiary">
|
||||
<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>
|
||||
|
||||
@@ -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.",
|
||||
},
|
||||
];
|
||||
@@ -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<AuftraegeDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function AuftraegeSeite({ vm }: { vm: VMState<AuftraegeDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Aufträge"
|
||||
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
|
||||
/>
|
||||
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -113,11 +95,6 @@ export function AuftraegeSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Aufträge"
|
||||
beschreibung="Von der Anfrage bis zur Abnahme — wo die Vorgänge stehen, was gebunden ist und was über Termin läuft."
|
||||
/>
|
||||
<AnalyticsToolbar seite="auftraege" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -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<FinanzenDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function FinanzenSeite({ vm }: { vm: VMState<FinanzenDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Finanzen"
|
||||
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
|
||||
/>
|
||||
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -116,11 +98,6 @@ export function FinanzenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Finanzen"
|
||||
beschreibung="Umsatz, Zahlungseingang und offene Forderungen — die Sicht der Buchhaltung auf den Betrieb."
|
||||
/>
|
||||
<AnalyticsToolbar seite="finanzen" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -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<KundenDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function KundenSeite({ vm }: { vm: VMState<KundenDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Kunden"
|
||||
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
|
||||
/>
|
||||
<AnalyticsToolbar seite="kunden" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -109,11 +91,6 @@ export function KundenSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Kunden"
|
||||
beschreibung="Woher Kunden kommen, wie viel sie bringen und wo Nachfassen lohnt."
|
||||
/>
|
||||
<AnalyticsToolbar seite="kunden" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -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<TeamDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function TeamSeite({ vm }: { vm: VMState<TeamDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Team"
|
||||
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
|
||||
/>
|
||||
<AnalyticsToolbar seite="team" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -123,11 +105,6 @@ export function TeamSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Team"
|
||||
beschreibung="Auslastung, produktive Stunden und Abschlüsse der Monteure — die Kennzahl hinter der Terminplanung."
|
||||
/>
|
||||
<AnalyticsToolbar seite="team" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -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<UebersichtDaten>;
|
||||
optionen: FilterOptionen;
|
||||
}) {
|
||||
export function UebersichtSeite({ vm }: { vm: VMState<UebersichtDaten> }) {
|
||||
if (vm.status !== "ready") {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Übersicht"
|
||||
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
|
||||
/>
|
||||
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
|
||||
<SeitenZustand vm={vm} />
|
||||
</>
|
||||
);
|
||||
return <SeitenZustand vm={vm} />;
|
||||
}
|
||||
const d = vm.data;
|
||||
|
||||
@@ -99,11 +81,6 @@ export function UebersichtSeite({
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
titel="Übersicht"
|
||||
beschreibung="Die Kennzahlen, nach denen der Betrieb gesteuert wird — Umsatz, Auslastung, offene Posten und Handlungsbedarf."
|
||||
/>
|
||||
<AnalyticsToolbar seite="uebersicht" optionen={optionen} />
|
||||
|
||||
<PageContainer>
|
||||
<Raster spalten={5}>
|
||||
|
||||
@@ -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 (
|
||||
<AuftraegeSeite
|
||||
vm={baueAuftraegeVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="auftraege" optionen={baueFilterOptionen(gesamt)}>
|
||||
<AuftraegeSeite vm={baueAuftraegeVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<FinanzenSeite
|
||||
vm={baueFinanzenVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="finanzen" optionen={baueFilterOptionen(gesamt)}>
|
||||
<FinanzenSeite vm={baueFinanzenVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<KundenSeite
|
||||
vm={baueKundenVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="kunden" optionen={baueFilterOptionen(gesamt)}>
|
||||
<KundenSeite vm={baueKundenVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<TeamSeite
|
||||
vm={baueTeamVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="team" optionen={baueFilterOptionen(gesamt)}>
|
||||
<TeamSeite vm={baueTeamVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<UebersichtSeite
|
||||
vm={baueUebersichtVM(daten, filter)}
|
||||
optionen={baueFilterOptionen(gesamt)}
|
||||
/>
|
||||
<AnalyticsShell aktiv="uebersicht" optionen={baueFilterOptionen(gesamt)}>
|
||||
<UebersichtSeite vm={baueUebersichtVM(daten, filter)} />
|
||||
</AnalyticsShell>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user