feat(bereiche): Bereiche verwalten mit Rollenprüfung und Audit
Bereiche mit Farbe, Arbeitsfenster und Tageskapazität – die erste schreibende Funktion und damit der Beleg, dass die Kette trägt: zod prüft jede Eingabe, die Rolle wird in der Seite und noch einmal in der Server Action geprüft, jede Änderung hinterlässt einen Audit-Eintrag. Die Audit-Details werden vor dem Speichern gesäubert: Felder, deren Name nach Passwort, Token, Betreff oder Mailinhalt aussieht, werden ersetzt. Das gilt auch in verschachtelten Objekten und Listen. Dazu die Protokollseite für die Verwaltung und die Startseite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
cc013fd6a1
commit
e9a2b4b463
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { KAPAZITAET_FELDER, WOCHENTAGE } from '@/lib/bereich'
|
||||
import {
|
||||
bereichAendern,
|
||||
bereichAnlegen,
|
||||
bereichLoeschen,
|
||||
type Rueckmeldung,
|
||||
} from '@/server/aktionen/bereiche'
|
||||
|
||||
type Werte = {
|
||||
id: string
|
||||
name: string
|
||||
farbe: string
|
||||
reihenfolge: number
|
||||
aktiv: boolean
|
||||
arbeitsfensterVon: string
|
||||
arbeitsfensterBis: string
|
||||
} & Record<(typeof KAPAZITAET_FELDER)[number], number>
|
||||
|
||||
const LEER = {
|
||||
name: '',
|
||||
farbe: '#2563eb',
|
||||
reihenfolge: 0,
|
||||
aktiv: true,
|
||||
arbeitsfensterVon: '08:00',
|
||||
arbeitsfensterBis: '17:00',
|
||||
kapazitaetMo: 0,
|
||||
kapazitaetDi: 0,
|
||||
kapazitaetMi: 0,
|
||||
kapazitaetDo: 0,
|
||||
kapazitaetFr: 0,
|
||||
kapazitaetSa: 0,
|
||||
kapazitaetSo: 0,
|
||||
} satisfies Omit<Werte, 'id'>
|
||||
|
||||
export function BereichFormular({ bereich }: { bereich?: Werte }) {
|
||||
const [laeuft, starte] = useTransition()
|
||||
const [felder, setFelder] = useState<Record<string, string>>({})
|
||||
const [offen, setOffen] = useState(!bereich)
|
||||
const werte = bereich ?? LEER
|
||||
|
||||
function verarbeite(antwort: Rueckmeldung, formular: HTMLFormElement) {
|
||||
if (antwort.ok) {
|
||||
setFelder({})
|
||||
toast.success(antwort.meldung)
|
||||
if (!bereich) formular.reset()
|
||||
return
|
||||
}
|
||||
setFelder(antwort.felder ?? {})
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
|
||||
function absenden(ereignis: React.FormEvent<HTMLFormElement>) {
|
||||
ereignis.preventDefault()
|
||||
const formular = ereignis.currentTarget
|
||||
const daten = new FormData(formular)
|
||||
starte(async () => {
|
||||
const antwort = bereich
|
||||
? await bereichAendern(bereich.id, daten)
|
||||
: await bereichAnlegen(daten)
|
||||
verarbeite(antwort, formular)
|
||||
})
|
||||
}
|
||||
|
||||
function loeschen() {
|
||||
if (!bereich) return
|
||||
const daten = new FormData()
|
||||
daten.set('id', bereich.id)
|
||||
starte(async () => {
|
||||
const antwort = await bereichLoeschen(daten)
|
||||
if (antwort.ok) toast.success(antwort.meldung)
|
||||
else toast.error(antwort.fehler)
|
||||
})
|
||||
}
|
||||
|
||||
if (bereich && !offen) {
|
||||
return (
|
||||
<Button art="umriss" groesse="klein" onClick={() => setOffen(true)}>
|
||||
Bearbeiten
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={absenden} className="flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`name-${werte.name}`}>Name</Label>
|
||||
<Input
|
||||
id={`name-${werte.name}`}
|
||||
name="name"
|
||||
defaultValue={werte.name}
|
||||
required
|
||||
aria-invalid={Boolean(felder.name)}
|
||||
aria-describedby={felder.name ? `name-fehler-${werte.name}` : undefined}
|
||||
/>
|
||||
{felder.name && (
|
||||
<p id={`name-fehler-${werte.name}`} className="text-gefahr text-xs">
|
||||
{felder.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`farbe-${werte.name}`}>Farbe</Label>
|
||||
<Input
|
||||
id={`farbe-${werte.name}`}
|
||||
name="farbe"
|
||||
type="color"
|
||||
defaultValue={werte.farbe}
|
||||
className="h-9 w-20 p-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`von-${werte.name}`}>Arbeitsfenster von</Label>
|
||||
<Input
|
||||
id={`von-${werte.name}`}
|
||||
name="arbeitsfensterVon"
|
||||
type="time"
|
||||
defaultValue={werte.arbeitsfensterVon}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={`bis-${werte.name}`}>bis</Label>
|
||||
<Input
|
||||
id={`bis-${werte.name}`}
|
||||
name="arbeitsfensterBis"
|
||||
type="time"
|
||||
defaultValue={werte.arbeitsfensterBis}
|
||||
required
|
||||
aria-invalid={Boolean(felder.arbeitsfensterBis)}
|
||||
/>
|
||||
{felder.arbeitsfensterBis && (
|
||||
<p className="text-gefahr text-xs">{felder.arbeitsfensterBis}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset className="flex flex-col gap-2">
|
||||
<legend className="mb-2 text-sm font-medium">Tageskapazität in Minuten</legend>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||
<div key={feld} className="flex flex-col gap-1">
|
||||
<Label htmlFor={`${feld}-${werte.name}`} className="text-xs font-normal">
|
||||
{WOCHENTAGE[i]}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${feld}-${werte.name}`}
|
||||
name={feld}
|
||||
type="number"
|
||||
min={0}
|
||||
max={1440}
|
||||
step={15}
|
||||
defaultValue={werte[feld]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{felder.kapazitaetMo && (
|
||||
<p className="text-gefahr text-xs">{felder.kapazitaetMo}</p>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id={`aktiv-${werte.name}`}
|
||||
name="aktiv"
|
||||
type="checkbox"
|
||||
defaultChecked={werte.aktiv}
|
||||
className="accent-betont size-4"
|
||||
/>
|
||||
<Label htmlFor={`aktiv-${werte.name}`} className="font-normal">
|
||||
In Benutzung
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={`reihenfolge-${werte.name}`} className="font-normal">
|
||||
Reihenfolge
|
||||
</Label>
|
||||
<Input
|
||||
id={`reihenfolge-${werte.name}`}
|
||||
name="reihenfolge"
|
||||
type="number"
|
||||
min={0}
|
||||
max={999}
|
||||
defaultValue={werte.reihenfolge}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="submit" disabled={laeuft}>
|
||||
{laeuft && <Loader2 className="animate-spin" aria-hidden />}
|
||||
{bereich ? 'Speichern' : 'Bereich anlegen'}
|
||||
</Button>
|
||||
|
||||
{bereich && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
art="leise"
|
||||
onClick={() => setOffen(false)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
art="gefahr"
|
||||
onClick={loeschen}
|
||||
disabled={laeuft}
|
||||
className="ml-auto"
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
Löschen
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { BereichFormular } from '@/app/(app)/bereiche/bereich-formular'
|
||||
import { Card, CardInhalt, CardKopf, CardTitel } from '@/components/ui/card'
|
||||
import { KAPAZITAET_FELDER, WOCHENTAGE, wochenKapazitaet } from '@/lib/bereich'
|
||||
import { istAdmin } from '@/lib/rollen'
|
||||
import { alsDauer, alsUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const metadata: Metadata = { title: 'Bereiche' }
|
||||
|
||||
export default async function BereicheSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/bereiche')
|
||||
const darfAendern = istAdmin(benutzer.rolle)
|
||||
|
||||
const bereiche = await db.bereich.findMany({
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Bereiche</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
Farbe, Arbeitsfenster und Tageskapazität je Lebensbereich. Die Kapazität
|
||||
begrenzt später, wie viel an einem Tag eingeplant werden kann.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{bereiche.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||
Noch keine Bereiche angelegt.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{bereiche.map((bereich) => (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-4 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="flex-1">{bereich.name}</CardTitel>
|
||||
{!bereich.aktiv && (
|
||||
<span className="bg-gedaempft text-gedaempft-vordergrund rounded-md px-2 py-0.5 text-xs">
|
||||
stillgelegt
|
||||
</span>
|
||||
)}
|
||||
</CardKopf>
|
||||
|
||||
<CardInhalt className="flex flex-col gap-4">
|
||||
<dl className="text-sm">
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-gedaempft-vordergrund">Arbeitsfenster:</dt>
|
||||
<dd>
|
||||
{alsUhrzeit(bereich.arbeitsfensterVon)} bis{' '}
|
||||
{alsUhrzeit(bereich.arbeitsfensterBis)} Uhr
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-gedaempft-vordergrund">Woche gesamt:</dt>
|
||||
<dd>{alsDauer(wochenKapazitaet(bereich))}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{KAPAZITAET_FELDER.map((feld, i) => (
|
||||
<li
|
||||
key={feld}
|
||||
className="bg-gedaempft rounded-md px-2 py-1 text-xs"
|
||||
title={WOCHENTAGE[i]}
|
||||
>
|
||||
<span className="text-gedaempft-vordergrund">
|
||||
{WOCHENTAGE[i]?.slice(0, 2)}
|
||||
</span>{' '}
|
||||
{alsDauer(bereich[feld])}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{darfAendern && (
|
||||
<BereichFormular
|
||||
bereich={{
|
||||
id: bereich.id,
|
||||
name: bereich.name,
|
||||
farbe: bereich.farbe,
|
||||
reihenfolge: bereich.reihenfolge,
|
||||
aktiv: bereich.aktiv,
|
||||
arbeitsfensterVon: alsUhrzeit(bereich.arbeitsfensterVon),
|
||||
arbeitsfensterBis: alsUhrzeit(bereich.arbeitsfensterBis),
|
||||
kapazitaetMo: bereich.kapazitaetMo,
|
||||
kapazitaetDi: bereich.kapazitaetDi,
|
||||
kapazitaetMi: bereich.kapazitaetMi,
|
||||
kapazitaetDo: bereich.kapazitaetDo,
|
||||
kapazitaetFr: bereich.kapazitaetFr,
|
||||
kapazitaetSa: bereich.kapazitaetSa,
|
||||
kapazitaetSo: bereich.kapazitaetSo,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{darfAendern && (
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel>Neuen Bereich anlegen</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<BereichFormular />
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { PasskeyVerwaltung } from '@/app/(app)/einstellungen/passkey-verwaltung'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
CardInhalt,
|
||||
CardKopf,
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
import { ROLLEN_NAME } from '@/lib/rollen'
|
||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const metadata: Metadata = { title: 'Einstellungen' }
|
||||
|
||||
export default async function EinstellungenSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/einstellungen')
|
||||
|
||||
const passkeys = await db.passkey.findMany({
|
||||
where: { benutzerId: benutzer.id },
|
||||
orderBy: { erstelltAm: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
bezeichnung: true,
|
||||
erstelltAm: true,
|
||||
zuletztBenutzt: true,
|
||||
gesichert: true,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Einstellungen</h1>
|
||||
</header>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Konto</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<dl className="grid gap-2 text-sm sm:grid-cols-[10rem_1fr]">
|
||||
<dt className="text-gedaempft-vordergrund">Name</dt>
|
||||
<dd>{benutzer.name ?? '—'}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">E-Mail</dt>
|
||||
<dd>{benutzer.email}</dd>
|
||||
<dt className="text-gedaempft-vordergrund">Rolle</dt>
|
||||
<dd>{ROLLEN_NAME[benutzer.rolle]}</dd>
|
||||
</dl>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Passkeys</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Mit einem Passkey meldest du dich per Fingerabdruck, Gesicht oder Geräte-PIN
|
||||
an – ohne Passwort. Hinterlege am besten zwei, damit du bei Verlust eines
|
||||
Geräts nicht ausgesperrt bist.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<PasskeyVerwaltung
|
||||
passkeys={passkeys.map((p) => ({
|
||||
id: p.id,
|
||||
bezeichnung: p.bezeichnung,
|
||||
erstelltAm: alsDatumUhrzeit(p.erstelltAm),
|
||||
zuletztBenutzt: p.zuletztBenutzt ? alsDatumUhrzeit(p.zuletztBenutzt) : null,
|
||||
gesichert: p.gesichert,
|
||||
}))}
|
||||
/>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { startRegistration } from '@simplewebauthn/browser'
|
||||
import { Fingerprint, Loader2, Trash2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { passkeyEntfernen } from '@/server/aktionen/passkeys'
|
||||
|
||||
export type PasskeyAnzeige = {
|
||||
id: string
|
||||
bezeichnung: string
|
||||
erstelltAm: string
|
||||
zuletztBenutzt: string | null
|
||||
gesichert: boolean
|
||||
}
|
||||
|
||||
export function PasskeyVerwaltung({ passkeys }: { passkeys: PasskeyAnzeige[] }) {
|
||||
const router = useRouter()
|
||||
const [bezeichnung, setBezeichnung] = useState('')
|
||||
const [legtAn, setLegtAn] = useState(false)
|
||||
const [laeuft, starte] = useTransition()
|
||||
|
||||
async function anlegen(ereignis: React.FormEvent) {
|
||||
ereignis.preventDefault()
|
||||
const name = bezeichnung.trim()
|
||||
if (!name) {
|
||||
toast.error('Bitte einen Namen für das Gerät angeben.')
|
||||
return
|
||||
}
|
||||
|
||||
setLegtAn(true)
|
||||
try {
|
||||
const antwortOptionen = await fetch('/api/passkey/registrierung')
|
||||
if (!antwortOptionen.ok) {
|
||||
toast.error('Die Anfrage wurde abgelehnt.')
|
||||
return
|
||||
}
|
||||
|
||||
const optionen = (await antwortOptionen.json()) as Parameters<
|
||||
typeof startRegistration
|
||||
>[0]['optionsJSON']
|
||||
|
||||
const antwort = await startRegistration({ optionsJSON: optionen })
|
||||
|
||||
const gespeichert = await fetch('/api/passkey/registrierung', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bezeichnung: name, antwort }),
|
||||
})
|
||||
|
||||
const ergebnis = (await gespeichert.json()) as { fehler?: string }
|
||||
if (!gespeichert.ok) {
|
||||
toast.error(ergebnis.fehler ?? 'Der Passkey konnte nicht gespeichert werden.')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success(`Passkey „${name}" angelegt.`)
|
||||
setBezeichnung('')
|
||||
router.refresh()
|
||||
} catch (fehler) {
|
||||
// Bricht der Benutzer den Systemdialog ab, ist das kein Fehler.
|
||||
if (fehler instanceof Error && fehler.name === 'NotAllowedError') return
|
||||
if (fehler instanceof Error && fehler.name === 'InvalidStateError') {
|
||||
toast.error('Dieses Gerät ist hier schon hinterlegt.')
|
||||
return
|
||||
}
|
||||
toast.error('Der Passkey konnte nicht angelegt werden.')
|
||||
} finally {
|
||||
setLegtAn(false)
|
||||
}
|
||||
}
|
||||
|
||||
function entfernen(id: string) {
|
||||
const daten = new FormData()
|
||||
daten.set('id', id)
|
||||
starte(async () => {
|
||||
const antwort = await passkeyEntfernen(daten)
|
||||
if (antwort.ok) {
|
||||
toast.success(antwort.meldung)
|
||||
router.refresh()
|
||||
} else {
|
||||
toast.error(antwort.fehler)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{passkeys.length === 0 ? (
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
Noch kein Passkey hinterlegt.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-rand divide-y">
|
||||
{passkeys.map((passkey) => (
|
||||
<li
|
||||
key={passkey.id}
|
||||
className="flex flex-wrap items-center gap-x-4 gap-y-1 py-3"
|
||||
>
|
||||
<Fingerprint className="text-gedaempft-vordergrund size-4" aria-hidden />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">{passkey.bezeichnung}</p>
|
||||
<p className="text-gedaempft-vordergrund text-xs">
|
||||
Angelegt {passkey.erstelltAm}
|
||||
{passkey.zuletztBenutzt
|
||||
? ` · zuletzt benutzt ${passkey.zuletztBenutzt}`
|
||||
: ' · noch nicht benutzt'}
|
||||
{passkey.gesichert ? ' · geräteübergreifend' : ' · nur dieses Gerät'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
art="leise"
|
||||
groesse="quadrat"
|
||||
title={`Passkey ${passkey.bezeichnung} entfernen`}
|
||||
onClick={() => entfernen(passkey.id)}
|
||||
disabled={laeuft}
|
||||
>
|
||||
<Trash2 aria-hidden />
|
||||
<span className="sr-only">Entfernen</span>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={(e) => void anlegen(e)} className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex flex-1 flex-col gap-2">
|
||||
<Label htmlFor="passkey-name">Neuen Passkey anlegen</Label>
|
||||
<Input
|
||||
id="passkey-name"
|
||||
value={bezeichnung}
|
||||
onChange={(e) => setBezeichnung(e.target.value)}
|
||||
placeholder="z. B. Diensthandy"
|
||||
maxLength={60}
|
||||
disabled={legtAn}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" art="umriss" disabled={legtAn}>
|
||||
{legtAn ? (
|
||||
<Loader2 className="animate-spin" aria-hidden />
|
||||
) : (
|
||||
<Fingerprint aria-hidden />
|
||||
)}
|
||||
Anlegen
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Kopfleiste } from '@/components/kopfleiste'
|
||||
import { INSTANZ_NAME } from '@/lib/instanz'
|
||||
import { versionAnzeige } from '@/lib/version'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { aktuelleInstanz } from '@/server/instanz'
|
||||
|
||||
/**
|
||||
* Alles unterhalb von (app) setzt eine Anmeldung voraus. Der Riegel sitzt
|
||||
* hier im Layout und zusaetzlich in jeder Server Action – nicht nur in der
|
||||
* Oberflaeche.
|
||||
*/
|
||||
export default async function AngemeldetesLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const benutzer = await verlangeAnmeldung()
|
||||
const instanz = aktuelleInstanz()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<Kopfleiste
|
||||
name={benutzer.name ?? benutzer.email}
|
||||
email={benutzer.email}
|
||||
rolle={benutzer.rolle}
|
||||
instanzName={INSTANZ_NAME[instanz]}
|
||||
/>
|
||||
|
||||
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8">{children}</main>
|
||||
|
||||
<footer className="text-gedaempft-vordergrund border-rand border-t px-4 py-4 text-center text-xs">
|
||||
Planer {versionAnzeige()} · {INSTANZ_NAME[instanz]}
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
Card,
|
||||
CardBeschreibung,
|
||||
CardInhalt,
|
||||
CardKopf,
|
||||
CardTitel,
|
||||
} from '@/components/ui/card'
|
||||
import { wochenKapazitaet } from '@/lib/bereich'
|
||||
import { alsDatum, alsDauer } from '@/lib/zeit'
|
||||
import { verlangeAnmeldung } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export default async function StartSeite() {
|
||||
const benutzer = await verlangeAnmeldung('/')
|
||||
const jetzt = new Date()
|
||||
|
||||
const bereiche = await db.bereich.findMany({
|
||||
where: { aktiv: true },
|
||||
orderBy: [{ reihenfolge: 'asc' }, { name: 'asc' }],
|
||||
})
|
||||
|
||||
const anrede = benutzer.name?.split(' ')[0] ?? benutzer.email
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Guten Tag, {anrede}</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">{alsDatum(jetzt)}</p>
|
||||
</header>
|
||||
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-medium">Deine Bereiche</h2>
|
||||
|
||||
{bereiche.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-8 text-center text-sm">
|
||||
Noch keine Bereiche angelegt.{' '}
|
||||
<Link href="/bereiche" className="text-betont underline underline-offset-4">
|
||||
Jetzt einrichten
|
||||
</Link>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
<ul className="grid gap-4 sm:grid-cols-2">
|
||||
{bereiche.map((bereich) => (
|
||||
<li key={bereich.id}>
|
||||
<Card>
|
||||
<CardKopf className="flex-row items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="size-3 shrink-0 rounded-full ring-1 ring-black/10"
|
||||
style={{ backgroundColor: bereich.farbe }}
|
||||
/>
|
||||
<CardTitel className="text-base">{bereich.name}</CardTitel>
|
||||
</CardKopf>
|
||||
<CardInhalt>
|
||||
<p className="text-gedaempft-vordergrund text-sm">
|
||||
{alsDauer(wochenKapazitaet(bereich))} in der Woche eingeplant
|
||||
</p>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardKopf>
|
||||
<CardTitel className="text-base">Was als Nächstes kommt</CardTitel>
|
||||
<CardBeschreibung>
|
||||
Aufgaben und Ideen folgen mit M2, das Lesen der Kalender mit M3, die
|
||||
Wochenplanung per Ziehen und Ablegen mit M4.
|
||||
</CardBeschreibung>
|
||||
</CardKopf>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { Card, CardInhalt } from '@/components/ui/card'
|
||||
import { alsDatumUhrzeit } from '@/lib/zeit'
|
||||
import { verlangeRolle } from '@/server/berechtigung'
|
||||
import { db } from '@/server/db'
|
||||
|
||||
export const metadata: Metadata = { title: 'Protokoll' }
|
||||
|
||||
const SEITENGROESSE = 100
|
||||
|
||||
export default async function ProtokollSeite() {
|
||||
// Zweiter Riegel: das Layout prueft die Anmeldung, hier zusaetzlich die Rolle.
|
||||
await verlangeRolle('ADMIN', '/verwaltung/protokoll')
|
||||
|
||||
const eintraege = await db.auditEintrag.findMany({
|
||||
orderBy: { zeitpunkt: 'desc' },
|
||||
take: SEITENGROESSE,
|
||||
select: {
|
||||
id: true,
|
||||
zeitpunkt: true,
|
||||
benutzerEmail: true,
|
||||
aktion: true,
|
||||
objektTyp: true,
|
||||
objektId: true,
|
||||
details: true,
|
||||
instanz: true,
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Protokoll</h1>
|
||||
<p className="text-gedaempft-vordergrund mt-1 text-sm">
|
||||
Die letzten {SEITENGROESSE} schreibenden Aktionen. Einträge lassen sich nicht
|
||||
ändern und nicht löschen.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{eintraege.length === 0 ? (
|
||||
<Card>
|
||||
<CardInhalt className="text-gedaempft-vordergrund py-10 text-center text-sm">
|
||||
Noch nichts protokolliert.
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
) : (
|
||||
<Card>
|
||||
<CardInhalt className="overflow-x-auto p-0">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="text-gedaempft-vordergrund border-rand border-b">
|
||||
<tr>
|
||||
<th scope="col" className="px-4 py-3 font-medium">
|
||||
Zeitpunkt
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">
|
||||
Wer
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">
|
||||
Was
|
||||
</th>
|
||||
<th scope="col" className="px-4 py-3 font-medium">
|
||||
Objekt
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-rand divide-y">
|
||||
{eintraege.map((eintrag) => (
|
||||
<tr key={eintrag.id}>
|
||||
<td className="text-gedaempft-vordergrund px-4 py-2 whitespace-nowrap">
|
||||
{alsDatumUhrzeit(eintrag.zeitpunkt)}
|
||||
</td>
|
||||
<td className="px-4 py-2">{eintrag.benutzerEmail}</td>
|
||||
<td className="px-4 py-2 font-medium">{eintrag.aktion}</td>
|
||||
<td className="text-gedaempft-vordergrund px-4 py-2">
|
||||
{eintrag.objektTyp}
|
||||
{eintrag.details ? (
|
||||
<span className="ml-2 text-xs">
|
||||
{JSON.stringify(eintrag.details)}
|
||||
</span>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardInhalt>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user