Merge pull request 'Portal: moderne Oberflaeche - Kacheln mit Icons, Favoriten, Befehlsleiste, Zustaende' (#409) from feat/portal-ui-1 into main
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 38s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 14s
CI / Web (dms-browser) (push) Failing after 51s
CI / Web (pdf-viewer) (push) Failing after 38s
CI / Web (shl) (push) Failing after 8s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s

This commit was merged in pull request #409.
This commit is contained in:
patrick committed 2026-10-28 14:52:56 +01:00
commit 6d3a4f8e56
22 files changed
+1191 -101

No files matched your search

+8
View File
@@ -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 (`<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 `<DMS-Adresse>/search?q=` weiter und erscheint nur, wenn das DMS für den Benutzer sichtbar ist.
+33 -16
View File
@@ -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<T>(path: string): Promise<T | null> {
@@ -14,8 +15,8 @@ async function getJson<T>(path: string): Promise<T | null> {
}
}
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 (
<section aria-labelledby="portal-admin" style={{ marginTop: 24 }}>
<h2 id="portal-admin" style={{ fontSize: 18 }}>{t("portal.admin", "Betrieb")}</h2>
{status && (
<ul style={{ listStyle: "none", margin: "0 0 16px", padding: 0 }}>
{status.map((m) => (
<li key={m.name}>
<span aria-hidden="true">{m.status === "up" ? "●" : "○"}</span> {m.name}: {statusLabel(m.status)}
</li>
))}
</ul>
<section className="np-section" aria-labelledby="portal-admin">
<h2 id="portal-admin">{t("portal.admin", "Betrieb")}</h2>
{status ? (
<>
<ul className="np-status-list">
{status.map((m) => {
const k = statusKey(m.status);
return (
<li key={m.name}>
<span className="np-dot" data-status={k} aria-hidden="true" />
{m.name}: {t(`portal.status.${k}`, fallback[k])}
</li>
);
})}
</ul>
{updated && (
<p className="np-tile-desc">
{t("portal.admin.updated", "Stand")}: {new Date(updated).toLocaleTimeString()}
</p>
)}
</>
) : (
<p className="np-tile-desc">{t("portal.admin.statusUnavailable", "Status derzeit nicht abrufbar")}</p>
)}
{kpi && (
<p>
{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
</p>
<ul className="np-grid np-stats">
<li><StatTile value={kpi.objektBestand} label={t("portal.kpi.objects", "Objekte")} /></li>
<li><StatTile value={kpi.loeschvorgaenge} label={t("portal.kpi.deletions", "Löschvorgänge")} /></li>
<li><StatTile value={`${(kpi.speichervolumenBytes / 1024 ** 3).toFixed(2)} GB`} label={t("portal.kpi.storage", "Speicher")} /></li>
</ul>
)}
</section>
);
+87
View File
@@ -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 (
<I18nProvider initialLocale="de">
<button type="button" onClick={() => setOpen(true)}>Auf</button>
<CommandPalette
open={open}
onClose={() => setOpen(false)}
modules={mods}
favorites={[]}
recent={[]}
resolveHref={(h) => h}
dmsHref={withDms ? "http://x/dms" : undefined}
onNavigate={navigate}
/>
</I18nProvider>
);
}
beforeEach(() => navigate.mockReset());
async function openPalette() {
const user = userEvent.setup();
render(<Host />);
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(<Host withDms={false} />);
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();
});
});
+96
View File
@@ -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<HTMLInputElement>(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<HTMLInputElement>) {
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 (
<Dialog open={open} onClose={onClose} titleId={`${uid}-title`} title={t("portal.palette.title", "Befehlsleiste")}>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={items.length > 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"
/>
<ul id={listId} role="listbox" aria-label={t("portal.palette.results", "Treffer")} className="np-palette-list">
{items.map((item, i) => (
<li
key={item.kind === "module" ? item.key : "docs"}
id={optionId(i)}
role="option"
aria-selected={i === active}
className="np-palette-option"
onMouseEnter={() => setActive(i)}
onClick={() => onNavigate(item)}
>
<span>{label(item)}</span>
{item.kind === "module" && <span className="np-tile-desc">{item.description}</span>}
</li>
))}
</ul>
{items.length === 0 && <p className="np-tile-desc" role="status">{t("portal.palette.none", "Kein Modul gefunden")}</p>}
</Dialog>
);
}
+189
View File
@@ -0,0 +1,189 @@
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<string>;
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(<I18nProvider initialLocale={locale}><ModuleGrid /></I18nProvider>);
}
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();
});
});
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"]');
});
});
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");
});
});
+205 -82
View File
@@ -1,10 +1,15 @@
"use client";
import { 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 { CommandPalette } from "./CommandPalette";
import type { CommandItem } from "../lib/commandSearch";
import { pushRecent, readCollapsed, readFavorites, readRecent, toggleCollapsed, toggleFavorite } from "../lib/favorites";
interface VisibleModulesResponse {
modules?: ModuleEntry[];
@@ -51,121 +56,239 @@ 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<T extends { key: string; modules: unknown[] }>(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" }
| { 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<State>({ 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 <p>{t("portal.loading", "Lade Übersicht …")}</p>;
const retry = useCallback(() => setAttempt((n) => n + 1), []);
if (state.status === "loading") return <Skeleton label={t("portal.loading", "Übersicht wird geladen")} />;
if (state.status === "noIdentity") return <p role="alert">{t("portal.noIdentity", "Anmeldung erforderlich.")}</p>;
if (state.status === "error") return <p role="alert">{state.message}</p>;
if (state.status === "error") {
return (
<div className="np-error" role="alert">
<p style={{ margin: "0 0 12px" }}>{t("portal.error", "Die Übersicht konnte nicht geladen werden.")}</p>
<button type="button" className="np-button" onClick={retry}>{t("portal.retry", "Erneut laden")}</button>
</div>
);
}
if (state.modules.length === 0) {
return <p>{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}</p>;
return (
<div>
<p>{t("portal.noModules", "Für Ihr Konto sind derzeit keine Module freigeschaltet.")}</p>
<p>{t("portal.noModules.hint", "Berechtigungen vergibt Ihre Administration.")}</p>
</div>
);
}
const { modules, worklist } = 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);
return <Overview modules={state.modules} worklist={state.worklist} who={state.who} />;
}
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<string[]>([]);
const [recent, setRecent] = useState<string[]>([]);
const [collapsed, setCollapsed] = useState<string[]>([]);
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));
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" },
{ 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 = 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 (
<li key={mod.key} className="np-tile-wrap" data-density={compact ? "compact" : undefined}>
<ModuleTile
moduleKey={mod.key}
label={mod.label}
description={mod.description}
href={resolveModuleHref(mod.href)}
badgeText={badge !== null && badge > 0 ? `${badge} ${t("portal.badge.open", "offen")}` : undefined}
onOpen={() => setRecent(pushRecent(mod.key, keys))}
/>
<button
type="button"
className="np-star"
aria-pressed={isFav}
aria-label={`${mod.label} ${isFav ? t("portal.favorite.remove", "als Favorit entfernen") : t("portal.favorite.add", "als Favorit markieren")}`}
onClick={() => onStar(mod)}
>
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false" fill={isFav ? "currentColor" : "none"} stroke="currentColor" strokeWidth="1.75" strokeLinejoin="round">
<path d="m12 3.5 2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.8 6.8 19.6l1-5.8L3.5 9.7l5.9-.9z" />
</svg>
</button>
</li>
);
};
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 (
<div>
{dms && (
<form action={`${resolveModuleHref(dms.href).replace(/\/$/, "")}/search`} method="get" role="search" style={{ marginBottom: 24 }}>
<input
name="q"
type="search"
aria-label={t("portal.search", "Dokumente durchsuchen")}
placeholder={t("portal.search", "Dokumente durchsuchen")}
style={{ width: "100%", boxSizing: "border-box", minHeight: 44, padding: "0 12px" }}
/>
</form>
)}
{today.length > 0 && (
<section aria-label={t("portal.today", "Heute")} style={{ display: "flex", gap: 16, flexWrap: "wrap", marginBottom: 24 }}>
{today.map((x) => (
<div key={x.label} style={{ padding: 12, border: "1px solid var(--shl-color-border, #d7dbe0)", borderRadius: 8, minWidth: 160 }}>
<strong style={{ fontSize: 24 }}>{x.value}</strong>
<div>{x.label}</div>
</div>
))}
</section>
)}
{groupModules(modules).map((g) => (
<section key={g.key} aria-labelledby={`grp-${g.key}`} style={{ marginBottom: 24 }}>
<h2 id={`grp-${g.key}`} style={{ fontSize: 18 }}>{t(`portal.group.${g.key}`, g.title)}</h2>
<ul
style={{
listStyle: "none",
margin: 0,
padding: 0,
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(240px, 1fr))",
gap: 16,
}}
>
{g.modules.map((mod) => {
const badge = badgeFor(mod.key, worklist);
return (
<li key={mod.key}>
<a
href={resolveModuleHref(mod.href)}
style={{
display: "block",
minHeight: 44,
padding: 16,
border: "1px solid var(--shl-color-border, #d7dbe0)",
borderRadius: 8,
textDecoration: "none",
color: "inherit",
}}
>
<strong>{mod.label}</strong>
{badge !== null && badge > 0 && (
<span aria-label={`${badge}`} style={{ marginLeft: 8, padding: "0 8px", borderRadius: 12, border: "1px solid currentColor" }}>
{badge}
</span>
)}
<p style={{ margin: "4px 0 0" }}>{mod.description}</p>
</a>
</li>
);
})}
<p className="np-greeting">
{t("portal.greeting", "Angemeldet als")} {who.userId} · {t("shl.auth.tenant", "Mandant")}: {who.tenant}
</p>
<button type="button" className="np-search" onClick={() => setPaletteOpen(true)} aria-haspopup="dialog">
<span>{t("portal.palette.open", "Modul oder Dokument suchen")}</span>
<kbd>Strg K</kbd>
</button>
<CommandPalette
open={paletteOpen}
onClose={() => 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 && (
<section className="np-section" aria-labelledby="np-today">
<h2 id="np-today">{t("portal.today", "Heute")}</h2>
<ul className="np-grid np-stats">
{stats.map((x) => (
<li key={x.key}>
<StatTile
value={x.value ?? "–"}
label={x.value === null || x.value === undefined ? `${x.label} (${t("portal.unavailable", "derzeit nicht verfügbar")})` : x.label}
href={x.value === null || x.value === undefined ? undefined : hrefOf(x.to)}
/>
</li>
))}
</ul>
</section>
))}
)}
{favMods.length > 0 && (
<section className="np-section" aria-labelledby="np-fav">
<h2 id="np-fav">{t("portal.favorites", "Favoriten")}</h2>
<ul className="np-grid">{favMods.map((m) => renderTile(m, true))}</ul>
</section>
)}
{recentMods.length > 0 && (
<section className="np-section" aria-labelledby="np-recent">
<h2 id="np-recent">{t("portal.recent", "Zuletzt benutzt")}</h2>
<ul className="np-chips">
{recentMods.map((m) => (
<li key={m.key}>
<a className="np-chip" href={resolveModuleHref(m.href)} onClick={() => setRecent(pushRecent(m.key, keys))}>{m.label}</a>
</li>
))}
</ul>
</section>
)}
{orderGroups(groupModules(modules)).map((g) => {
const isCollapsed = collapsed.includes(g.key);
return (
<section key={g.key} className="np-section" aria-labelledby={`grp-${g.key}`}>
<h2 id={`grp-${g.key}`}>
<button
type="button"
className="np-group-toggle"
aria-expanded={!isCollapsed}
aria-controls={`list-${g.key}`}
onClick={() => setCollapsed(toggleCollapsed(g.key))}
>
<span aria-hidden="true">{isCollapsed ? "▸" : "▾"}</span> {t(`portal.group.${g.key}`, g.title)}
</button>
</h2>
<ul id={`list-${g.key}`} className="np-grid" hidden={isCollapsed}>
{g.modules.map((m) => renderTile(m))}
</ul>
</section>
);
})}
<p role="status" aria-live="polite" className="shl-visually-hidden">{live}</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { useI18n } from "@nexarch/shl";
export function PageTitle() {
const { t } = useI18n();
return <h1>{t("portal.title", "Übersicht")}</h1>;
}
+13
View File
@@ -0,0 +1,13 @@
export function Skeleton({ label }: { label: string }) {
return (
<div>
<p role="status" className="shl-visually-hidden">{label}</p>
<ul className="np-grid np-stats" aria-hidden="true" style={{ marginBottom: 24 }}>
{[0, 1, 2].map((i) => <li key={i} className="np-skeleton np-skeleton-stat" />)}
</ul>
<ul className="np-grid" aria-hidden="true">
{[0, 1, 2, 3, 4, 5].map((i) => <li key={i} className="np-skeleton" />)}
</ul>
</div>
);
}
+53
View File
@@ -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<HTMLAnchorElement>;
}
export function ModuleTile({ moduleKey, label, description, href, badgeText, status, onOpen }: ModuleTileProps) {
return (
<a className="np-tile" href={href} onClick={onOpen}>
<span className="np-tile-icon">{moduleIcon(moduleKey)}</span>
<span className="np-tile-body">
<span className="np-tile-title">
{label}
{badgeText && <span className="np-badge">{badgeText}</span>}
</span>
<span className="np-tile-desc" style={{ display: "block" }}>{description}</span>
{status && (
<span className="np-tile-desc" style={{ display: "block" }}>
<span className="np-dot" data-status={status.state} aria-hidden="true" />
{status.text}
</span>
)}
</span>
</a>
);
}
export interface StatTileProps {
value: ReactNode;
label: string;
href?: string;
}
export function StatTile({ value, label, href }: StatTileProps) {
const inner = (
<>
<span className="np-stat-value">{value}</span>
<span className="np-stat-label">{label}</span>
</>
);
return href ? (
<a className="np-tile np-stat" href={href}>{inner}</a>
) : (
<div className="np-tile np-stat">{inner}</div>
);
}
+2
View File
@@ -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";
+22
View File
@@ -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<string>();
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();
}
});
});
+91
View File
@@ -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",
});
+31
View File
@@ -0,0 +1,31 @@
import type { ReactNode } from "react";
const svg = (children: ReactNode) => (
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" focusable="false">
{children}
</svg>
);
const doc = svg(<><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" /><path d="M14 3v5h5M9 13h6M9 17h6" /></>);
export const MODULE_ICONS: Record<string, ReactNode> = {
dms: doc,
dms_upload: svg(<><path d="M12 16V4M7 9l5-5 5 5" /><path d="M4 16v3a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-3" /></>),
mail: svg(<><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" /></>),
workflow: svg(<><circle cx="6" cy="6" r="2.5" /><circle cx="18" cy="12" r="2.5" /><circle cx="6" cy="18" r="2.5" /><path d="M8.5 6H12a3 3 0 0 1 3 3v.5M8.5 18H12a3 3 0 0 0 3-3v-.5" /></>),
workflow_cases: svg(<><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" /></>),
routing_rules: svg(<><path d="M4 6h6l4 6h6M4 18h6l4-6" /><path d="m17 9 3 3-3 3" /></>),
ai: svg(<><path d="m12 3 1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z" /><path d="M19 15l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z" /></>),
ai_extraction: svg(<><path d="M5 4h14v6H5zM5 14h6v6H5z" /><path d="m14 17 2 2 4-4" /></>),
connect: svg(<><path d="M9 7H7a4 4 0 0 0 0 8h2M15 7h2a4 4 0 0 1 0 8h-2M8 11h8" /></>),
connect_datev: svg(<><ellipse cx="12" cy="6" rx="7" ry="3" /><path d="M5 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6M5 12v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" /></>),
tenant_admin: svg(<><rect x="4" y="3" width="10" height="18" rx="1" /><path d="M14 9h6v12h-6M8 7h2M8 11h2M8 15h2" /></>),
rbac_admin: svg(<><path d="M12 3 5 6v5c0 4.5 3 8 7 10 4-2 7-5.5 7-10V6z" /><path d="m9.5 12 2 2 3.5-4" /></>),
kennzahlen_dashboard: svg(<><path d="M4 20V10M10 20V4M16 20v-8M22 20H2" /></>),
};
export const FALLBACK_ICON = svg(<rect x="4" y="4" width="16" height="16" rx="3" />);
export function moduleIcon(key: string): ReactNode {
return MODULE_ICONS[key] ?? FALLBACK_ICON;
}
+3 -2
View File
@@ -1,10 +1,11 @@
import { AdminPanel } from "./AdminPanel";
import { ModuleGrid } from "./ModuleGrid";
import { PageTitle } from "./PageTitle";
export default function Page() {
return (
<div style={{ maxWidth: 960, margin: "0 auto", padding: 24 }}>
<h1>NEXARCH Portal</h1>
<div className="np-page">
<PageTitle />
<ModuleGrid />
<AdminPanel />
</div>
+103
View File
@@ -0,0 +1,103 @@
.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; }
}
.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; }
.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; }
+28
View File
@@ -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");
});
});
+37
View File
@@ -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<CommandItem>((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;
}
+64
View File
@@ -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([]);
});
});
+59
View File
@@ -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;
}
+18
View File
@@ -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");
});
});
+1 -1
View File
@@ -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";
+40
View File
@@ -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<ColorScheme, ColorTokens> = {
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<ColorScheme, ColorTokens> = {
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<ColorScheme, { sm: string; md: string }> = {
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<string, string> {
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;
}