From b112647ff1e031ed59806eeba383ede552dfc7df Mon Sep 17 00:00:00 2001 From: Kundencenter Date: Mon, 28 Sep 2026 11:43:50 +0200 Subject: [PATCH] feat(web): Designsystem v3 "KeyControl" + gruppierte Navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Löst das bisherige "Calm Cloud"-Design nach Nutzerfeedback ab: dunkle Navy-Sidebar (#173568) als dauerhaft sichtbare, vertrauenswürdige Navigation, klares Blau (#3B6FD8) als Primärfarbe, hellgrauer Arbeitsbereich (#F4F6FA). Sidebar breiter mit mehr Abstand, größere Schriftgrößen für kleine/gedämpfte Texte, größere max. Inhaltsbreite. Navigation in aufklappbare Gruppen sortiert (Produkte / Abrechnung / Support / Verwaltung) statt einer langen flachen Liste. Kopfbereich zeigt bei Kunden zusätzlich den Organisationsnamen. Ressourcenübersicht zeigt Kunden ihre Produkte jetzt als Kartenraster statt als spärliche Tabelle (Personal behält die tabellarische Ansicht für die höhere Datendichte). Co-Authored-By: Claude Sonnet 5 --- apps/web/src/app/(app)/layout.tsx | 32 +++++++------- apps/web/src/app/(app)/ressourcen/page.tsx | 17 +++++++- apps/web/src/app/globals.css | 49 +++++++++++++++------- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 8d2f9a3..216ab14 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -15,32 +15,30 @@ export default function AppLayout({ children }: { children: ReactNode }) { useEffect(() => setOpen(false), [path]); if (loading || !me || me.pendingMfa) return

Wird geladen …

; const link = (href: string, label: string) => {label}; + const inGroup = (hrefs: string[]) => hrefs.some((h) => path === h || path.startsWith(h + '/')); + const group = (title: string, hrefs: string[], items: ReactNode) => ( +
{title}
{items}
+ ); async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } + const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][]; + const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][]; const nav = ( ); diff --git a/apps/web/src/app/(app)/ressourcen/page.tsx b/apps/web/src/app/(app)/ressourcen/page.tsx index 0bf37e7..7c4d58a 100644 --- a/apps/web/src/app/(app)/ressourcen/page.tsx +++ b/apps/web/src/app/(app)/ressourcen/page.tsx @@ -46,7 +46,7 @@ export default function Ressourcen() { useEffect(() => { if (canAssign) api('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [canAssign]); const stale = list?.find((r) => r.stale); return (<> -

Ressourcen

+

Ressourcen

{err && {err}} {ok && {ok}} {stale && Einige Angaben sind möglicherweise nicht aktuell: {stale.staleReason} Angezeigt wird der zuletzt bekannte Stand.} @@ -55,9 +55,22 @@ export default function Ressourcen() { GROUPS.map((g) => { const rows = list.filter((r) => g.types.includes(r.type)); if (rows.length === 0) return null; const lic = g.key === 'license'; return (

{g.title} ({rows.length})

+ {staff ? (
{!lic && }{lic && } - {rows.map((r) => {!lic && } + {rows.map((r) => {!lic && }{lic && })}
NameTypStatusGültig bisStandLizenzschlüssel
{r.name}{staff && !r.orgId && <> Nicht zugewiesen}{canAssign && !r.orgId &&
{ setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} />
}
{TYPE[r.type] ?? r.type}
{r.name}{!r.orgId && <> Nicht zugewiesen}{canAssign && !r.orgId &&
{ setCusts((l) => [...l, c]); setErr(''); setOk(`Kunde ${cr.customerNumber} angelegt. ` + (cr.mail === 'sent' ? 'Die Einladung wurde per E-Mail versendet.' : cr.inviteLink ? `E-Mail-Versand ist nicht eingerichtet. Einladungslink (nur jetzt sichtbar): ${cr.inviteLink}` : '') + ' Jetzt noch „Zuweisen“ klicken.'); }} onDone={() => { setErr(''); setOk(`„${r.name}“ wurde zugewiesen. Kunden dürfen daran zunächst keine Aktionen ausführen (im Produkt bzw. an der Ressource freigebbar).`); void reload(); }} />
}
{TYPE[r.type] ?? r.type} {r.validUntil ? fmt(r.validUntil) : 'unbefristet'}{fmt(r.syncedAt)}{r.stale && <> Veraltet}{r.canReveal ? : –}
+ ) : ( +
{rows.map((r) => ( +
+

{r.name}

+ {!lic &&
{TYPE[r.type] ?? r.type}
} +
Gültig bis: {r.validUntil ? fmt(r.validUntil) : 'unbefristet'}
+
Stand: {fmt(r.syncedAt)}{r.stale && <> Veraltet}
+ {lic && r.canReveal &&
} +
Details ansehen
+
+ ))}
+ )}
); })} ); } diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 5a3d1e9..6b5e02a 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -1,10 +1,11 @@ -/* Designsystem v2 "Calm Cloud": warmgraue/GitHub-dunkle Flächen, Dunkelblau als vertrauenswürdige Hauptfarbe, - Türkis nur für aktive Navigation/technische Marker. Hell/Dunkel automatisch über prefers-color-scheme, - zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */ +/* Designsystem v3 "KeyControl": dunkles Navy als vertrauenswürdige, dauerhaft sichtbare Seitenleiste, + klares Blau als Primärfarbe, helle Arbeitsfläche mit klar getrennten Bereichen. Hell/Dunkel automatisch + über prefers-color-scheme, zusätzlich manuell über [data-theme] umschaltbar (siehe ThemeToggle). */ :root { - --bg: #f6f8fa; --surface: #ffffff; --surface-2: #eef1f5; --border: #e1e5eb; --text: #172033; --muted: #5b6472; - --accent: #315efb; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #315efb; + --bg: #f4f6fa; --surface: #ffffff; --surface-2: #eef1f6; --border: #e3e8f0; --text: #172033; --muted: #5b6472; + --accent: #3b6fd8; --accent2: #14b8a6; --accent-contrast: #ffffff; --focus: #3b6fd8; --ok: #22a06b; --ok-bg: #e4f5ec; --info: #2e6fe0; --info-bg: #e7f1fc; --warn: #e59a17; --warn-bg: #fdf1dc; --err: #dc4c64; --err-bg: #fbe7ea; --lock: #4b5563; --lock-bg: #eceef2; + --nav-bg: #173568; --nav-text: #dbe4f7; --nav-muted: #8fa4d4; --nav-border: #24417f; --nav-hover: #1f4084; --nav-active-bg: #2a4d93; --nav-active-text: #ffffff; --shadow-sm: 0 1px 2px rgba(23, 32, 51, .04), 0 1px 1px rgba(23, 32, 51, .04); --shadow-md: 0 8px 20px rgba(23, 32, 51, .06), 0 2px 6px rgba(23, 32, 51, .04); --radius: 12px; --radius-sm: 8px; --s1: 4px; --s2: 8px; --s3: 16px; --s4: 24px; --s5: 40px; @@ -13,11 +14,13 @@ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff; --ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932; + --nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff; --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35); } } :root[data-theme='dark'] { --bg: #0d1117; --surface: #161b22; --surface-2: #1c2129; --border: #262c36; --text: #e6edf3; --muted: #8b98a5; --accent: #6e8dff; --accent2: #2dd4bf; --accent-contrast: #0d1117; --focus: #6e8dff; --ok: #34d399; --ok-bg: #10281d; --info: #8ab4f8; --info-bg: #16283d; --warn: #f5b84d; --warn-bg: #3a2b0c; --err: #f1798d; --err-bg: #3b1620; --lock: #b7c0cc; --lock-bg: #232932; + --nav-bg: #0a1220; --nav-text: #cdd9f0; --nav-muted: #7c8bb0; --nav-border: #1c2740; --nav-hover: #131f36; --nav-active-bg: #1d3358; --nav-active-text: #ffffff; --shadow-sm: 0 1px 2px rgba(0, 0, 0, .45); --shadow-md: 0 10px 24px rgba(0, 0, 0, .5), 0 2px 8px rgba(0, 0, 0, .35); } * { box-sizing: border-box; } @@ -25,22 +28,30 @@ html { color-scheme: light dark; } body { margin: 0; font: 16px/1.55 var(--font); background: var(--bg); color: var(--text); } body, .btn, .badge, .card, input, select, a, .alert { transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease; } h1 { font-size: 1.7rem; font-weight: 700; margin: 0 0 var(--s3); letter-spacing: -0.015em; } h2 { font-size: 1.15rem; font-weight: 600; margin: 0 0 var(--s3); } h3 { font-size: 1rem; font-weight: 600; margin: 0 0 var(--s2); } -a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .875rem; } +a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--muted); } .small { font-size: .9rem; } :focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 6px; } .skip { position: absolute; left: -999px; } .skip:focus { left: var(--s3); top: var(--s3); background: var(--surface); padding: var(--s2) var(--s3); z-index: 10; } -.shell { display: grid; grid-template-columns: 260px 1fr; min-height: 100vh; } -.nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--s4) var(--s3); display: flex; flex-direction: column; gap: 2px; } -.nav .brand { font-weight: 700; font-size: 1.1rem; letter-spacing: -0.01em; padding: 0 var(--s3) var(--s4); } -.nav a { display: block; padding: 10px var(--s3); border-radius: var(--radius-sm); color: var(--text); text-decoration: none; } -.nav a:hover { background: var(--surface-2); } -.nav a[aria-current='page'] { background: color-mix(in srgb, var(--accent2) 14%, transparent); color: var(--accent2); font-weight: 600; } -.nav .group { margin: var(--s3) var(--s3) var(--s1); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); } +.shell { display: grid; grid-template-columns: 288px 1fr; min-height: 100vh; } +.nav { background: var(--nav-bg); border-right: 1px solid var(--nav-border); padding: var(--s4) var(--s3) var(--s3); display: flex; flex-direction: column; gap: 2px; } +.nav .brand { font-weight: 700; font-size: 1.2rem; letter-spacing: -0.01em; color: #fff; padding: 0 var(--s2) var(--s4); } +.nav a { display: block; padding: 11px var(--s3); border-radius: var(--radius-sm); color: var(--nav-text); text-decoration: none; font-size: .96rem; } +.nav a:hover { background: var(--nav-hover); color: #fff; } +.nav a[aria-current='page'] { background: var(--nav-active-bg); color: var(--nav-active-text); font-weight: 600; } +.nav .group { margin: 0; } +.nav .group > summary { list-style: none; cursor: pointer; margin: var(--s2) var(--s1) 2px; padding: 8px var(--s2); font-size: .74rem; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--nav-muted); display: flex; align-items: center; justify-content: space-between; border-radius: var(--radius-sm); } +.nav .group > summary::-webkit-details-marker { display: none; } +.nav .group > summary:hover { color: #fff; background: var(--nav-hover); } +.nav .group > summary::after { content: '▸'; font-size: .7rem; transition: transform .15s ease; } +.nav .group[open] > summary::after { transform: rotate(90deg); } +.nav .group .items { display: flex; flex-direction: column; gap: 2px; padding-bottom: var(--s2); } .nav .spacer { flex: 1; } -.main { padding: var(--s5) var(--s4); max-width: 1100px; width: 100%; } +.nav .who { padding: var(--s2); color: var(--nav-text); } +.nav .who .org { color: #fff; font-weight: 600; } +.main { padding: var(--s5) var(--s4); max-width: 1240px; width: 100%; } .topbar { display: none; } .bottomnav { display: none; } @media (max-width: 800px) { - .shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 30% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; } + .shell { grid-template-columns: 1fr; } .nav { display: none; } .nav.open { display: flex; position: fixed; inset: 0 20% 56px 0; z-index: 5; box-shadow: 0 0 0 100vmax rgba(0,0,0,.4); overflow: auto; } .topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--s2); padding: var(--s3); background: var(--surface); border-bottom: 1px solid var(--border); } .main { padding: var(--s4) var(--s3) calc(var(--s4) + 56px); } .bottomnav { display: flex; position: fixed; left: 0; right: 0; bottom: 0; z-index: 6; background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-md); padding-bottom: env(safe-area-inset-bottom, 0); } @@ -61,7 +72,7 @@ a { color: var(--accent); } p { margin: 0 0 var(--s3); } .muted { color: var(--m input, select { font: inherit; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text); min-height: 44px; width: 100%; } input:focus-visible, select:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent); outline: none; } .cols { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } -table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .74rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); } +table { width: 100%; border-collapse: collapse; } th, td { text-align: left; padding: 12px var(--s3); border-bottom: 1px solid var(--border); vertical-align: top; } th { font-size: .78rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); background: var(--surface-2); } tbody tr:hover { background: var(--surface-2); } .tablewrap { overflow-x: auto; border-radius: var(--radius-sm); border: 1px solid var(--border); } .tablewrap table { border: none; } .tablewrap th:first-child, .tablewrap td:first-child { padding-left: var(--s3); } @@ -69,6 +80,12 @@ tbody tr:hover { background: var(--surface-2); } .badge.ok { background: var(--ok-bg); color: var(--ok); } .badge.info { background: var(--info-bg); color: var(--info); } .badge.warn { background: var(--warn-bg); color: var(--warn); } .badge.err { background: var(--err-bg); color: var(--err); } .badge.lock { background: var(--lock-bg); color: var(--lock); } .alert { border-radius: var(--radius-sm); padding: var(--s3); margin-bottom: var(--s3); border: 1px solid transparent; } .alert.err { background: var(--err-bg); color: var(--err); } .alert.ok { background: var(--ok-bg); color: var(--ok); } .alert.warn { background: var(--warn-bg); color: var(--warn); } .alert.info { background: var(--info-bg); color: var(--info); } .empty { text-align: center; padding: var(--s5) var(--s3); color: var(--muted); } +.pagehead { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--s3); flex-wrap: wrap; margin-bottom: var(--s4); } +.pagehead h1 { margin: 0; } +.productgrid { display: grid; gap: var(--s3); grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); } +.productcard { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: var(--s4); box-shadow: var(--shadow-sm); display: flex; flex-direction: column; gap: 6px; } +.productcard h3 { margin: 0; font-size: 1.05rem; } +.productcard .id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .82rem; color: var(--muted); } .center { min-height: 100vh; display: grid; place-items: center; padding: var(--s3); } .auth { width: 100%; max-width: 420px; } code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; word-break: break-all; } .codes { columns: 2; background: var(--surface-2); padding: var(--s3); border-radius: var(--radius-sm); } .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }