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); }