From 50b55a2c81e34186e28a7eea12909ac95ee81528 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 12:26:57 +0100 Subject: [PATCH 1/3] portal: visuelle Basis (Kacheln, Icons, Skeleton, Fehlerkarte), Tokens und Skip-Link-CSS in shl Co-Authored-By: Claude Sonnet 5.5 --- web/portal/app/AdminPanel.tsx | 49 ++++++--- web/portal/app/ModuleGrid.render.test.tsx | 108 +++++++++++++++++++ web/portal/app/ModuleGrid.tsx | 125 ++++++++++++---------- web/portal/app/PageTitle.tsx | 8 ++ web/portal/app/Skeleton.tsx | 13 +++ web/portal/app/Tile.tsx | 53 +++++++++ web/portal/app/layout.tsx | 2 + web/portal/app/messages.ts | 91 ++++++++++++++++ web/portal/app/moduleIcons.tsx | 31 ++++++ web/portal/app/page.tsx | 5 +- web/portal/app/portal.css | 66 ++++++++++++ web/shl/__tests__/tokens.test.ts | 18 ++++ web/shl/index.ts | 2 +- web/shl/tokens/tokens.ts | 40 +++++++ 14 files changed, 534 insertions(+), 77 deletions(-) create mode 100644 web/portal/app/ModuleGrid.render.test.tsx create mode 100644 web/portal/app/PageTitle.tsx create mode 100644 web/portal/app/Skeleton.tsx create mode 100644 web/portal/app/Tile.tsx create mode 100644 web/portal/app/messages.ts create mode 100644 web/portal/app/moduleIcons.tsx create mode 100644 web/portal/app/portal.css diff --git a/web/portal/app/AdminPanel.tsx b/web/portal/app/AdminPanel.tsx index 7fa4c1ae..09030194 100644 --- a/web/portal/app/AdminPanel.tsx +++ b/web/portal/app/AdminPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useI18n } from "@nexarch/shl"; import { getSession } from "../lib/http"; +import { StatTile } from "./Tile"; import type { KennzahlenSummary, ModuleStatusRow } from "../lib/adminData"; async function getJson(path: string): Promise { @@ -14,8 +15,8 @@ async function getJson(path: string): Promise { } } -export function statusLabel(status: string): string { - return status === "up" ? "in Betrieb" : status === "down" ? "gestört" : "unbekannt"; +export function statusKey(status: string): "up" | "down" | "unknown" { + return status === "up" ? "up" : status === "down" ? "down" : "unknown"; } export function AdminPanel() { @@ -40,23 +41,39 @@ export function AdminPanel() { }, []); if (!status && !kpi) return null; + const fallback = { up: "in Betrieb", down: "gestört", unknown: "unbekannt" } as const; + const updated = status?.map((m) => m.lastChecked).filter((x): x is string => !!x).sort().pop(); return ( -
-

{t("portal.admin", "Betrieb")}

- {status && ( -
    - {status.map((m) => ( -
  • - {m.name}: {statusLabel(m.status)} -
  • - ))} -
+
+

{t("portal.admin", "Betrieb")}

+ {status ? ( + <> +
    + {status.map((m) => { + const k = statusKey(m.status); + return ( +
  • +
  • + ); + })} +
+ {updated && ( +

+ {t("portal.admin.updated", "Stand")}: {new Date(updated).toLocaleTimeString()} +

+ )} + + ) : ( +

{t("portal.admin.statusUnavailable", "Status derzeit nicht abrufbar")}

)} {kpi && ( -

- {t("portal.kpi.objects", "Objekte")}: {kpi.objektBestand} · {t("portal.kpi.deletions", "Löschvorgänge")}: {kpi.loeschvorgaenge} ·{" "} - {t("portal.kpi.storage", "Speicher")}: {(kpi.speichervolumenBytes / 1024 ** 3).toFixed(2)} GB -

+
    +
  • +
  • +
  • +
)}
); diff --git a/web/portal/app/ModuleGrid.render.test.tsx b/web/portal/app/ModuleGrid.render.test.tsx new file mode 100644 index 00000000..10b9abe5 --- /dev/null +++ b/web/portal/app/ModuleGrid.render.test.tsx @@ -0,0 +1,108 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider, MODULE_REGISTRY } from "@nexarch/shl"; +import "./messages"; +import { MODULE_ICONS } from "./moduleIcons"; + +const fetchMock = vi.fn(); +let tokenPromise: Promise; +vi.mock("../lib/http", () => ({ + getSession: () => ({ + token: () => tokenPromise, + identity: () => ({ userId: "u-1", tenant: "acme", role: "user" }), + fetch: (...a: unknown[]) => fetchMock(...a), + }), +})); + +import { ModuleGrid } from "./ModuleGrid"; + +const mods = [ + { key: "dms", group: "work", label: "Dokumente", description: "Dokumente verwalten.", href: "http://localhost:3101", permission: "p" }, + { key: "workflow", group: "work", label: "Aufgaben & Freigaben", description: "Freigaben.", href: "http://localhost:3103", permission: "p" }, + { key: "rbac_admin", group: "admin", label: "Rollen & Rechte", description: "Rechte.", href: "http://localhost:3112", permission: "p" }, +]; + +function reply(modules: unknown, worklist: unknown) { + fetchMock.mockImplementation(async (url: string) => { + if (url === "/api/visible-modules") return new Response(JSON.stringify({ modules }), { status: 200 }); + if (url === "/api/worklist") return worklist ? new Response(JSON.stringify(worklist), { status: 200 }) : new Response("", { status: 500 }); + return new Response("", { status: 404 }); + }); +} + +function renderGrid(locale: "de" | "en" = "de") { + return render(); +} + +beforeEach(() => { + fetchMock.mockReset(); + tokenPromise = Promise.resolve("t"); +}); + +describe("Icons", () => { + it("jedes Modul der Registry hat ein eigenes Icon", () => { + for (const m of MODULE_REGISTRY.filter((x) => x.key !== "portal")) expect(MODULE_ICONS[m.key], m.key).toBeTruthy(); + }); +}); + +describe("ModuleGrid (gerendert)", () => { + it("zeigt zuerst den Skeleton mit Statustext, danach die Kacheln", async () => { + let release!: (v: string) => void; + tokenPromise = new Promise((r) => (release = r)); + reply(mods, { approvals: 5, tasks: 0, remindersDue: 2 }); + renderGrid(); + expect(screen.getByRole("status").textContent).toBe("Übersicht wird geladen"); + release("t"); + expect(await screen.findByRole("link", { name: /Dokumente/ })).toBeTruthy(); + expect(screen.queryByText("Übersicht wird geladen")).toBeNull(); + }); + + it("Badge zeigt Text mit Einheit, Gruppen sind Ueberschriften", async () => { + reply(mods, { approvals: 5, tasks: 0, remindersDue: 2 }); + renderGrid(); + const wf = await screen.findByRole("link", { name: /Aufgaben & Freigaben/ }); + expect(wf.textContent).toContain("5 offen"); + expect(screen.getByRole("heading", { name: "Arbeiten" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Verwalten" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Heute" })).toBeTruthy(); + }); + + it("Ausfall der Worklist blendet 'Heute' aus, Kacheln bleiben", async () => { + reply(mods, null); + renderGrid(); + await screen.findByRole("link", { name: /Dokumente/ }); + expect(screen.queryByRole("heading", { name: "Heute" })).toBeNull(); + }); + + it("Teilausfall einer Zaehlerquelle wird als nicht verfuegbar benannt", async () => { + reply(mods, { approvals: 1, tasks: null, remindersDue: 0 }); + renderGrid(); + expect(await screen.findByText(/Meine Aufgaben \(derzeit nicht verfügbar\)/)).toBeTruthy(); + }); + + it("Fehler zeigt Meldung mit 'Erneut laden', das den Abruf wiederholt", async () => { + const user = userEvent.setup(); + fetchMock.mockRejectedValueOnce(new Error("netz")).mockRejectedValueOnce(new Error("netz")); + renderGrid(); + const alert = await screen.findByRole("alert"); + expect(alert.textContent).toContain("konnte nicht geladen werden"); + reply(mods, { approvals: 0, tasks: 0, remindersDue: 0 }); + await user.click(screen.getByRole("button", { name: "Erneut laden" })); + expect(await screen.findByRole("link", { name: /Dokumente/ })).toBeTruthy(); + }); + + it("leere Modulliste zeigt Hinweis auf die Administration", async () => { + reply([], null); + renderGrid(); + expect(await screen.findByText(/Berechtigungen vergibt Ihre Administration/)).toBeTruthy(); + }); + + it("englische Sprache uebersetzt Gruppen und Badge", async () => { + reply(mods, { approvals: 3, tasks: 0, remindersDue: 0 }); + renderGrid("en"); + const wf = await screen.findByRole("link", { name: /Aufgaben & Freigaben/ }); + expect(wf.textContent).toContain("3 open"); + expect(screen.getByRole("heading", { name: "Work" })).toBeTruthy(); + }); +}); diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index 84f427de..2ef2fed6 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -1,10 +1,12 @@ "use client"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useI18n, resolveModuleHref } from "@nexarch/shl"; import type { ModuleEntry } from "../lib/modules"; import type { Worklist } from "../lib/worklist"; import { getSession } from "../lib/http"; +import { ModuleTile, StatTile } from "./Tile"; +import { Skeleton } from "./Skeleton"; interface VisibleModulesResponse { modules?: ModuleEntry[]; @@ -54,54 +56,77 @@ export function badgeFor(key: string, w: Worklist | null): number | null { type State = | { status: "loading" } | { status: "noIdentity" } - | { status: "error"; message: string } - | { status: "ready"; modules: ModuleEntry[]; worklist: Worklist | null }; + | { status: "error" } + | { status: "ready"; modules: ModuleEntry[]; worklist: Worklist | null; who: { userId: string; tenant: string } }; export function ModuleGrid() { const { t } = useI18n(); const [state, setState] = useState({ status: "loading" }); + const [attempt, setAttempt] = useState(0); useEffect(() => { let cancelled = false; + setState({ status: "loading" }); (async () => { try { const session = getSession(); await session.token(); - if (!session.identity()) { + const identity = session.identity(); + if (!identity) { if (!cancelled) setState({ status: "noIdentity" }); return; } const call = (input: RequestInfo | URL, init?: RequestInit) => session.fetch(input, init); const [modules, worklist] = await Promise.all([fetchVisibleModules(call), fetchWorklist(call)]); - if (!cancelled) setState({ status: "ready", modules, worklist }); - } catch (err) { - if (!cancelled) { - setState({ status: "error", message: (err as Error).message }); - } + if (!cancelled) setState({ status: "ready", modules, worklist, who: { userId: identity.userId, tenant: identity.tenant } }); + } catch { + if (!cancelled) setState({ status: "error" }); } })(); return () => { cancelled = true; }; - }, []); + }, [attempt]); - if (state.status === "loading") return

{t("portal.loading", "Lade Übersicht …")}

; + const retry = useCallback(() => setAttempt((n) => n + 1), []); + + if (state.status === "loading") return ; if (state.status === "noIdentity") return

{t("portal.noIdentity", "Anmeldung erforderlich.")}

; - if (state.status === "error") return

{state.message}

; + if (state.status === "error") { + return ( +
+

{t("portal.error", "Die Übersicht konnte nicht geladen werden.")}

+ +
+ ); + } if (state.modules.length === 0) { - return

{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}

; + return ( +
+

{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}

+

{t("portal.noModules.hint", "Berechtigungen vergibt Ihre Administration.")}

+
+ ); } - const { modules, worklist } = state; + const { modules, worklist, who } = state; const dms = modules.find((m) => m.key === "dms"); - const today = [ - { label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals ?? null }, - { label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks ?? null }, - { label: t("portal.reminders", "Fällige Wiedervorlagen"), value: worklist?.remindersDue ?? null }, - ].filter((x) => x.value !== null); + const stats = [ + { key: "approvals", label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals, to: "workflow" }, + { key: "tasks", label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks, to: "workflow" }, + { key: "reminders", label: t("portal.reminders", "Fällige Wiedervorlagen"), value: worklist?.remindersDue, to: "workflow_cases" }, + ]; + const showStats = worklist !== null && stats.some((x) => x.value !== null && x.value !== undefined); + const hrefOf = (key: string) => { + const m = modules.find((x) => x.key === key); + return m ? resolveModuleHref(m.href) : undefined; + }; return (
+

+ {t("portal.greeting", "Angemeldet als")} {who.userId} · {t("shl.auth.tenant", "Mandant")}: {who.tenant} +

{dms && (
)} - {today.length > 0 && ( -
- {today.map((x) => ( -
- {x.value} -
{x.label}
-
- ))} + {showStats && ( +
+

{t("portal.today", "Heute")}

+
    + {stats.map((x) => ( +
  • + +
  • + ))} +
)} {groupModules(modules).map((g) => ( -
-

{t(`portal.group.${g.key}`, g.title)}

-
    +
    +

    {t(`portal.group.${g.key}`, g.title)}

    +
      {g.modules.map((mod) => { const badge = badgeFor(mod.key, worklist); return (
    • - - {mod.label} - {badge !== null && badge > 0 && ( - - {badge} - - )} -

      {mod.description}

      -
      + badgeText={badge !== null && badge > 0 ? `${badge} ${t("portal.badge.open", "offen")}` : undefined} + />
    • ); })} diff --git a/web/portal/app/PageTitle.tsx b/web/portal/app/PageTitle.tsx new file mode 100644 index 00000000..ba0579c2 --- /dev/null +++ b/web/portal/app/PageTitle.tsx @@ -0,0 +1,8 @@ +"use client"; + +import { useI18n } from "@nexarch/shl"; + +export function PageTitle() { + const { t } = useI18n(); + return

      {t("portal.title", "Übersicht")}

      ; +} diff --git a/web/portal/app/Skeleton.tsx b/web/portal/app/Skeleton.tsx new file mode 100644 index 00000000..e8e314d4 --- /dev/null +++ b/web/portal/app/Skeleton.tsx @@ -0,0 +1,13 @@ +export function Skeleton({ label }: { label: string }) { + return ( +
      +

      {label}

      + + +
      + ); +} diff --git a/web/portal/app/Tile.tsx b/web/portal/app/Tile.tsx new file mode 100644 index 00000000..48acdb7d --- /dev/null +++ b/web/portal/app/Tile.tsx @@ -0,0 +1,53 @@ +import type { MouseEventHandler, ReactNode } from "react"; +import { moduleIcon } from "./moduleIcons"; + +export interface ModuleTileProps { + moduleKey: string; + label: string; + description: string; + href: string; + badgeText?: string; + status?: { state: string; text: string }; + onOpen?: MouseEventHandler; +} + +export function ModuleTile({ moduleKey, label, description, href, badgeText, status, onOpen }: ModuleTileProps) { + return ( + + {moduleIcon(moduleKey)} + + + {label} + {badgeText && {badgeText}} + + {description} + {status && ( + + + )} + + + ); +} + +export interface StatTileProps { + value: ReactNode; + label: string; + href?: string; +} + +export function StatTile({ value, label, href }: StatTileProps) { + const inner = ( + <> + {value} + {label} + + ); + return href ? ( + {inner} + ) : ( +
      {inner}
      + ); +} diff --git a/web/portal/app/layout.tsx b/web/portal/app/layout.tsx index 8b70dc49..87db7236 100644 --- a/web/portal/app/layout.tsx +++ b/web/portal/app/layout.tsx @@ -1,6 +1,8 @@ import Script from "next/script"; import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl"; import type { ReactNode } from "react"; +import "./messages"; +import "./portal.css"; import { AccountBar } from "./AccountBar"; import { PortalShell } from "./PortalShell"; diff --git a/web/portal/app/messages.ts b/web/portal/app/messages.ts new file mode 100644 index 00000000..cef84e03 --- /dev/null +++ b/web/portal/app/messages.ts @@ -0,0 +1,91 @@ +import { registerMessages } from "@nexarch/shl"; + +registerMessages("de", { + "portal.title": "Übersicht", + "portal.greeting": "Angemeldet als", + "portal.loading": "Übersicht wird geladen", + "portal.loaded": "Übersicht geladen", + "portal.noIdentity": "Anmeldung erforderlich.", + "portal.noModules": "Für Ihr Konto sind derzeit keine Module freigeschaltet.", + "portal.noModules.hint": "Berechtigungen vergibt Ihre Administration.", + "portal.error": "Die Übersicht konnte nicht geladen werden.", + "portal.retry": "Erneut laden", + "portal.search": "Dokumente durchsuchen", + "portal.today": "Heute", + "portal.approvals": "Offene Freigaben", + "portal.tasks": "Meine Aufgaben", + "portal.reminders": "Fällige Wiedervorlagen", + "portal.unavailable": "derzeit nicht verfügbar", + "portal.group.work": "Arbeiten", + "portal.group.admin": "Verwalten", + "portal.group.audit": "Prüfen", + "portal.badge.open": "offen", + "portal.admin": "Betrieb", + "portal.admin.statusUnavailable": "Status derzeit nicht abrufbar", + "portal.admin.updated": "Stand", + "portal.status.up": "in Betrieb", + "portal.status.down": "gestört", + "portal.status.unknown": "unbekannt", + "portal.kpi.objects": "Objekte", + "portal.kpi.deletions": "Löschvorgänge", + "portal.kpi.storage": "Speicher", + "portal.favorites": "Favoriten", + "portal.recent": "Zuletzt benutzt", + "portal.favorite.add": "als Favorit markieren", + "portal.favorite.remove": "als Favorit entfernen", + "portal.favorite.added": "zu Favoriten hinzugefügt", + "portal.favorite.removed": "aus Favoriten entfernt", + "portal.group.collapse": "Bereich einklappen", + "portal.group.expand": "Bereich ausklappen", + "portal.palette.open": "Modul oder Dokument suchen", + "portal.palette.title": "Befehlsleiste", + "portal.palette.placeholder": "Modul suchen oder Dokumentsuche starten", + "portal.palette.results": "Treffer", + "portal.palette.searchDocs": "In Dokumenten suchen", + "portal.palette.none": "Kein Modul gefunden", +}); + +registerMessages("en", { + "portal.title": "Overview", + "portal.greeting": "Signed in as", + "portal.loading": "Loading overview", + "portal.loaded": "Overview loaded", + "portal.noIdentity": "Sign-in required.", + "portal.noModules": "No modules are enabled for your account yet.", + "portal.noModules.hint": "Your administration assigns permissions.", + "portal.error": "The overview could not be loaded.", + "portal.retry": "Reload", + "portal.search": "Search documents", + "portal.today": "Today", + "portal.approvals": "Open approvals", + "portal.tasks": "My tasks", + "portal.reminders": "Due follow-ups", + "portal.unavailable": "currently unavailable", + "portal.group.work": "Work", + "portal.group.admin": "Manage", + "portal.group.audit": "Review", + "portal.badge.open": "open", + "portal.admin": "Operations", + "portal.admin.statusUnavailable": "Status currently unavailable", + "portal.admin.updated": "As of", + "portal.status.up": "running", + "portal.status.down": "disrupted", + "portal.status.unknown": "unknown", + "portal.kpi.objects": "Objects", + "portal.kpi.deletions": "Deletions", + "portal.kpi.storage": "Storage", + "portal.favorites": "Favorites", + "portal.recent": "Recently used", + "portal.favorite.add": "mark as favorite", + "portal.favorite.remove": "remove from favorites", + "portal.favorite.added": "added to favorites", + "portal.favorite.removed": "removed from favorites", + "portal.group.collapse": "Collapse section", + "portal.group.expand": "Expand section", + "portal.palette.open": "Find a module or document", + "portal.palette.title": "Command bar", + "portal.palette.placeholder": "Find a module or search documents", + "portal.palette.results": "Results", + "portal.palette.searchDocs": "Search in documents", + "portal.palette.none": "No module found", +}); diff --git a/web/portal/app/moduleIcons.tsx b/web/portal/app/moduleIcons.tsx new file mode 100644 index 00000000..96947b10 --- /dev/null +++ b/web/portal/app/moduleIcons.tsx @@ -0,0 +1,31 @@ +import type { ReactNode } from "react"; + +const svg = (children: ReactNode) => ( + +); + +const doc = svg(<>); + +export const MODULE_ICONS: Record = { + dms: doc, + dms_upload: svg(<>), + mail: svg(<>), + workflow: svg(<>), + workflow_cases: svg(<>), + routing_rules: svg(<>), + ai: svg(<>), + ai_extraction: svg(<>), + connect: svg(<>), + connect_datev: svg(<>), + tenant_admin: svg(<>), + rbac_admin: svg(<>), + kennzahlen_dashboard: svg(<>), +}; + +export const FALLBACK_ICON = svg(); + +export function moduleIcon(key: string): ReactNode { + return MODULE_ICONS[key] ?? FALLBACK_ICON; +} diff --git a/web/portal/app/page.tsx b/web/portal/app/page.tsx index afe21a43..87ecb220 100644 --- a/web/portal/app/page.tsx +++ b/web/portal/app/page.tsx @@ -1,10 +1,11 @@ import { AdminPanel } from "./AdminPanel"; import { ModuleGrid } from "./ModuleGrid"; +import { PageTitle } from "./PageTitle"; export default function Page() { return ( -
      -

      NEXARCH Portal

      +
      +
      diff --git a/web/portal/app/portal.css b/web/portal/app/portal.css new file mode 100644 index 00000000..5d84d82a --- /dev/null +++ b/web/portal/app/portal.css @@ -0,0 +1,66 @@ +.np-page { max-width: 1040px; margin: 0 auto; padding: var(--shl-spacing-lg, 24px) var(--shl-spacing-md, 16px); } +.np-page h1 { font-size: 24px; margin: 0 0 var(--shl-spacing-xs, 4px); } +.np-greeting { margin: 0 0 var(--shl-spacing-lg, 24px); color: var(--shl-color-text-secondary, #4b5563); } +.np-section { margin: 0 0 var(--shl-spacing-lg, 24px); } +.np-section > h2 { font-size: 18px; margin: 0 0 var(--shl-spacing-sm, 8px); } +.np-grid { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--shl-spacing-md, 16px); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } +.np-stats { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } + +.np-tile { + position: relative; display: flex; gap: var(--shl-spacing-md, 16px); align-items: flex-start; + min-height: 112px; padding: var(--shl-spacing-md, 16px); box-sizing: border-box; + background: var(--shl-color-surface-raised, #fff); color: var(--shl-color-text-primary, #14181f); + border: 1px solid var(--shl-color-border, #d7dbe0); border-radius: var(--shl-radius-md, 12px); + box-shadow: var(--shl-shadow-sm, none); text-decoration: none; + transition: border-color var(--shl-motion-fast, 150ms) ease, box-shadow var(--shl-motion-fast, 150ms) ease, transform var(--shl-motion-fast, 150ms) ease; +} +.np-tile:hover { border-color: var(--shl-color-accent, #1d4ed8); box-shadow: var(--shl-shadow-md, none); transform: translateY(-1px); } +.np-tile:focus-visible { border-color: var(--shl-color-accent, #1d4ed8); } +.np-tile:active { transform: none; background: var(--shl-color-accent-subtle, #eef3ff); } +.np-tile-icon { + flex: none; width: 40px; height: 40px; display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--shl-radius-sm, 8px); background: var(--shl-color-accent-subtle, #eef3ff); color: var(--shl-color-accent, #1d4ed8); +} +.np-tile-body { min-width: 0; flex: 1; } +.np-tile-title { display: flex; flex-wrap: wrap; align-items: center; gap: var(--shl-spacing-sm, 8px); font-size: 15px; font-weight: 600; } +.np-tile-desc { + margin: 4px 0 0; font-size: 13px; color: var(--shl-color-text-secondary, #4b5563); + display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; +} +.np-badge { + display: inline-flex; align-items: center; padding: 0 8px; min-height: 20px; font-size: 12px; font-weight: 600; + border-radius: var(--shl-radius-pill, 999px); background: var(--shl-color-accent, #1d4ed8); color: var(--shl-color-accent-contrast, #fff); +} +.np-stat { flex-direction: column; min-height: 88px; gap: 2px; } +.np-stat-value { font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; } +.np-stat-label { font-size: 13px; color: var(--shl-color-text-secondary, #4b5563); } + +.np-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; background: var(--shl-color-text-secondary, #4b5563); } +.np-dot[data-status="up"] { background: var(--shl-color-success, #15803d); } +.np-dot[data-status="down"] { background: var(--shl-color-danger, #b91c1c); } +.np-status-list { list-style: none; margin: 0 0 var(--shl-spacing-md, 16px); padding: 0; } +.np-status-list li { padding: 4px 0; } + +.np-skeleton { background: var(--shl-color-skeleton, #e5e7eb); border-radius: var(--shl-radius-md, 12px); min-height: 112px; animation: np-shine 1.4s ease-in-out infinite; } +.np-skeleton-stat { min-height: 88px; } +@keyframes np-shine { 0%, 100% { background: var(--shl-color-skeleton, #e5e7eb); } 50% { background: var(--shl-color-skeleton-shine, #f3f4f6); } } + +.np-error { + padding: var(--shl-spacing-md, 16px); border: 1px solid var(--shl-color-danger, #b91c1c); border-radius: var(--shl-radius-md, 12px); + background: var(--shl-color-danger-subtle, #fdecea); +} +.np-button { + min-height: 44px; padding: 0 var(--shl-spacing-md, 16px); border-radius: var(--shl-radius-sm, 8px); font: inherit; font-weight: 500; cursor: pointer; + border: 1px solid var(--shl-color-border, #d7dbe0); background: var(--shl-color-surface-raised, #fff); color: var(--shl-color-text-primary, #14181f); +} +.np-button:hover { background: var(--shl-color-surface-hover, #eef3ff); } + +@media (max-width: 768px) { + .np-grid { grid-template-columns: 1fr; } + .np-stats { grid-template-columns: repeat(2, 1fr); } + .np-tile:hover { transform: none; } +} +@media (prefers-reduced-motion: reduce) { + .np-skeleton { animation: none; } + .np-tile:hover { transform: none; } +} diff --git a/web/shl/__tests__/tokens.test.ts b/web/shl/__tests__/tokens.test.ts index 776950d3..cba92d96 100644 --- a/web/shl/__tests__/tokens.test.ts +++ b/web/shl/__tests__/tokens.test.ts @@ -41,3 +41,21 @@ describe("Design-Tokens: WCAG 2.1 AA Kontrast", () => { }); } }); + +describe("SHL-Erweiterung: Radius, Schatten, Skeleton, Favorit", () => { + it("cssVariables liefert die neuen Variablen in beiden Schemata", async () => { + const { cssVariables } = await import("../tokens/tokens"); + for (const scheme of ["light", "dark"] as const) { + const v = cssVariables(scheme); + for (const k of ["--shl-radius-sm", "--shl-radius-md", "--shl-radius-pill", "--shl-shadow-sm", "--shl-shadow-md", "--shl-motion-fast", "--shl-color-skeleton", "--shl-color-skeleton-shine", "--shl-color-favorite", "--shl-color-surface-hover"]) { + expect(v[k], `${scheme} ${k}`).toBeTruthy(); + } + } + }); + + it("focusVisibleStyles enthaelt Skip-Link-Regel und reduced-motion", async () => { + const { focusVisibleStyles } = await import("../tokens/tokens"); + expect(focusVisibleStyles).toContain(".shl-skip-link"); + expect(focusVisibleStyles).toContain("prefers-reduced-motion"); + }); +}); diff --git a/web/shl/index.ts b/web/shl/index.ts index 95a3b004..8e483df4 100644 --- a/web/shl/index.ts +++ b/web/shl/index.ts @@ -1,7 +1,7 @@ // Öffentliche Schnittstelle des Pakets @nexarch/shl — Modul-Frontends importieren ausschließlich hierüber, // nicht aus internen Unterpfaden (SHL-01 Akzeptanzkriterium 2: dokumentiert, versioniert, importierbar statt kopiert). -export { colorTokens, spacing, breakpoints, typography, cssVariables, focusVisibleStyles, baseComponentStyles } from "./tokens/tokens"; +export { colorTokens, spacing, radius, shadows, breakpoints, typography, cssVariables, focusVisibleStyles, baseComponentStyles } from "./tokens/tokens"; export type { ColorScheme, ColorTokens } from "./tokens/tokens"; export { ThemeProvider, useTheme, currentColors } from "./theme/ThemeProvider"; diff --git a/web/shl/tokens/tokens.ts b/web/shl/tokens/tokens.ts index 3ee3c7d9..723a284e 100644 --- a/web/shl/tokens/tokens.ts +++ b/web/shl/tokens/tokens.ts @@ -20,6 +20,10 @@ export interface ColorTokens { success: string; warning: string; focusRing: string; + skeleton: string; + skeletonShine: string; + favorite: string; + surfaceHover: string; } // Kontrastwerte geprüft: textPrimary auf background/surface >= 7:1, textSecondary >= 4.5:1, @@ -40,6 +44,10 @@ export const colorTokens: Record = { dangerSubtle: "#FDECEA", success: "#15803D", warning: "#B45309", + skeleton: "#E5E7EB", + skeletonShine: "#F3F4F6", + favorite: "#B45309", + surfaceHover: "#EEF3FF", focusRing: "#1D4ED8", }, dark: { @@ -57,6 +65,10 @@ export const colorTokens: Record = { dangerSubtle: "#3A1E22", success: "#4ADE80", warning: "#FBBF24", + skeleton: "#2A2F38", + skeletonShine: "#333944", + favorite: "#FBBF24", + surfaceHover: "#1E2A44", focusRing: "#5B8DEF", }, }; @@ -70,6 +82,13 @@ export const spacing = { xxl: "48px", } as const; +export const radius = { sm: "8px", md: "12px", pill: "999px" } as const; + +export const shadows: Record = { + light: { sm: "0 1px 2px rgba(0,0,0,.06)", md: "0 4px 12px rgba(0,0,0,.08)" }, + dark: { sm: "0 1px 2px rgba(0,0,0,.4)", md: "0 4px 12px rgba(0,0,0,.5)" }, +}; + export const breakpoints = { mobile: "0px", tablet: "768px", @@ -103,6 +122,21 @@ export const focusVisibleStyles = ` outline: 3px solid var(--shl-color-focus-ring, #1D4ED8); outline-offset: 2px; } + .shl-skip-link { + position: absolute; + left: var(--shl-spacing-sm, 8px); + top: -64px; + z-index: 1000; + padding: var(--shl-spacing-sm, 8px) var(--shl-spacing-md, 16px); + background: var(--shl-color-accent, #1D4ED8); + color: var(--shl-color-accent-contrast, #FFFFFF); + border-radius: var(--shl-radius-sm, 8px); + } + .shl-skip-link:focus { top: var(--shl-spacing-sm, 8px); } + @media (prefers-reduced-motion: reduce) { + :root { --shl-motion-fast: 0ms; } + *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; } + } .shl-visually-hidden { position: absolute; width: 1px; @@ -146,5 +180,11 @@ export function cssVariables(scheme: ColorScheme): Record { for (const [key, value] of Object.entries(spacing)) { vars[`--shl-spacing-${key}`] = value; } + for (const [key, value] of Object.entries(radius)) { + vars[`--shl-radius-${key}`] = value; + } + vars["--shl-shadow-sm"] = shadows[scheme].sm; + vars["--shl-shadow-md"] = shadows[scheme].md; + vars["--shl-motion-fast"] = "150ms"; return vars; } From 2d582c5b085d36c8a42a0e3b4e77b26daf1e3f58 Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 12:29:23 +0100 Subject: [PATCH 2/3] portal: Favoriten, Zuletzt benutzt, einklappbare Gruppen, Reihenfolge nach Modulzahl Co-Authored-By: Claude Sonnet 5.5 --- web/portal/app/ModuleGrid.render.test.tsx | 65 ++++++++++- web/portal/app/ModuleGrid.tsx | 135 ++++++++++++++++++---- web/portal/app/portal.css | 21 ++++ web/portal/lib/favorites.test.ts | 64 ++++++++++ web/portal/lib/favorites.ts | 59 ++++++++++ 5 files changed, 318 insertions(+), 26 deletions(-) create mode 100644 web/portal/lib/favorites.test.ts create mode 100644 web/portal/lib/favorites.ts diff --git a/web/portal/app/ModuleGrid.render.test.tsx b/web/portal/app/ModuleGrid.render.test.tsx index 10b9abe5..89d6dcfd 100644 --- a/web/portal/app/ModuleGrid.render.test.tsx +++ b/web/portal/app/ModuleGrid.render.test.tsx @@ -63,8 +63,8 @@ describe("ModuleGrid (gerendert)", () => { renderGrid(); const wf = await screen.findByRole("link", { name: /Aufgaben & Freigaben/ }); expect(wf.textContent).toContain("5 offen"); - expect(screen.getByRole("heading", { name: "Arbeiten" })).toBeTruthy(); - expect(screen.getByRole("heading", { name: "Verwalten" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: /Arbeiten/ })).toBeTruthy(); + expect(screen.getByRole("heading", { name: /Verwalten/ })).toBeTruthy(); expect(screen.getByRole("heading", { name: "Heute" })).toBeTruthy(); }); @@ -103,6 +103,65 @@ describe("ModuleGrid (gerendert)", () => { renderGrid("en"); const wf = await screen.findByRole("link", { name: /Aufgaben & Freigaben/ }); expect(wf.textContent).toContain("3 open"); - expect(screen.getByRole("heading", { name: "Work" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: /Work/ })).toBeTruthy(); + }); +}); + +import { orderGroups } from "./ModuleGrid"; + +describe("orderGroups", () => { + it("sortiert nach Anzahl sichtbarer Module, bei Gleichstand work/admin/audit", () => { + const g = (key: string, n: number) => ({ key, modules: new Array(n).fill(0) }); + expect(orderGroups([g("work", 1), g("admin", 1), g("audit", 1)]).map((x) => x.key)).toEqual(["work", "admin", "audit"]); + expect(orderGroups([g("work", 1), g("audit", 3)]).map((x) => x.key)).toEqual(["audit", "work"]); + }); +}); + +describe("Favoriten, Zuletzt benutzt und Einklappen", () => { + beforeEach(() => window.localStorage.clear()); + + it("Stern markiert Favorit, zeigt Abschnitt und meldet es per Live-Region", async () => { + const user = userEvent.setup(); + reply(mods, { approvals: 0, tasks: 0, remindersDue: 0 }); + renderGrid(); + const star = await screen.findByRole("button", { name: /Dokumente als Favorit markieren/ }); + expect(star.getAttribute("aria-pressed")).toBe("false"); + await user.click(star); + expect(screen.getByRole("heading", { name: "Favoriten" })).toBeTruthy(); + expect(window.localStorage.getItem("nexarch-portal-favorites")).toBe('["dms"]'); + expect(screen.getAllByRole("status").some((el) => el.textContent === "Dokumente zu Favoriten hinzugefügt")).toBe(true); + expect(screen.getAllByRole("button", { name: /Dokumente als Favorit entfernen/ })[0].getAttribute("aria-pressed")).toBe("true"); + }); + + it("entzogenes Modul verschwindet aus den Favoriten", async () => { + window.localStorage.setItem("nexarch-portal-favorites", '["dms","ai"]'); + reply(mods, null); + renderGrid(); + expect(await screen.findByRole("heading", { name: "Favoriten" })).toBeTruthy(); + expect(JSON.parse(window.localStorage.getItem("nexarch-portal-favorites") ?? "[]")).toContain("ai"); + expect(screen.getAllByRole("button", { name: /als Favorit entfernen/ })).toHaveLength(2); + }); + + it("Klick auf eine Kachel landet unter 'Zuletzt benutzt'", async () => { + const user = userEvent.setup(); + reply(mods, null); + renderGrid(); + const link = await screen.findByRole("link", { name: /Rollen & Rechte/ }); + link.addEventListener("click", (e) => e.preventDefault()); + await user.click(link); + expect(window.localStorage.getItem("nexarch-portal-recent")).toBe('["rbac_admin"]'); + expect(screen.getByRole("heading", { name: "Zuletzt benutzt" })).toBeTruthy(); + }); + + it("Gruppe laesst sich ein- und ausklappen und merkt sich den Zustand", async () => { + const user = userEvent.setup(); + reply(mods, null); + renderGrid(); + const toggle = await screen.findByRole("button", { name: /Verwalten/ }); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + await user.click(toggle); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(document.getElementById("list-admin")?.hidden).toBe(true); + expect(window.localStorage.getItem("nexarch-portal-collapsed")).toBe('["admin"]'); }); }); diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index 2ef2fed6..f4862ad0 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -1,12 +1,13 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useI18n, resolveModuleHref } from "@nexarch/shl"; import type { ModuleEntry } from "../lib/modules"; import type { Worklist } from "../lib/worklist"; import { getSession } from "../lib/http"; import { ModuleTile, StatTile } from "./Tile"; import { Skeleton } from "./Skeleton"; +import { pushRecent, readCollapsed, readFavorites, readRecent, toggleCollapsed, toggleFavorite } from "../lib/favorites"; interface VisibleModulesResponse { modules?: ModuleEntry[]; @@ -53,6 +54,13 @@ export function badgeFor(key: string, w: Worklist | null): number | null { return null; } +const GROUP_ORDER = ["work", "admin", "audit"]; + +// Gruppen mit mehr sichtbaren Modulen zuerst (Pruefer sehen ihre Gruppe oben), bei Gleichstand work/admin/audit. +export function orderGroups(groups: T[]): T[] { + return [...groups].sort((a, b) => b.modules.length - a.modules.length || GROUP_ORDER.indexOf(a.key) - GROUP_ORDER.indexOf(b.key)); +} + type State = | { status: "loading" } | { status: "noIdentity" } @@ -109,8 +117,33 @@ export function ModuleGrid() { ); } - const { modules, worklist, who } = state; - const dms = modules.find((m) => m.key === "dms"); + return ; +} + +interface OverviewProps { + modules: ModuleEntry[]; + worklist: Worklist | null; + who: { userId: string; tenant: string }; +} + +function Overview({ modules, worklist, who }: OverviewProps) { + const { t } = useI18n(); + const keys = useMemo(() => modules.map((m) => m.key), [modules]); + const [favorites, setFavorites] = useState([]); + const [recent, setRecent] = useState([]); + const [collapsed, setCollapsed] = useState([]); + const [live, setLive] = useState(""); + + useEffect(() => { + setFavorites(readFavorites(keys)); + setRecent(readRecent(keys)); + setCollapsed(readCollapsed()); + setLive(`${t("portal.loaded", "Übersicht geladen")}, ${keys.length}`); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [keys]); + + const byKey = useMemo(() => new Map(modules.map((m) => [m.key, m])), [modules]); + const dms = byKey.get("dms"); const stats = [ { key: "approvals", label: t("portal.approvals", "Offene Freigaben"), value: worklist?.approvals, to: "workflow" }, { key: "tasks", label: t("portal.tasks", "Meine Aufgaben"), value: worklist?.tasks, to: "workflow" }, @@ -118,10 +151,47 @@ export function ModuleGrid() { ]; const showStats = worklist !== null && stats.some((x) => x.value !== null && x.value !== undefined); const hrefOf = (key: string) => { - const m = modules.find((x) => x.key === key); + const m = byKey.get(key); return m ? resolveModuleHref(m.href) : undefined; }; + const onStar = (mod: ModuleEntry) => { + const was = favorites.includes(mod.key); + setFavorites(toggleFavorite(mod.key, keys)); + setLive(`${mod.label} ${was ? t("portal.favorite.removed", "aus Favoriten entfernt") : t("portal.favorite.added", "zu Favoriten hinzugefügt")}`); + }; + + const renderTile = (mod: ModuleEntry, compact = false) => { + const badge = badgeFor(mod.key, worklist); + const isFav = favorites.includes(mod.key); + return ( +
    • + 0 ? `${badge} ${t("portal.badge.open", "offen")}` : undefined} + onOpen={() => setRecent(pushRecent(mod.key, keys))} + /> + +
    • + ); + }; + + const favMods = favorites.map((k) => byKey.get(k)).filter((m): m is ModuleEntry => !!m); + const recentMods = recent.map((k) => byKey.get(k)).filter((m): m is ModuleEntry => !!m); + return (

      @@ -154,27 +224,46 @@ export function ModuleGrid() {

    )} - {groupModules(modules).map((g) => ( -
    -

    {t(`portal.group.${g.key}`, g.title)}

    -
      - {g.modules.map((mod) => { - const badge = badgeFor(mod.key, worklist); - return ( -
    • - 0 ? `${badge} ${t("portal.badge.open", "offen")}` : undefined} - /> -
    • - ); - })} + {favMods.length > 0 && ( +
      +

      {t("portal.favorites", "Favoriten")}

      +
        {favMods.map((m) => renderTile(m, true))}
      +
      + )} + {recentMods.length > 0 && ( +
      +

      {t("portal.recent", "Zuletzt benutzt")}

      +
      - ))} + )} + {orderGroups(groupModules(modules)).map((g) => { + const isCollapsed = collapsed.includes(g.key); + return ( +
      +

      + +

      + +
      + ); + })} +

      {live}

); } diff --git a/web/portal/app/portal.css b/web/portal/app/portal.css index 5d84d82a..94662caa 100644 --- a/web/portal/app/portal.css +++ b/web/portal/app/portal.css @@ -64,3 +64,24 @@ .np-skeleton { animation: none; } .np-tile:hover { transform: none; } } + +.np-tile-wrap { position: relative; } +.np-star { + position: absolute; top: 4px; right: 4px; width: 44px; height: 44px; display: inline-flex; align-items: center; justify-content: center; + background: transparent; border: 0; border-radius: var(--shl-radius-sm, 8px); cursor: pointer; color: var(--shl-color-text-secondary, #4b5563); +} +.np-star:hover { background: var(--shl-color-surface-hover, #eef3ff); } +.np-star[aria-pressed="true"] { color: var(--shl-color-favorite, #b45309); } +.np-tile-wrap .np-tile-title { padding-right: 36px; } +.np-tile-wrap[data-density="compact"] .np-tile { min-height: 72px; } +.np-tile-wrap[data-density="compact"] .np-tile-desc { display: none !important; } +.np-tile-wrap[data-density="compact"] .np-tile-icon { width: 32px; height: 32px; } +.np-chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--shl-spacing-sm, 8px); } +.np-chip { + display: inline-flex; align-items: center; min-height: 44px; padding: 0 var(--shl-spacing-md, 16px); text-decoration: none; + border: 1px solid var(--shl-color-border, #d7dbe0); border-radius: var(--shl-radius-pill, 999px); background: var(--shl-color-surface-raised, #fff); + color: var(--shl-color-text-primary, #14181f); +} +.np-chip:hover { background: var(--shl-color-surface-hover, #eef3ff); border-color: var(--shl-color-accent, #1d4ed8); } +.np-group-toggle { background: none; border: 0; padding: 0; min-height: 44px; font: inherit; font-weight: 600; color: inherit; cursor: pointer; text-align: left; } +.np-section > h2:has(.np-group-toggle) { margin: 0; } diff --git a/web/portal/lib/favorites.test.ts b/web/portal/lib/favorites.test.ts new file mode 100644 index 00000000..50a00ada --- /dev/null +++ b/web/portal/lib/favorites.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MAX_FAVORITES, MAX_RECENT, pushRecent, readCollapsed, readFavorites, readRecent, toggleCollapsed, toggleFavorite } from "./favorites"; + +const all = ["dms", "mail", "workflow", "ai", "connect", "rbac_admin", "tenant_admin", "kennzahlen_dashboard"]; + +beforeEach(() => window.localStorage.clear()); + +describe("Favoriten", () => { + it("schaltet um und behaelt die Reihenfolge des Markierens", () => { + toggleFavorite("mail", all); + toggleFavorite("dms", all); + expect(readFavorites(all)).toEqual(["mail", "dms"]); + toggleFavorite("mail", all); + expect(readFavorites(all)).toEqual(["dms"]); + }); + + it("filtert Keys heraus, die der Benutzer nicht mehr sehen darf, und speichert nur Modul-Keys", () => { + toggleFavorite("dms", all); + toggleFavorite("mail", all); + expect(readFavorites(["mail"])).toEqual(["mail"]); + expect(window.localStorage.getItem("nexarch-portal-favorites")).toBe('["dms","mail"]'); + }); + + it("begrenzt auf die Maximalzahl, aeltester faellt heraus", () => { + for (const k of all.slice(0, MAX_FAVORITES + 1)) toggleFavorite(k, all); + const fav = readFavorites(all); + expect(fav).toHaveLength(MAX_FAVORITES); + expect(fav).not.toContain(all[0]); + }); + + it("ignoriert kaputten Speicherinhalt", () => { + window.localStorage.setItem("nexarch-portal-favorites", "{kaputt"); + expect(readFavorites(all)).toEqual([]); + window.localStorage.setItem("nexarch-portal-favorites", '[1,"dms",null]'); + expect(readFavorites(all)).toEqual(["dms"]); + }); + + it("bleibt nutzbar, wenn der Speicher wirft", () => { + const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new Error("voll"); + }); + expect(() => toggleFavorite("dms", all)).not.toThrow(); + spy.mockRestore(); + }); +}); + +describe("Zuletzt benutzt", () => { + it("neuester vorn, ohne Duplikate, begrenzt", () => { + for (const k of ["dms", "mail", "workflow", "ai", "connect", "mail"]) pushRecent(k, all); + const r = readRecent(all); + expect(r[0]).toBe("mail"); + expect(new Set(r).size).toBe(r.length); + expect(r.length).toBeLessThanOrEqual(MAX_RECENT); + }); +}); + +describe("Eingeklappte Gruppen", () => { + it("merkt sich den Zustand je Gruppe", () => { + toggleCollapsed("admin"); + expect(readCollapsed()).toEqual(["admin"]); + toggleCollapsed("admin"); + expect(readCollapsed()).toEqual([]); + }); +}); diff --git a/web/portal/lib/favorites.ts b/web/portal/lib/favorites.ts new file mode 100644 index 00000000..95615da3 --- /dev/null +++ b/web/portal/lib/favorites.ts @@ -0,0 +1,59 @@ +const FAV_KEY = "nexarch-portal-favorites"; +const RECENT_KEY = "nexarch-portal-recent"; +const COLLAPSED_KEY = "nexarch-portal-collapsed"; +export const MAX_FAVORITES = 6; +export const MAX_RECENT = 4; + +function read(key: string): string[] { + try { + const raw = window.localStorage.getItem(key); + if (!raw) return []; + const v = JSON.parse(raw); + return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []; + } catch { + return []; + } +} + +function write(key: string, value: string[]): void { + try { + window.localStorage.setItem(key, JSON.stringify(value)); + } catch { + /* Speicher nicht verfuegbar: Funktion bleibt ohne Persistenz nutzbar */ + } +} + +// Nur Modul-Keys, die der Benutzer aktuell sehen darf: entzogene Rechte bleiben nicht sichtbar. +export function readFavorites(visibleKeys: string[]): string[] { + const visible = new Set(visibleKeys); + return read(FAV_KEY).filter((k) => visible.has(k)).slice(0, MAX_FAVORITES); +} + +export function toggleFavorite(key: string, visibleKeys: string[]): string[] { + const current = readFavorites(visibleKeys); + const next = current.includes(key) ? current.filter((k) => k !== key) : [...current, key].slice(-MAX_FAVORITES); + write(FAV_KEY, next); + return next; +} + +export function readRecent(visibleKeys: string[]): string[] { + const visible = new Set(visibleKeys); + return read(RECENT_KEY).filter((k) => visible.has(k)).slice(0, MAX_RECENT); +} + +export function pushRecent(key: string, visibleKeys: string[]): string[] { + const next = [key, ...readRecent(visibleKeys).filter((k) => k !== key)].slice(0, MAX_RECENT); + write(RECENT_KEY, next); + return next; +} + +export function readCollapsed(): string[] { + return read(COLLAPSED_KEY); +} + +export function toggleCollapsed(group: string): string[] { + const current = readCollapsed(); + const next = current.includes(group) ? current.filter((g) => g !== group) : [...current, group]; + write(COLLAPSED_KEY, next); + return next; +} From 142d213178bd038bd013eb1936b2c3b3d8981c3c Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 28 Oct 2026 12:32:12 +0100 Subject: [PATCH 3/3] portal: Befehlsleiste (Strg+K) mit Modulsprung und Dokumentsuche, Texte DE/EN, README Co-Authored-By: Claude Sonnet 5.5 --- web/portal/README.md | 8 ++ web/portal/app/CommandPalette.test.tsx | 87 ++++++++++++++++++++ web/portal/app/CommandPalette.tsx | 96 +++++++++++++++++++++++ web/portal/app/ModuleGrid.render.test.tsx | 22 ++++++ web/portal/app/ModuleGrid.tsx | 47 ++++++++--- web/portal/app/messages.test.ts | 22 ++++++ web/portal/app/portal.css | 16 ++++ web/portal/lib/commandSearch.test.ts | 28 +++++++ web/portal/lib/commandSearch.ts | 37 +++++++++ 9 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 web/portal/app/CommandPalette.test.tsx create mode 100644 web/portal/app/CommandPalette.tsx create mode 100644 web/portal/app/messages.test.ts create mode 100644 web/portal/lib/commandSearch.test.ts create mode 100644 web/portal/lib/commandSearch.ts diff --git a/web/portal/README.md b/web/portal/README.md index 748cd719..4a9fac85 100644 --- a/web/portal/README.md +++ b/web/portal/README.md @@ -54,6 +54,14 @@ Die Statusseite (`PORTAL_STATUS_API_URL`) ist selbst nicht authentifiziert und d `GET /api/system-status` (Recht `core.tenant.admin`) und `GET /api/kennzahlen-summary` (Recht `kennzahlen.read`) prüfen das Recht über die policy-api, bevor sie die Quelle abfragen, und geben nur Modulname, Status, Zeitpunkt bzw. drei Summenwerte zurück (Objekte, Löschvorgänge, Speicher). Die Kennzahlen-Quelle erhält Mandant und Rolle aus dem Token. Die Statusseite ist selbst nicht authentifiziert und darf nur vom Portal-Server aufgerufen werden. Ohne Recht antworten die Routen mit 403, der Block bleibt ausgeblendet. +## Bedienung + +- **Befehlsleiste:** `Strg+K` (oder `/` außerhalb von Eingabefeldern) öffnet eine Suche über die sichtbaren Module; ohne Modultreffer führt Enter zur Dokumentsuche des DMS (`/search?q=`). Rein clientseitig, keine Daten verlassen den Browser. +- **Favoriten und Zuletzt benutzt:** Stern an der Kachel; gespeichert werden nur Modul-Schlüssel in `localStorage` (`nexarch-portal-favorites`, `-recent`, `-collapsed`). Schlüssel zu Modulen ohne aktuelle Berechtigung werden beim Lesen verworfen. +- **Gruppen** lassen sich einklappen und sind nach der Zahl sichtbarer Module sortiert (Prüfer sehen "Prüfen" zuerst). +- **Ladezustand** als Skeleton, Fehler mit "Erneut laden". Texte kommen aus `app/messages.ts` (DE/EN). +- Hell/Dunkel und reduzierte Bewegung folgen den Tokens in `@nexarch/shl`; Stile in `app/portal.css`. + ## Oberfläche Kacheln sind nach Arbeiten, Verwalten und Prüfen gruppiert (`group` in `MODULE_REGISTRY`). Das Mandanten-Label in der Navigation stammt aus der Sitzung. Die Suchzeile leitet an `/search?q=` weiter und erscheint nur, wenn das DMS für den Benutzer sichtbar ist. diff --git a/web/portal/app/CommandPalette.test.tsx b/web/portal/app/CommandPalette.test.tsx new file mode 100644 index 00000000..39ff56cc --- /dev/null +++ b/web/portal/app/CommandPalette.test.tsx @@ -0,0 +1,87 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { I18nProvider } from "@nexarch/shl"; +import "./messages"; +import { useState } from "react"; +import { CommandPalette } from "./CommandPalette"; + +const mod = (key: string, label: string, description = "") => ({ key, label, description, href: `http://x/${key}`, permission: "p" }); +const mods = [mod("dms", "Dokumente", "Dokumente verwalten"), mod("mail", "Postfach", "E-Mails")]; +const navigate = vi.fn(); + +function Host({ withDms = true }: { withDms?: boolean }) { + const [open, setOpen] = useState(false); + return ( + + + setOpen(false)} + modules={mods} + favorites={[]} + recent={[]} + resolveHref={(h) => h} + dmsHref={withDms ? "http://x/dms" : undefined} + onNavigate={navigate} + /> + + ); +} + +beforeEach(() => navigate.mockReset()); + +async function openPalette() { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Auf" })); + const input = await screen.findByRole("combobox"); + await vi.waitFor(() => expect(document.activeElement).toBe(input)); + return { user, input }; +} + +describe("CommandPalette", () => { + it("fokussiert das Eingabefeld und tippen filtert; Pfeil und Enter navigieren", async () => { + const { user, input } = await openPalette(); + await user.type(input, "post"); + const opt = screen.getAllByRole("option")[0]; + expect(opt.textContent).toContain("Postfach"); + expect(input.getAttribute("aria-activedescendant")).toBe(opt.id); + await user.keyboard("{Enter}"); + expect(navigate).toHaveBeenCalledWith(expect.objectContaining({ kind: "module", key: "mail" })); + }); + + it("ohne Modultreffer fuehrt Enter zur Dokumentsuche mit kodiertem q", async () => { + const { user, input } = await openPalette(); + await user.type(input, "rechnung 2026"); + await user.keyboard("{Enter}"); + expect(navigate).toHaveBeenCalledWith({ kind: "docs", query: "rechnung 2026", href: "http://x/dms/search?q=rechnung%202026" }); + }); + + it("Pfeiltasten wechseln den aktiven Eintrag", async () => { + const { user, input } = await openPalette(); + const first = screen.getAllByRole("option")[0]; + expect(first.getAttribute("aria-selected")).toBe("true"); + await user.keyboard("{ArrowDown}"); + const second = screen.getAllByRole("option")[1]; + expect(second.getAttribute("aria-selected")).toBe("true"); + expect(input.getAttribute("aria-activedescendant")).toBe(second.id); + }); + + it("Esc schliesst und der Fokus kehrt zum Ausloeser zurueck", async () => { + const { user } = await openPalette(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("combobox")).toBeNull(); + expect(document.activeElement).toBe(screen.getByRole("button", { name: "Auf" })); + }); + + it("ohne sichtbares DMS gibt es keine Dokumentsuche", async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: "Auf" })); + const input = await screen.findByRole("combobox"); + await user.type(input, "xyz"); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.getByText("Kein Modul gefunden")).toBeTruthy(); + }); +}); diff --git a/web/portal/app/CommandPalette.tsx b/web/portal/app/CommandPalette.tsx new file mode 100644 index 00000000..692a97a1 --- /dev/null +++ b/web/portal/app/CommandPalette.tsx @@ -0,0 +1,96 @@ +"use client"; + +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Dialog, useI18n } from "@nexarch/shl"; +import { searchCommands, type CommandItem } from "../lib/commandSearch"; +import type { ModuleEntry } from "../lib/modules"; + +export interface CommandPaletteProps { + open: boolean; + onClose: () => void; + modules: ModuleEntry[]; + favorites: string[]; + recent: string[]; + resolveHref: (href: string) => string; + dmsHref?: string; + onNavigate: (item: CommandItem) => void; +} + +export function CommandPalette({ open, onClose, modules, favorites, recent, resolveHref, dmsHref, onNavigate }: CommandPaletteProps) { + const { t } = useI18n(); + const uid = useId(); + const inputRef = useRef(null); + const [query, setQuery] = useState(""); + const [active, setActive] = useState(0); + + const items = useMemo( + () => searchCommands(query, modules, favorites, recent, resolveHref, dmsHref), + [query, modules, favorites, recent, resolveHref, dmsHref] + ); + + useEffect(() => { + if (!open) return; + setQuery(""); + setActive(0); + const id = window.setTimeout(() => inputRef.current?.focus(), 0); + return () => window.clearTimeout(id); + }, [open]); + + useEffect(() => setActive(0), [query]); + + const optionId = (i: number) => `${uid}-opt-${i}`; + const label = (item: CommandItem) => + item.kind === "docs" ? `${t("portal.palette.searchDocs", "In Dokumenten suchen")}: „${item.query}“` : item.label; + + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActive((i) => (items.length ? (i + 1) % items.length : 0)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActive((i) => (items.length ? (i - 1 + items.length) % items.length : 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = items[active]; + if (item) onNavigate(item); + } + } + + const listId = `${uid}-list`; + return ( + + 0} + aria-controls={listId} + aria-activedescendant={items.length ? optionId(active) : undefined} + aria-autocomplete="list" + aria-label={t("portal.palette.placeholder", "Modul suchen oder Dokumentsuche starten")} + placeholder={t("portal.palette.placeholder", "Modul suchen oder Dokumentsuche starten")} + value={query} + onChange={(e) => setQuery(e.target.value)} + onKeyDown={onKeyDown} + className="np-palette-input" + /> +
    + {items.map((item, i) => ( +
  • setActive(i)} + onClick={() => onNavigate(item)} + > + {label(item)} + {item.kind === "module" && {item.description}} +
  • + ))} +
+ {items.length === 0 &&

{t("portal.palette.none", "Kein Modul gefunden")}

} +
+ ); +} diff --git a/web/portal/app/ModuleGrid.render.test.tsx b/web/portal/app/ModuleGrid.render.test.tsx index 89d6dcfd..26cb5f51 100644 --- a/web/portal/app/ModuleGrid.render.test.tsx +++ b/web/portal/app/ModuleGrid.render.test.tsx @@ -165,3 +165,25 @@ describe("Favoriten, Zuletzt benutzt und Einklappen", () => { expect(window.localStorage.getItem("nexarch-portal-collapsed")).toBe('["admin"]'); }); }); + +describe("Befehlsleiste im Portal", () => { + it("Strg+K oeffnet die Palette, '/' ausserhalb von Eingabefeldern ebenfalls", async () => { + const user = userEvent.setup(); + reply(mods, null); + renderGrid(); + await screen.findByRole("link", { name: /Dokumente/ }); + await user.keyboard("{Control>}k{/Control}"); + expect(await screen.findByRole("combobox")).toBeTruthy(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("combobox")).toBeNull(); + await user.keyboard("/"); + expect(await screen.findByRole("combobox")).toBeTruthy(); + }); + + it("Ausloeser-Button nennt das Kuerzel", async () => { + reply(mods, null); + renderGrid(); + const btn = await screen.findByRole("button", { name: /Modul oder Dokument suchen/ }); + expect(btn.textContent).toContain("Strg K"); + }); +}); diff --git a/web/portal/app/ModuleGrid.tsx b/web/portal/app/ModuleGrid.tsx index f4862ad0..749b40e8 100644 --- a/web/portal/app/ModuleGrid.tsx +++ b/web/portal/app/ModuleGrid.tsx @@ -7,6 +7,8 @@ import type { Worklist } from "../lib/worklist"; import { getSession } from "../lib/http"; import { ModuleTile, StatTile } from "./Tile"; import { Skeleton } from "./Skeleton"; +import { CommandPalette } from "./CommandPalette"; +import type { CommandItem } from "../lib/commandSearch"; import { pushRecent, readCollapsed, readFavorites, readRecent, toggleCollapsed, toggleFavorite } from "../lib/favorites"; interface VisibleModulesResponse { @@ -133,6 +135,23 @@ function Overview({ modules, worklist, who }: OverviewProps) { const [recent, setRecent] = useState([]); const [collapsed, setCollapsed] = useState([]); const [live, setLive] = useState(""); + const [paletteOpen, setPaletteOpen] = useState(false); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + const target = e.target as HTMLElement | null; + const typing = !!target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable); + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); + setPaletteOpen(true); + } else if (e.key === "/" && !typing && !e.ctrlKey && !e.metaKey && !e.altKey) { + e.preventDefault(); + setPaletteOpen(true); + } + } + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, []); useEffect(() => { setFavorites(readFavorites(keys)); @@ -197,17 +216,23 @@ function Overview({ modules, worklist, who }: OverviewProps) {

{t("portal.greeting", "Angemeldet als")} {who.userId} · {t("shl.auth.tenant", "Mandant")}: {who.tenant}

- {dms && ( -
- -
- )} + + setPaletteOpen(false)} + modules={modules} + favorites={favorites} + recent={recent} + resolveHref={resolveModuleHref} + dmsHref={dms ? resolveModuleHref(dms.href) : undefined} + onNavigate={(item: CommandItem) => { + if (item.kind === "module") setRecent(pushRecent(item.key, keys)); + window.location.assign(item.href); + }} + /> {showStats && (

{t("portal.today", "Heute")}

diff --git a/web/portal/app/messages.test.ts b/web/portal/app/messages.test.ts new file mode 100644 index 00000000..ddabe402 --- /dev/null +++ b/web/portal/app/messages.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { registry } from "@nexarch/shl/i18n/messages"; +import "./messages"; + +// @ts-nocheck-Ersatz: nutzt Node-Builtins nur im Test +describe("portal.* Texte", () => { + it("jeder im Quelltext verwendete portal.*-Schluessel hat DE und EN", () => { + const dir = __dirname; + const files = readdirSync(dir).filter((f) => /\.(tsx?)$/.test(f) && !/\.test\./.test(f) && f !== "messages.ts"); + const keys = new Set(); + for (const f of files) { + for (const m of readFileSync(path.join(dir, f), "utf8").matchAll(/["'`](portal\.[a-zA-Z.]+)["'`]/g)) keys.add(m[1]); + } + expect(keys.size).toBeGreaterThan(10); + for (const k of keys) { + expect(registry.de[k], `de ${k}`).toBeTruthy(); + expect(registry.en[k], `en ${k}`).toBeTruthy(); + } + }); +}); diff --git a/web/portal/app/portal.css b/web/portal/app/portal.css index 94662caa..de75a672 100644 --- a/web/portal/app/portal.css +++ b/web/portal/app/portal.css @@ -85,3 +85,19 @@ .np-chip:hover { background: var(--shl-color-surface-hover, #eef3ff); border-color: var(--shl-color-accent, #1d4ed8); } .np-group-toggle { background: none; border: 0; padding: 0; min-height: 44px; font: inherit; font-weight: 600; color: inherit; cursor: pointer; text-align: left; } .np-section > h2:has(.np-group-toggle) { margin: 0; } + +.np-search { + display: flex; align-items: center; justify-content: space-between; gap: var(--shl-spacing-md, 16px); width: 100%; box-sizing: border-box; + min-height: 44px; margin: 0 0 var(--shl-spacing-lg, 24px); padding: 0 var(--shl-spacing-md, 16px); font: inherit; text-align: left; cursor: pointer; + border: 1px solid var(--shl-color-border, #d7dbe0); border-radius: var(--shl-radius-md, 12px); + background: var(--shl-color-surface-raised, #fff); color: var(--shl-color-text-secondary, #4b5563); +} +.np-search:hover { border-color: var(--shl-color-accent, #1d4ed8); } +.np-search kbd { font-family: inherit; font-size: 12px; padding: 2px 6px; border: 1px solid var(--shl-color-border, #d7dbe0); border-radius: 6px; } +.np-palette-input { + width: 100%; box-sizing: border-box; min-height: 44px; padding: 0 12px; font: inherit; border-radius: var(--shl-radius-sm, 8px); + border: 1px solid var(--shl-color-border, #d7dbe0); background: var(--shl-color-surface-raised, #fff); color: var(--shl-color-text-primary, #14181f); +} +.np-palette-list { list-style: none; margin: var(--shl-spacing-sm, 8px) 0 0; padding: 0; max-height: 50vh; overflow-y: auto; } +.np-palette-option { display: flex; flex-direction: column; padding: 10px 12px; min-height: 44px; box-sizing: border-box; border-radius: var(--shl-radius-sm, 8px); cursor: pointer; } +.np-palette-option[aria-selected="true"] { background: var(--shl-color-surface-hover, #eef3ff); outline: 2px solid var(--shl-color-accent, #1d4ed8); outline-offset: -2px; } diff --git a/web/portal/lib/commandSearch.test.ts b/web/portal/lib/commandSearch.test.ts new file mode 100644 index 00000000..bfc850c9 --- /dev/null +++ b/web/portal/lib/commandSearch.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "vitest"; +import { docsSearchHref, searchCommands } from "./commandSearch"; + +const mod = (key: string, label: string, description = "") => ({ key, label, description, href: `http://x/${key}`, permission: "p" }); +const mods = [mod("dms", "Dokumente", "Dokumente durchsuchen"), mod("mail", "Postfach", "E-Mails"), mod("ai", "KI-Funktionen", "Zusammenfassungen")]; +const id = (h: string) => h; + +describe("searchCommands", () => { + it("ohne Eingabe: Favoriten, dann Zuletzt benutzt, dann alphabetisch; keine Dokumentsuche", () => { + const r = searchCommands("", mods, ["ai"], ["mail"], id, "http://x/dms"); + expect(r.map((x) => (x.kind === "module" ? x.key : "docs"))).toEqual(["ai", "mail", "dms"]); + }); + + it("filtert case-insensitiv nach Name und Beschreibung und haengt die Dokumentsuche an", () => { + const r = searchCommands("POST", mods, [], [], id, "http://x/dms"); + expect(r.map((x) => (x.kind === "module" ? x.key : "docs"))).toEqual(["mail", "docs"]); + const d = searchCommands("zusammen", mods, [], [], id, "http://x/dms"); + expect(d[0]).toMatchObject({ kind: "module", key: "ai" }); + }); + + it("ohne sichtbares DMS keine Dokumentsuche", () => { + expect(searchCommands("xyz", mods, [], [], id, undefined)).toEqual([]); + }); + + it("kodiert die Suchanfrage in der URL", () => { + expect(docsSearchHref("http://dms/", "a&b c")).toBe("http://dms/search?q=a%26b%20c"); + }); +}); diff --git a/web/portal/lib/commandSearch.ts b/web/portal/lib/commandSearch.ts new file mode 100644 index 00000000..291eda27 --- /dev/null +++ b/web/portal/lib/commandSearch.ts @@ -0,0 +1,37 @@ +import type { ModuleEntry } from "./modules"; + +export type CommandItem = + | { kind: "module"; key: string; label: string; description: string; href: string } + | { kind: "docs"; query: string; href: string }; + +export const MAX_MODULE_HITS = 8; + +export function docsSearchHref(dmsHref: string, query: string): string { + return `${dmsHref.replace(/\/$/, "")}/search?q=${encodeURIComponent(query)}`; +} + +// Reihenfolge ohne Eingabe: Favoriten, zuletzt benutzt, Rest alphabetisch; mit Eingabe gilt dieselbe Rangfolge unter den Treffern. +export function searchCommands( + query: string, + modules: ModuleEntry[], + favorites: string[], + recent: string[], + resolveHref: (href: string) => string, + dmsHref?: string +): CommandItem[] { + const q = query.trim().toLowerCase(); + const rank = (key: string) => { + const f = favorites.indexOf(key); + if (f >= 0) return f; + const r = recent.indexOf(key); + if (r >= 0) return 100 + r; + return 1000; + }; + const hits = modules + .filter((m) => !q || m.label.toLowerCase().includes(q) || m.description.toLowerCase().includes(q)) + .sort((a, b) => rank(a.key) - rank(b.key) || a.label.localeCompare(b.label, "de")) + .slice(0, MAX_MODULE_HITS) + .map((m) => ({ kind: "module", key: m.key, label: m.label, description: m.description, href: resolveHref(m.href) })); + if (q && dmsHref) hits.push({ kind: "docs", query: query.trim(), href: docsSearchHref(dmsHref, query.trim()) }); + return hits; +}