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
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:
commit
6d3a4f8e56
22 files changed
+1191
-101
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@nexarch/shl";
|
||||
|
||||
export function PageTitle() {
|
||||
const { t } = useI18n();
|
||||
return <h1>{t("portal.title", "Übersicht")}</h1>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user