feat: Oberflaechenbausteine, Anwendungsrahmen und Konventionen

Basisbausteine im shadcn/ui-Stil, aber mit drei bewussten Abweichungen:

- Bedienelemente sind 44px hoch statt 36px. Ich bediene die App oft am
  Handy im Heim oder auf dem Amt; kleiner ist dort nicht treffsicher.
- Auswahlfelder benutzen das native <select>. Android und iOS oeffnen
  dafuer ihre eigene Liste - schneller als jeder Nachbau, und mit
  Screenreader und Tastatur funktioniert es ohne Zutun.
- Eingabefelder haben mindestens 16px Schrift, sonst zoomt iOS-Safari
  beim Hineintippen das Formular heran.

<Feld> verdrahtet Beschriftung, Erklaerung und Fehler ueber
aria-describedby. Der Erklaerungstext ist kein Beiwerk: die Anwendung
benutzt Begriffe aus dem Betreuungsrecht, die niemand nachschlagen soll.

<Rechtshinweis> ist der stehende Vermerk, dass hier keine Rechtsberatung
stattfindet. Er steht an jeder Stelle mit Paragraphenbezug.

Der Anwendungsrahmen fuehrt drei Bedingungen zusammen, ohne die niemand
an Fachdaten kommt: angemeldet, zweiter Faktor eingerichtet, mindestens
eine betreute Person zugeordnet. Die Navigation zeigt Zaehler fuer
ueberfaellige Fristen und neuen Posteingang - die Frage "wo muss ich
hin?" wird schon im Menue beantwortet.

Das Farbschema wird per Inline-Skript mit CSP-Nonce gesetzt, bevor die
Seite gezeichnet wird - sonst blitzt beim Laden das helle Thema auf.

docs/KONVENTIONEN.md haelt fest, was in allen weiteren Etappen gilt:
Verzeichnisaufbau, Cent-Betraege, Umgang mit verschluesselten Feldern,
Zugriffspruefung, Audit-Pflicht, Ampelfarben, Commit-Trailer.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:54:56 +02:00
parent bd1c7b801f
commit dcd3a8ce71
30 changed files with 2151 additions and 12 deletions
+99
View File
@@ -0,0 +1,99 @@
import { redirect } from 'next/navigation'
import { AppHuelle, SchnellerfassungKnopf } from '@/components/app-huelle'
import { BenutzerMenue } from '@/components/benutzer-menue'
import { BetreuteAuswahl } from '@/components/betreute-auswahl'
import { NAV_STRUKTUR, type NavEintrag } from '@/components/navigation'
import { aktiveBetreute, erfordereBenutzer, erfordereZweitenFaktor, meineBetreuten } from '@/lib/auth/sitzung'
import { darf } from '@/lib/auth/rollen'
import { ROLLEN_BESCHRIFTUNG } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Rahmen aller Fachseiten.
*
* Hier laufen die drei Bedingungen zusammen, ohne die niemand an Daten
* kommt: angemeldet, zweiter Faktor eingerichtet, mindestens eine betreute
* Person zugeordnet.
*/
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const benutzer = await erfordereBenutzer()
await erfordereZweitenFaktor(benutzer)
const betreute = await meineBetreuten(benutzer)
if (betreute.length === 0) {
// Erststart: es gibt noch keine Stammdaten.
redirect('/ersteinrichtung')
}
const aktive = (await aktiveBetreute(benutzer))!
// Zugang der aktiven Person bestimmt, welche Menuepunkte sichtbar sind.
const eintrag = await db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId: benutzer.id, betreuteId: aktive.id } },
select: { rolle: true, freigegebeneModule: true },
})
const zugang = eintrag
? { rolle: eintrag.rolle, freigegebeneModule: eintrag.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
: null
// Zaehler fuer die Navigation: was ist ueberfaellig, was liegt neu im
// Posteingang? Beides beantwortet "wo muss ich hin?" schon im Menue.
const heute = new Date()
heute.setHours(0, 0, 0, 0)
const [ueberfaellig, neuImPosteingang] = await Promise.all([
db.frist.count({
where: { betreuteId: aktive.id, status: 'OFFEN', datum: { lt: heute }, ...nichtGeloescht },
}),
db.dokument.count({
where: { betreuteId: aktive.id, posteingangStatus: 'NEU', ...nichtGeloescht },
}),
])
const navigation: NavEintrag[] = NAV_STRUKTUR.filter((e) => darf(zugang, e.modul, 'lesen')).map(
(e) => ({
...e,
zaehler:
e.href === '/fristen' ? ueberfaellig : e.href === '/dokumente' ? neuImPosteingang : undefined,
dringend: e.href === '/fristen',
}),
)
return (
<AppHuelle
navigation={navigation}
kopfzeile={
<BetreuteAuswahl
aktiveId={aktive.id}
betreute={betreute.map((b) => ({
id: b.id,
vorname: b.vorname,
nachname: b.nachname,
geburtsdatum: b.geburtsdatum ? b.geburtsdatum.toISOString() : null,
}))}
/>
}
benutzerbereich={
<BenutzerMenue
name={benutzer.name}
email={benutzer.email}
rolle={ROLLEN_BESCHRIFTUNG[benutzer.rolle]}
/>
}
schnellerfassung={
<SchnellerfassungKnopf
eintraege={[
{ titel: 'Beleg fotografieren', href: '/dokumente/neu?quelle=kamera' },
{ titel: 'Notiz / Kontakt festhalten', href: '/kontakte/protokoll/neu' },
{ titel: 'Frist anlegen', href: '/fristen/neu' },
{ titel: 'Buchung erfassen', href: '/finanzen/buchungen/neu' },
]}
/>
}
>
{children}
</AppHuelle>
)
}