docs: Bedienungsanleitung und Roadmap

Bis jetzt gab es Code und Betriebsdokumentation, aber nichts, was einem
beim Arbeiten hilft.

Die Anleitung ist kein Handbuch zum Durchlesen, sondern die zwoelf
Handgriffe, die im Alltag vorkommen - vom Behoerdenschreiben aufnehmen
bis zum Vertrag kuendigen. Dazu der Jahresrhythmus, die Berichte, was am
Handy anders ist, und ein Abschnitt "wenn etwas schiefgeht" mit den
Faellen, die wirklich passieren: Handy verloren, Code wird nicht
angenommen, Frist stimmt nicht, Bericht zeigt eine Differenz.

Ein eigener Abschnitt sagt, was die Anwendung NICHT tut. Wer sich auf
etwas verlaesst, das sie nicht leistet, faellt sonst genau dann darauf
herein, wenn es darauf ankommt.

Sie ist unter /anleitung auch in der Anwendung lesbar - dieselbe Datei,
kein zweiter Text, der auseinanderlaeuft. Wer im Heim steht und nicht
weiterweiss, hat sie auf dem Telefon dabei, und der Druckknopf legt sie
zu den Betreuungsunterlagen.

Dafuer ein schlichter Markdown-Baustein statt einer Bibliothek: die
Anwendung zeigt genau zwei solche Dokumente, beide schreibe ich selbst.
Rohes HTML wird nicht durchgereicht. Die Datenschutzseite benutzt ihn
jetzt ebenfalls, statt eine zweite Umsetzung zu pflegen.

Beide Dateien werden ueber outputFileTracingIncludes in den
Standalone-Build gezogen - der Pfad entsteht erst zur Laufzeit, die
Dateiverfolgung von Next.js kann ihn nicht erkennen. Ohne den Eintrag
waeren beide Seiten im Behaelter leer.

Die Roadmap sortiert die 44 Schema-Wuensche der Etappen nach einer
einzigen Frage zuerst: Kann daraus ein falscher Wert in einem Bericht
ans Gericht werden? Was das kann, steht in 1.1. Dazu ein Abschnitt
"bewusst nicht geplant" mit Begruendung - damit klar ist, worauf
niemand wartet.

Etappe: 8
Phase: Haertung
This commit is contained in:
Florian Wilde
2026-08-22 10:51:28 +02:00
parent b2ac13e128
commit 0e7da42f27
8 changed files with 1131 additions and 81 deletions
+430
View File
@@ -0,0 +1,430 @@
# Bedienungsanleitung
Für dich als ehrenamtlichen Betreuer. Kein Handbuch zum Durchlesen —
sondern eine Sammlung der Handgriffe, die du tatsächlich brauchst, in der
Reihenfolge, in der sie im Alltag vorkommen.
> Diese Anwendung ist ein **Arbeits- und Dokumentationswerkzeug**. Sie
> rechnet Fristen aus und sortiert Unterlagen. Sie erteilt **keine
> Rechtsberatung** und kennt deinen Einzelfall nicht. Wo ein Paragraph
> steht, steht daneben der Hinweis, dass er die Rückfrage bei Gericht,
> Bezirk oder Anwalt nicht ersetzt. Nimm den ernst.
---
## Der Grundgedanke in drei Sätzen
Du trägst einmal ein, was passiert ist — ein Schreiben, ein Besuch, eine
Überweisung. Die Anwendung merkt sich, wann daraus etwas folgt, und
erinnert dich rechtzeitig. Am Ende des Betreuungsjahres erzeugt sie aus
demselben Bestand die Berichte, die Gericht und Bezirk von dir wollen.
Das heißt umgekehrt: **Was du nicht einträgst, steht später nicht im
Bericht.** Der Aufwand liegt vorn, der Gewinn hinten.
---
## Der erste Tag
Beim allerersten Aufruf führt dich ein Assistent durch drei Schritte.
**Schritt 1 — Dein Zugang.** Name, E-Mail, Passwort. Nimm vier zufällige
Wörter statt eines kurzen Kunstworts: *Kastanie-Waschbär-Turm-Grün* ist
leichter zu merken und deutlich schwerer zu knacken. Noch besser ist ein
Passwortmanager.
**Schritt 2 — Der zweite Faktor.** Installiere eine Authenticator-App —
**Aegis** (Android) oder **2FAS** (iOS) sind quelloffen und brauchen kein
Konto. Scanne den Code, tippe die sechs Ziffern ein.
Danach bekommst du **acht Wiederherstellungscodes**. Die sind der einzige
Weg zurück, wenn dein Handy verloren geht. Druck sie aus und leg sie zu
deinen Betreuungsunterlagen — **nicht** auf dasselbe Gerät wie die App.
Sie werden dir nie wieder angezeigt.
**Schritt 3 — Die betreute Person.** Nur das Nötigste. Ein Feld ist dabei
wichtiger als alle anderen:
> **Das Bestellungsdatum.** Es steht in deiner Bestellungsurkunde. Daraus
> berechnet die Anwendung dein Betreuungsjahr — und damit, wann
> Anfangsbericht, Vermögensverzeichnis, Jahresbericht und die
> Aufwandspauschale fällig sind. Ist es falsch, ist alles falsch.
Alles Weitere trägst du später unter **Stammdaten → Bearbeiten** in Ruhe
nach. Das lohnt sich: Was dort steht, taucht in jedem Bericht und in jedem
Anschreiben wieder auf.
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
---
## Der Morgen: die Seite „Heute"
Diese eine Seite beantwortet die Frage, was ansteht. Von oben nach unten:
| Kachel | Was sie dir sagt |
|---|---|
| **Fristen** | Was überfällig ist (rot), was diese Woche fällig wird (orange), was diesen Monat (gelb) |
| **Wartet auf andere** | Vorgänge, bei denen die Gegenstelle am Zug ist — mit Tagen seit dem letzten Kontakt |
| **Nachforderungen des Trägers** | Was der Bezirk noch von dir will, mit Fortschrittsbalken |
| **Ungeprüfter Posteingang** | Neu hochgeladene Schreiben, die noch niemand zugeordnet hat |
| **Unverbuchte Belege** | Rechnungen ohne Buchung und Buchungen ohne Beleg |
| **Wo stehen wir?** | Vermögen zum letzten Stichtag, Heimkosten gegen Deckung, Tage bis zum Jahresbericht |
Jede Kachel endet in einer Handlung. Wenn irgendwo nichts steht, ist dort
auch nichts zu tun.
**Ist eine Kachel leer, obwohl du Daten erwartest?** Dann fehlt meist ein
Eintrag: keine Fristen ohne Bestellungsdatum, keine Deckungslücke ohne
erfassten Heimkostenmonat.
---
## Die zwölf häufigsten Handgriffe
### 1. Ein Behördenschreiben aufnehmen
**Dokumente → Aufnehmen**, oder unterwegs über den runden Knopf unten
rechts → *Beleg fotografieren*.
1. Foto machen oder PDF wählen — mehrere auf einmal sind ausdrücklich
erwünscht.
2. Warten, bis die Datei angenommen ist. Geprüft wird der **Inhalt**, nicht
die Endung: eine Datei, die sich nur als PDF ausgibt, wird abgelehnt.
3. Titel eintragen (oder leer lassen — die Erkennung schlägt einen vor).
4. **Eingangsdatum** setzen. Das ist wichtiger, als es aussieht: Bei
Bescheiden läuft die Widerspruchsfrist ab dem Zugang, nicht ab dem
Datum auf dem Brief.
5. Vorgang auswählen.
6. *In die Akte übernehmen*.
Danach liest der Server den Text (Texterkennung auf Deutsch) und schlägt
dir **Absender, Datum, Aktenzeichen, Dokumentart und eine im Text genannte
Frist** vor. Die Textstelle steht daneben — prüf sie, bevor du übernimmst.
Nichts davon wird automatisch angelegt.
**Trag ein, wo das Papier liegt.** Es gibt ein Feld für Ordner und
Register. Wenn der Rechtspfleger das Original sehen will, findest du es
dann in zehn Sekunden.
### 2. Aus einem Schreiben eine Frist machen
Öffne das Dokument. Steht unter den Vorschlägen eine erkannte Frist,
genügt ein Klick auf *Als Frist übernehmen*. Sonst: **Fristen → Neu**,
Datum eintragen, Vorgang zuordnen.
Setz bei behördlichen Fristen die Vorwarnung großzügig. Der Standard ist
60/30/7/1 Tage vorher.
### 3. Einen Besuch festhalten — in zwanzig Sekunden
Runder Knopf unten rechts → *Notiz / Kontakt festhalten*.
Datum ist auf heute vorbelegt, die Art wählst du aus einer Knopfreihe, die
Dauer per Schnellwahl. Ein Freitextfeld, fertig.
Zwei Felder lohnen die zusätzlichen Sekunden:
- **„Persönlicher Kontakt zur betreuten Person"** — dieses Häkchen zählt
direkt in den Jahresbericht. Es ist bewusst **nicht** vorbelegt: ein
versehentlich gesetztes Häkchen wäre eine falsche Angabe gegenüber dem
Gericht.
- **Geäußerte Wünsche** — seit der Reform 2023 ist der Wille der betreuten
Person die Leitschnur deines Handelns (§ 1821 BGB). Was du hier
festhältst, landet im Jahresbericht und trägt deine Entscheidungen.
Das ist der Handgriff, der sich am meisten auszahlt. Ein Jahresbericht aus
vierzig kurzen Notizen schreibt sich fast von selbst; einer aus der
Erinnerung ist eine Qual.
### 4. Eine Buchung erfassen
**Vermögen & Kasse → Buchungen → Neu**, oder über den runden Knopf.
Wichtig ist die Trennung: **Betrifft die Buchung die betreute Person oder
ist es deine eigene Auslage?** Läuft eine Buchung auf ein als privat
markiertes Konto und ist trotzdem als „betrifft die Betreute"
gekennzeichnet, warnt die Anwendung. Das ist der Fehler, der einem in der
Rechnungslegung um die Ohren fliegt.
**Belege.** Eine Buchung ohne Beleg wird in der Liste markiert. Das ist
kein Verbot — manchmal gibt es keinen. Dann trag eine Begründung ein, dann
ist die Zeile sauber und du weißt später noch, warum.
### 5. Einen Vorgang anlegen
**Vorgänge → Neuer Vorgang.** Nimm eine Vorlage, wenn eine passt:
Heimeinzug · Antrag Hilfe zur Pflege · Wohnungsauflösung · Hausverkauf ·
Darlehen § 91 SGB XII · Versicherungsverwertung · Pflegegrad-Höherstufung ·
Steuererklärung · Widerspruch gegen Bescheid
Jede bringt eine durchdachte Aufgabenliste in der richtigen Reihenfolge
mit. Bei den genehmigungspflichtigen erzwingt sie die Reihenfolge
**Mitteilung → Genehmigung → Rechtsgeschäft**.
Ein Feld solltest du immer pflegen: **Nächster Schritt.** Es steht auf dem
Dashboard und beantwortet dir in vier Wochen die Frage, was du dir dabei
gedacht hast.
### 6. Ein genehmigungspflichtiges Geschäft
Hausverkauf, Wohnungskündigung, Kreditaufnahme, Verwertung einer
Lebensversicherung — dafür brauchst du das Betreuungsgericht.
Markiere den Vorgang als genehmigungspflichtig und wähle die
Rechtsgrundlage. Die Anwendung zeigt dann eine feste Stufenfolge:
**Mitteilung ans Gericht → Antrag gestellt → Anhörung → Beschluss →
Rechtskraft → Vollzug**
> Die Stufe **Vollzug lässt sich erst setzen, wenn ein Beschlussdokument
> hinterlegt ist.** Das ist Absicht: Nach § 1858 BGB muss die Genehmigung
> **vor** dem Rechtsgeschäft vorliegen. Ein Vertrag, den du vorher
> unterschreibst, ist schwebend unwirksam.
Sprich vorher mit dem Rechtspfleger. Die Anwendung ersetzt das Gespräch
nicht, sie erinnert dich nur daran.
### 7. Eine Nachforderung des Bezirks abarbeiten
**Sozialhilfe → Akte öffnen.** Jede angeforderte Unterlage steht als
eigene Zeile mit Status und Frist. Der Fortschrittsbalken zeigt „x von y
Unterlagen geliefert".
Beim Anlegen des Antrags kannst du die **Checkliste der Standardunterlagen**
übernehmen — Rentenbescheide, Kontoauszüge, Versicherungsunterlagen,
notarielle Übertragungsverträge, Pflegegradbescheid, Heimvertrag,
Grundbuchauszug. Zu jeder steht dabei, **warum** der Träger sie will. Das
erspart Rückfragen.
Gibt es eine Unterlage nicht, setz den Status auf *nicht vorhanden* und
schreib eine Begründung dazu. Die geht später mit ins Unterlagenpaket —
besser als kommentarloses Schweigen.
Wenn alles beisammen ist: **Berichte → Unterlagenpaket**. Das macht aus
den markierten Dokumenten **eine** PDF mit Anschreiben, laufender Nummer
und Inhaltsverzeichnis.
### 8. Einen Bescheid prüfen
**Sozialhilfe → Akte → Bescheide → Neu.**
Trag das **Zugangsdatum** ein, nicht das Datum auf dem Bescheid. Die
Widerspruchsfrist läuft ab Bekanntgabe.
Die Anwendung rechnet die Frist aus — mit Wochenende, bayerischen
Feiertagen und dem Sonderfall, dass bei fehlender oder falscher
Rechtsbehelfsbelehrung ein Jahr statt eines Monats gilt (§ 66 SGG). Der
Rechenweg wird ausgegeben, damit du ihn nachvollziehen kannst. Aus dem
Ergebnis entsteht automatisch die Frist *Widerspruch möglich bis*.
**Rechne im Zweifel nach oder frag beim Bezirk.** Eine verstrichene
Widerspruchsfrist bekommst du nicht zurück.
### 9. Einen Rückkaufswert anfordern
**Verträge → Vertrag öffnen → Rückkaufswert anfordern.** Es entsteht ein
Anschreiben zum Kopieren, und die Anwendung legt eine **Wiedervorlage in
vier Wochen** an. Versicherer antworten selten von selbst.
### 10. Einen Vertrag kündigen
**Verträge → Vertrag öffnen → Kündigen.**
Der Rechner nennt dir den **spätesten Absendetag**, nicht den Fristablauf —
eine Kündigung wirkt mit dem Zugang, nicht mit dem Abschicken. Drei Tage
Postlauf sind eingerechnet.
Der Assistent fragt dich nach der Genehmigung. Er beantwortet die Frage
nicht, und das soll er auch nicht: Ob die Verwertung einer
Lebensversicherung genehmigungspflichtig ist, entscheidet der
Rechtspfleger. Der **Versandnachweis** (Einschreiben, Faxprotokoll) ist
Pflichtfeld — ohne ihn geht die Kündigung nicht durch.
### 11. Einen Wertstand nachtragen
**Vermögen & Kasse → Vermögen → Posten öffnen → Wert eintragen.**
Stichtag, Betrag und **Wertquelle** — letztere ist Pflicht. „Eigene
Schätzung" ist eine zulässige Antwort, „keine Angabe" nicht: Im
Vermögensverzeichnis steht die Quelle mit dabei, und das Gericht darf
wissen, woher die Zahl kommt.
Alte Werte werden **nie überschrieben**. Jede Vermögensübersicht bleibt
später reproduzierbar.
### 12. Etwas versehentlich Gelöschtes zurückholen
**Einstellungen → Papierkorb.** Gelöschtes bleibt 30 Tage liegen und lässt
sich mit einem Klick zurückholen. Erst danach ist es wirklich weg.
Endgültiges Löschen verlangt eine erneute Anmeldung.
---
## Das Betreuungsjahr
Dein Jahr läuft **ab dem Tag der Bestellung**, nicht ab dem 1. Januar. Wer
am 14. Februar bestellt wurde, hat sein erstes Betreuungsjahr bis zum
13. Februar des Folgejahres.
| Wann | Was | Grundlage |
|---|---|---|
| 3 Monate nach Bestellung | Vermögensverzeichnis und Anfangsbericht | §§ 1835, 1863 BGB |
| Zum Ende jedes Betreuungsjahres | Jahresbericht und Vermögensübersicht | §§ 1859, 1863 BGB |
| Bis 6 Monate danach | **Aufwandspauschale beantragen** | § 1878 BGB |
| Jährlich bis 31.12. | Bayerisches Landespflegegeld | BayLPflGG |
| Jährlich | Zuzahlungsbefreiung der Krankenkasse | § 62 SGB V |
| Jährlich | Rückkaufswerte aller Versicherungen aktualisieren | — |
> **Die Aufwandspauschale ist eine Ausschlussfrist.** Läuft sie ab, ist
> der Anspruch ersatzlos weg — keine Nachsicht, keine Wiedereinsetzung.
> Die Anwendung warnt deshalb schon 90 Tage vorher und markiert sie rot.
---
## Die Berichte
Alle unter **Berichte**, die beiden Finanzberichte unter **Vermögen &
Kasse → Berichte**. Jeder trägt Kopf mit Aktenzeichen, Fußzeile mit
Seitenzahl und ein Unterschriftenfeld.
**Vermögensverzeichnis (§ 1835 BGB).** Stichtag wählen, Knopf drücken.
Aktiva nach Gruppen, Passiva, laufende Einnahmen und Ausgaben,
Belegverweise. Hat ein Posten zum Stichtag keinen Wert, erscheint er als
**Lücke** — nicht als 0,00 €. Das ist ehrlicher und fällt dir auf.
**Vermögensübersicht / Rechnungslegung (§§ 1859, 1865 BGB).**
Betreuungsjahr wählen. Anfangsbestand, geordnete Einnahmen und Ausgaben,
Endbestand, Belegliste.
Zwei Varianten: die knappe Übersicht für **befreite Betreuer** (§ 1859 BGB
— als Angehöriger bist du das in der Regel) und die vollständige
Rechnungslegung.
> Geht die Rechenprobe nicht auf, wird die **Differenz im Bericht
> ausgewiesen** und jeder fehlende Saldostand benannt. Das ist Absicht.
> Eine still geglättete Rechnungslegung ist gegenüber dem Gericht wertlos —
> und meistens fehlt einfach ein Kontoauszug.
**Jahresbericht (§ 1863 BGB).** Die Anwendung erzeugt einen **Entwurf** aus
deinen Kontaktprotokollen, Gesundheitsereignissen und Vorgängen, gegliedert
nach dem, was das Gericht sehen will. Du überarbeitest ihn im Browser
(wird automatisch zwischengespeichert) und gibst ihn dann als PDF oder DOCX
aus.
> Es ist ein Entwurf, kein fertiger Bericht. Lies ihn durch, bevor du ihn
> abgibst. Was dort steht, verantwortest du.
**Belegmappe.** Alle Dokumente eines Vorgangs oder Zeitraums als eine
chronologisch sortierte, paginierte PDF mit Deckblatt und
Inhaltsverzeichnis. Genau das, was Gericht und Bezirk haben wollen.
**Vollexport.** Ein ZIP mit allen Originaldateien, allen Daten als
lesbarem JSON, den Berichten und Prüfsummen. Damit kannst du diese
Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
---
## Am Handy
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
nicht dafür angepasst.
- Menü über das Symbol links oben, schließt mit Wischen oder Escape.
- Der **runde Knopf unten rechts** ist immer erreichbar: Beleg
fotografieren, Notiz festhalten, Frist anlegen, Buchung erfassen.
- Tabellen scrollen in ihrem eigenen Rahmen, nie die ganze Seite.
- Zoomen ist erlaubt und abgeschaltet wird es nie.
Der typische Fall: Du sitzt im Heim, fotografierst die Heimrechnung,
ordnest sie dem Vorgang zu, hältst den Besuch fest — drei Minuten, und
abends musst du nichts nacharbeiten.
---
## Wenn etwas schiefgeht
**Ich habe mein Handy mit der Authenticator-App verloren.**
Melde dich mit einem deiner Wiederherstellungscodes an — statt der sechs
Ziffern gibst du den Code im Format `XXXX-XXXX-XXXX` ein. Jeder
funktioniert einmal. Danach unter **Konto → Zweiter Faktor** neu
einrichten und **neue Codes erzeugen**.
**Ich habe auch die Wiederherstellungscodes verloren.**
Dann kommt nur noch jemand mit Zugriff auf den Server weiter, indem er den
zweiten Faktor in der Datenbank zurücksetzt. Genau deshalb: Codes
ausdrucken.
**Der Code wird nicht angenommen.**
Fast immer geht die Uhr des Telefons falsch. TOTP-Codes hängen an der
Uhrzeit. Stell die automatische Zeitsynchronisierung ein.
**„Zu viele Fehlversuche."**
Nach fünf Fehlversuchen wird für eine Viertelstunde gesperrt. Warte ab —
das gilt auch dann, wenn du selbst es warst.
**Ich habe mein Passwort geändert und bin überall abgemeldet.**
So gewollt. Ein Passwortwechsel beendet alle Sitzungen, auch auf Geräten,
an denen du gerade nicht sitzt.
**Eine Frist stimmt nicht.**
Prüf zuerst das Bestellungsdatum unter **Stammdaten**. Änderst du es,
werden die Pflichtfristen neu berechnet. Erledigte Fristen bleiben dabei
unangetastet.
**Ein Bericht zeigt eine Differenz.**
Meistens fehlt ein Saldostand. Der Bericht sagt dir, welcher. Trag ihn
unter **Vermögen & Kasse → Konten** nach und erzeuge neu.
**Die Texterkennung findet nichts.**
Sie braucht Tesseract auf dem Server. Fehlt es, funktioniert der Upload
trotzdem — nur ohne Volltextsuche. Titel und Absender kannst du von Hand
eintragen.
---
## Was diese Anwendung nicht tut
Damit du dich nicht darauf verlässt:
- **Sie berät dich nicht.** Sie rechnet Fristen und schlägt Einordnungen
vor. Ob dein Fall eine Ausnahme ist, weiß sie nicht.
- **Sie entscheidet nicht, was Schonvermögen ist.** Sie gibt dir
Argumentationshilfen zu § 90 SGB XII zum Übernehmen. Entscheiden tun der
Träger und notfalls das Sozialgericht.
- **Sie schätzt keine Bewilligungschancen.**
- **Sie holt keine Kontoauszüge ab.** Kontobewegungen kommen per
CSV-Import oder von Hand.
- **Sie verschickt nichts.** Anschreiben entstehen als Text zum Kopieren,
Berichte als PDF. Absenden musst du selbst — und den Versandnachweis
hochladen.
- **Sie ersetzt deine Papierablage nicht.** Originale bleiben Originale.
Deshalb gibt es das Feld für den Ablageort.
---
## Zwei Gewohnheiten, die sich lohnen
**Jeden Kontakt festhalten, sofort.** Auch das kurze Telefonat. Zwanzig
Sekunden jetzt sparen dir im Januar einen halben Tag.
**Einmal im Jahr die Wiederherstellung üben.** Ein Backup, das nie
zurückgespielt wurde, ist kein Backup. Die Anleitung steht in der README
unter *Sicherung und Wiederherstellung*. Zwanzig Minuten, einmal im Jahr.
---
## Wo du weiterkommst
- **Hinweise** in der Anwendung — 37 Karten zu den häufigen Fallstricken,
jede mit Rechtsgrundlage und Link zum Gesetzestext.
- **Protokoll** — jede Änderung, lückenlos und unveränderlich. Dein
Nachweis gegenüber Gericht und möglichen Miterben.
- Dein **Rechtspfleger** beim Betreuungsgericht. Er ist zuständig und
antwortet. Bei allem, was genehmigungspflichtig sein könnte: vorher
fragen, nicht nachher.
- Die örtliche **Betreuungsbehörde** und die Betreuungsvereine beraten
ehrenamtliche Betreuer kostenlos.
+261
View File
@@ -0,0 +1,261 @@
# Roadmap
Was als Nächstes drankommt, und warum in dieser Reihenfolge.
**Maßstab ist der Alltag, nicht die Vollständigkeit.** Ein Punkt steht
weit oben, wenn er entweder etwas Falsches richtigstellt oder jeden Monat
Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben.
Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md))
---
## Woher die Punkte kommen
Beim Bauen sind **44 Schema-Wünsche** aufgelaufen — Stellen, an denen das
Datenmodell etwas nicht abbildet, was die Fachlogik gebraucht hätte. Sie
liegen einzeln und begründet in [`schema-wuensche/`](schema-wuensche/),
nach Modul getrennt. Diese Roadmap sortiert sie nach Dringlichkeit und
bündelt sie zu Releases, die sich in einem Rutsch migrieren lassen.
Dazu kommen die bekannten Einschränkungen aus dem Changelog und das, was
sich im Betrieb noch zeigen wird.
---
## 1.1 — Was heute noch falsch sein kann
**Ziel:** Kein Wert in der Anwendung soll unter ungünstigen Umständen
falsch sein, ohne dass man es merkt. Das ist die einzige Art von Fehler,
die man einem Betreuungsgericht nicht erklären kann.
### Kostenkonto der Immobilie richtigstellen
`Buchung` hat kein `immobilieId`. Das Kostenkonto wertet deshalb über
Buchungskategorien aus — bei **einem** Grundstück richtig, bei einem
zweiten still falsch. Die Oberfläche sagt das derzeit an der Auswertung
dazu; das ist ein Pflaster, keine Lösung.
*Aufwand: klein. Migration: ein Feld, ein Index.*
### Umbuchungen kennen ihr Gegenkonto
Eine Umbuchung zwischen zwei Konten erscheint heute als zwei unverbundene
Buchungen. In der Rechnungslegung sieht das aus wie eine Ausgabe und eine
Einnahme — und wer nachrechnet, fragt zu Recht nach.
*Aufwand: klein. Migration: ein selbstbezüglicher Fremdschlüssel.*
### Dublettenschutz beim Import ohne Konto
Der Schutz vor Doppelbuchungen greift über `(kontoId, importReferenz)`.
Ist kein Konto zugeordnet, greift er nicht — ein zweimal eingelesener
Auszug erzeugt dann doppelte Buchungen.
*Aufwand: klein. Migration: partieller eindeutiger Index.*
### Rückkaufswert am Vertrag
Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht
deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem
Versicherer braucht.
*Aufwand: klein.*
### Genau eine Zielspalte je Dokumentverknüpfung
`DokumentVerknuepfung` hat zwölf mögliche Zielspalten; dass genau eine
gesetzt ist, erzwingt bisher nur die Anwendung. Ein CHECK-Constraint macht
daraus eine Zusage, die auch bei direktem Datenbankzugriff hält.
*Aufwand: klein. Migration: ein Constraint.*
### Papierkorb für Saldo- und Wertstände
Beide Modelle haben kein `geloeschtAm`. Ein versehentlich eingetragener
Kontostand lässt sich nur endgültig löschen — was der Zusage widerspricht,
dass Löschen 30 Tage rückgängig zu machen ist.
*Aufwand: klein.*
### Aufgaben aus Vorlagen entfernen können
Passt eine Aufgabe aus einer Vorgangs-Vorlage im Einzelfall nicht, bleibt
sie dauerhaft offen stehen und verfälscht jede Fortschrittsanzeige.
*Aufwand: klein.*
---
## 1.2 — Weniger tippen
**Ziel:** Die Handgriffe, die sich jeden Monat wiederholen, kürzer machen.
### Nachforderungen mit stabilem Schlüssel
Der Dublettenschutz beim Übernehmen der Standardcheckliste läuft über die
Bezeichnung. Wer eine Zeile umbenennt, bekommt sie beim nächsten Mal
doppelt.
### Bescheid und Frist verbinden
Aus einem Bescheid entsteht die Widerspruchsfrist heute über einen
Textschlüssel statt über einen Fremdschlüssel. Ändert sich das
Zugangsdatum, wandert die Frist nicht mit.
### Statuswechsel eines Vorgangs als eigene Tabelle
Der Zeitstrahl liest Statuswechsel aus dem entschlüsselten Audit-Log. Das
funktioniert, ist aber langsam und hängt an einem Format, das eigentlich
nur Nachweis sein soll.
### Wer hat was erfasst
Fristen, Saldo- und Wertstände tragen kein Feld für den erfassenden
Benutzer. Bei einer Betreuung zu zweit ist das die erste Frage.
### Ansprechpartner am Vorgang
Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner
mangels Feld aber nicht übergeben.
### Inventarliste als eigenes Modell
Wertgegenstände hängen heute an Freitext und Fotos. Für die
Wohnungsauflösung wäre eine Liste mit Bezeichnung, Raum, geschätztem Wert
und Verbleib das richtige Werkzeug.
### Pflegegrad-Historie
Der Pflegegrad steht als einzelnes Feld in den Stammdaten. Frühere Grade
mit ihren Zeiträumen gehören in eine Historie — sie werden im
Jahresbericht und bei einer Höherstufung gebraucht.
---
## 1.3 — Belastbarkeit
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
geprüft sind.
### Texterkennung gegen echtes Tesseract
Bisher ist nur der Weg „nicht verfügbar, Upload funktioniert trotzdem"
getestet. Ein Test im Container gegen echte Schreiben — auch schlechte
Handyfotos, auch mehrseitige Bescheide — fehlt.
### CAMT.053 gegen einen echten Bankauszug
Der Leser ist gegen selbst gebaute Beispiele geprüft. Ein echter Auszug
der Sparkasse ist der eigentliche Test.
### Suche und Belegmappe gegen Postgres
Beide sind nur in Einzelteilen geprüft, nicht end-to-end gegen die
Datenbank mit einigen hundert Dokumenten.
### Wiederherstellung als Test
Die Anleitung dafür steht in der README. Ein Skript, das sie einmal im
Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
Absichtserklärung.
### Barrierefreiheits-Durchgang mit einem Screenreader
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
Teilen getestet. Ein Durchgang mit NVDA oder VoiceOver ist etwas anderes
als eine Testzusicherung.
### `@react-pdf/renderer` ohne Krücke
`src/lib/pdf/standardschrift.ts` gleicht aus, dass die Unterabhängigkeiten
für Standardschriften kein Schriftobjekt mehr liefern. Ein `overrides`-
Eintrag in der `package.json` oder eine neuere Fassung macht die Datei
überflüssig — sie baut sich dann selbst ab.
---
## 2.0 — Mehr als eine Betreuung
**Ziel:** Was sich nur mit einem Bruch lösen lässt. Deshalb eine eigene
Hauptversion — und deshalb nicht vorher.
### Zweite betreute Person im Alltag erprobt
Das Datenmodell trägt es von Anfang an: Alles hängt an einer `betreuteId`,
der Zugriff wird gegen sie geprüft, die Auswahl steht in der Seitenleiste.
**Gelaufen ist es bisher nur mit einer.** Was fehlt, ist Erfahrung — und
vermutlich ein Dashboard, das über beide hinwegsieht.
### Heimkostenmodell entwirren
Der einrichtungseinheitliche Eigenanteil auf der Heimrechnung ist bereits
um die Leistung der Pflegekasse gekürzt. Das Modul rechnet deshalb
ausdrücklich nach der Lesart `EIGENANTEIL_NETTO` und warnt, wenn die
Pflegekassenleistung zusätzlich erfasst wird.
Sauberer wäre, die beiden Lesarten im Modell zu trennen, statt sie im Kopf
auseinanderzuhalten. Das ändert bestehende Datensätze — also 2.0.
### Mehrere Fassungen je Bericht
Heute verdrängt die DOCX-Ausgabe die abgegebene PDF-Fassung. Was einmal
beim Gericht lag, sollte unverändert nachweisbar bleiben.
### Verschlüsselte Ablage auch bei S3
Beim Dateitreiber liegen Dokumente zusätzlich AES-256-GCM-verschlüsselt.
Bei S3 geht das nicht, weil signierte URLs am Server vorbei ausgeliefert
werden. Der Ausweg wäre, Dateien durch die Anwendung zu reichen — das
kostet Durchsatz und ist eine bewusste Abwägung, keine Selbstverständlichkeit.
---
## Später, ohne Datum
Sinnvoll, aber nicht dringend:
- **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt
einzelner Erinnerungen.
- **Vorlagen für eigene Anschreiben**, über die mitgelieferten hinaus.
- **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF.
- **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug).
- **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch
vorbereitet durch die erneute Anmeldung, fachlich noch nicht gebraucht.
- **Statistik über mehrere Betreuungsjahre** für den eigenen Überblick.
---
## Bewusst nicht geplant
Damit klar ist, worauf niemand warten muss:
| Was | Warum nicht |
|---|---|
| **Automatische Rechtsberatung** | Die Anwendung kennt den Einzelfall nicht. Sie rechnet und dokumentiert; entscheiden tun Gericht, Träger und im Zweifel das Sozialgericht. |
| **Prognosen zu Bewilligungschancen** | Eine Zahl, die niemand belegen kann, wird trotzdem geglaubt. Das ist schlimmer als keine Zahl. |
| **Direkte Bankanbindung** | Ein dauerhafter Kontozugriff ist ein Risiko, das der Bequemlichkeit nicht wert ist. CSV und CAMT reichen. |
| **Mehrmandantenfähigkeit für fremde Betreuungen** | Sobald mehrere Betreuer mit fremden Fällen auf derselben Installation arbeiten, ändert sich die datenschutzrechtliche Lage grundlegend. Mehrere Betreute **einer** Person bleiben möglich. |
| **Native App für Handy und Tablet** | Die Web-Anwendung ist am Telefon vollwertig bedienbar. Eine zweite Oberfläche wäre eine zweite Oberfläche, die veraltet. |
| **Cloud-Angebot für andere Betreuer** | Dann wäre ich Auftragsverarbeiter für fremde Gesundheitsdaten. Eine andere Aufgabe als die, für die das hier gebaut wurde. |
| **KI-Auswertung der Akte** | Gesundheitsdaten einer nicht entscheidungsfähigen Person verlassen diesen Server nicht. Die Texterkennung läuft lokal, und dabei bleibt es. |
---
## Wie hier priorisiert wird
Drei Fragen, in dieser Reihenfolge:
1. **Kann daraus ein falscher Wert in einem Bericht ans Gericht werden?**
Dann sofort — auch wenn es unbequem ist.
2. **Kostet es jeden Monat Zeit?** Dann bald.
3. **Wäre es schön?** Dann irgendwann, und ehrlich gesagt vielleicht nie.
Fällt ein Punkt in Kategorie 1 und wird trotzdem nicht sofort behoben,
steht der Grund dabei — und die Oberfläche sagt an der betroffenen Stelle,
worauf man achten muss. Ein stillschweigend falscher Wert ist das
Schlimmste, was diese Anwendung produzieren kann.
+10
View File
@@ -6,6 +6,16 @@ const nextConfig = {
poweredByHeader: false, poweredByHeader: false,
// Argon2 ist eine native Bibliothek und darf nicht gebundelt werden. // Argon2 ist eine native Bibliothek und darf nicht gebundelt werden.
serverExternalPackages: ['@node-rs/argon2'], serverExternalPackages: ['@node-rs/argon2'],
// Zwei Seiten lesen zur Laufzeit eine Markdown-Datei aus docs/:
// die Datenschutzhinweise und die Bedienungsanleitung. Der Pfad wird
// erst zur Laufzeit gebaut, die Dateiverfolgung von Next.js kann ihn
// also nicht erkennen - ohne diesen Eintrag fehlten beide Dateien im
// Standalone-Build, und im Behaelter waeren die Seiten leer.
outputFileTracingIncludes: {
'/datenschutz': ['./docs/datenschutzhinweise.md'],
'/anleitung': ['./docs/ANLEITUNG.md'],
},
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: false,
}, },
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from 'next'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { BookOpen } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { MarkdownSeite } from '@/components/markdown-seite'
import { DruckKnopf } from '@/components/druck-knopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
export const metadata: Metadata = { title: 'Anleitung' }
export const dynamic = 'force-dynamic'
/**
* Die Bedienungsanleitung, gelesen aus docs/ANLEITUNG.md.
*
* Dieselbe Datei, die auch im Repository liegt und sich ausdrucken laesst -
* zwei Fassungen desselben Textes waeren zwei Fassungen, die auseinander-
* laufen. Wer im Heim steht und nicht weiterweiss, hat sie damit auf dem
* Telefon dabei.
*/
export default async function AnleitungSeite() {
await erfordereBenutzer()
const pfad = path.join(process.cwd(), 'docs', 'ANLEITUNG.md')
let inhalt: string
try {
inhalt = await readFile(pfad, 'utf8')
} catch {
inhalt = ''
}
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel="Bedienungsanleitung"
beschreibung="Die Handgriffe, die du tatsächlich brauchst — in der Reihenfolge, in der sie im Alltag vorkommen."
aktionen={<DruckKnopf beschriftung="Anleitung drucken" />}
/>
{inhalt === '' ? (
<Alert variant="warnung">
<BookOpen aria-hidden="true" />
<AlertDescription>
Die Anleitung konnte nicht geladen werden. Sie liegt im Projekt unter{' '}
<code>docs/ANLEITUNG.md</code>.
</AlertDescription>
</Alert>
) : (
<MarkdownSeite inhalt={inhalt} />
)}
</div>
)
}
+15 -80
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { MarkdownSeite } from '@/components/markdown-seite'
export const metadata: Metadata = { title: 'Datenschutz' } export const metadata: Metadata = { title: 'Datenschutz' }
@@ -11,83 +12,7 @@ export const metadata: Metadata = { title: 'Datenschutz' }
* Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man * Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man
* auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen * auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen
* fuer denselben Text waeren zwei Quellen fuer Fehler. * fuer denselben Text waeren zwei Quellen fuer Fehler.
*
* Die Umsetzung ist bewusst schlicht: keine Markdown-Bibliothek, nur die
* wenigen Auszeichnungen, die im Dokument wirklich vorkommen.
*/ */
function alsAbsaetze(markdown: string) {
const zeilen = markdown.split(/\r?\n/)
const bloecke: React.ReactNode[] = []
let absatz: string[] = []
const absatzSchliessen = (schluessel: number) => {
if (absatz.length === 0) return
bloecke.push(
<p key={`p-${schluessel}`} className="leading-relaxed">
{absatz.join(' ')}
</p>,
)
absatz = []
}
zeilen.forEach((zeile, i) => {
const roh = zeile.trim()
// Der Hinweisblock fuer den Ausfuellenden gehoert nicht auf die Seite.
if (roh.startsWith('>')) return
if (roh === '' || roh === '---') {
absatzSchliessen(i)
return
}
if (roh.startsWith('## ')) {
absatzSchliessen(i)
bloecke.push(
<h2 key={i} className="mt-8 text-xl font-semibold">
{roh.slice(3)}
</h2>,
)
return
}
if (roh.startsWith('# ')) {
absatzSchliessen(i)
bloecke.push(
<h1 key={i} className="text-2xl font-semibold">
{roh.slice(2)}
</h1>,
)
return
}
if (roh.startsWith('- ')) {
absatzSchliessen(i)
bloecke.push(
<li key={i} className="ml-5 list-disc leading-relaxed">
{roh.slice(2)}
</li>,
)
return
}
// Tabellenzeilen werden als einfache Aufzaehlung dargestellt.
if (roh.startsWith('|')) {
absatzSchliessen(i)
const spalten = roh.split('|').map((s) => s.trim()).filter(Boolean)
if (spalten.every((s) => /^-+$/.test(s))) return
bloecke.push(
<div key={i} className="flex flex-col gap-0.5 border-l-2 py-1 pl-3 sm:flex-row sm:gap-3">
<span className="min-w-56 font-medium">{spalten[0]}</span>
<span className="text-muted-foreground">{spalten.slice(1).join(' · ')}</span>
</div>,
)
return
}
absatz.push(roh)
})
absatzSchliessen(zeilen.length)
return bloecke
}
export default async function DatenschutzSeite() { export default async function DatenschutzSeite() {
const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md') const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md')
let inhalt: string let inhalt: string
@@ -95,14 +20,24 @@ export default async function DatenschutzSeite() {
try { try {
inhalt = await readFile(pfad, 'utf8') inhalt = await readFile(pfad, 'utf8')
} catch { } catch {
inhalt = inhalt = ''
'# Datenschutzhinweise\n\nDer Text konnte nicht geladen werden. ' +
'Er liegt in docs/datenschutzhinweise.md.'
} }
return ( return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6"> <div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<article className="space-y-3 [&_strong]:font-semibold">{alsAbsaetze(inhalt)}</article> <h1 className="text-2xl font-semibold">Datenschutzhinweise</h1>
<p className="mt-1 text-sm text-muted-foreground">nach Art. 13 und 14 DSGVO</p>
<div className="mt-8">
{inhalt === '' ? (
<p className="text-muted-foreground">
Der Text konnte nicht geladen werden. Er liegt im Projekt unter{' '}
<code className="rounded bg-muted px-1.5 py-0.5">docs/datenschutzhinweise.md</code>.
</p>
) : (
<MarkdownSeite inhalt={inhalt} />
)}
</div>
<p className="mt-10 border-t pt-6 text-sm text-muted-foreground"> <p className="mt-10 border-t pt-6 text-sm text-muted-foreground">
<Link href="/anmelden" className="underline hover:no-underline"> <Link href="/anmelden" className="underline hover:no-underline">
+16 -1
View File
@@ -3,7 +3,16 @@
import * as React from 'react' import * as React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react' import {
BookOpen,
LogOut,
Monitor,
Moon,
Settings,
ShieldCheck,
Sun,
User as UserIcon,
} from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -96,6 +105,12 @@ export function BenutzerMenue({
Einstellungen Einstellungen
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/anleitung">
<BookOpen className="size-4" />
Anleitung
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { Printer } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* Druckt die aktuelle Seite.
*
* Muss eine Client-Komponente sein - window.print() gibt es auf dem
* Server nicht. Der Knopf selbst traegt "no-print", damit er auf dem
* Ausdruck nicht auftaucht.
*/
export function DruckKnopf({ beschriftung = 'Drucken' }: { beschriftung?: string }) {
return (
<Button variant="outline" className="no-print" onClick={() => window.print()}>
<Printer aria-hidden="true" />
{beschriftung}
</Button>
)
}
+324
View File
@@ -0,0 +1,324 @@
import * as React from 'react'
/**
* Anzeige einer Markdown-Datei aus dem docs-Verzeichnis.
*
* Bewusst ohne Markdown-Bibliothek: Die Anwendung zeigt genau zwei solche
* Dokumente an - die Datenschutzhinweise und die Anleitung. Beide schreibe
* ich selbst, ich weiss also, welche Auszeichnungen darin vorkommen. Eine
* Bibliothek waere hier vor allem zusaetzliche Abhaengigkeit und
* zusaetzliche Angriffsflaeche.
*
* Unterstuetzt: Ueberschriften, Absaetze, Aufzaehlungen (auch nummeriert),
* Tabellen, Zitatbloecke, Codebloecke, Trennlinien sowie **fett**,
* `Code` und [Links](ziel) im Fliesstext.
*
* Nicht unterstuetzt und auch nicht noetig: eingebettetes HTML, Bilder,
* Fussnoten. Rohes HTML wird NICHT durchgereicht - der Text wird immer als
* Text ausgegeben, nie als Markup.
*/
type Block =
| { art: 'ueberschrift'; ebene: 2 | 3 | 4; text: string }
| { art: 'absatz'; text: string }
| { art: 'liste'; nummeriert: boolean; punkte: string[] }
| { art: 'tabelle'; kopf: string[]; zeilen: string[][] }
| { art: 'zitat'; zeilen: string[] }
| { art: 'code'; zeilen: string[] }
| { art: 'trenner' }
/** Zerlegt den Fliesstext in fett, Code und Links. */
function inline(text: string, schluessel: string): React.ReactNode[] {
const teile: React.ReactNode[] = []
// Reihenfolge zaehlt: Code zuerst, damit Sternchen darin nicht greifen.
const muster = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g
let letzte = 0
let treffer: RegExpExecArray | null
let nummer = 0
while ((treffer = muster.exec(text)) !== null) {
if (treffer.index > letzte) {
teile.push(text.slice(letzte, treffer.index))
}
const stueck = treffer[0]
const id = `${schluessel}-${nummer++}`
if (stueck.startsWith('`')) {
teile.push(
<code key={id} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]">
{stueck.slice(1, -1)}
</code>,
)
} else if (stueck.startsWith('**')) {
teile.push(
<strong key={id} className="font-semibold">
{stueck.slice(2, -2)}
</strong>,
)
} else {
const beschriftung = stueck.slice(1, stueck.indexOf(']'))
const ziel = stueck.slice(stueck.indexOf('](') + 2, -1)
const extern = /^https?:\/\//.test(ziel)
teile.push(
<a
key={id}
href={ziel}
className="text-primary underline underline-offset-2 hover:no-underline"
{...(extern ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
>
{beschriftung}
</a>,
)
}
letzte = treffer.index + stueck.length
}
if (letzte < text.length) teile.push(text.slice(letzte))
return teile
}
function zerlegen(markdown: string, ohneTitel: boolean): Block[] {
const zeilen = markdown.split(/\r?\n/)
const bloecke: Block[] = []
let absatz: string[] = []
const absatzSchliessen = () => {
if (absatz.length === 0) return
bloecke.push({ art: 'absatz', text: absatz.join(' ') })
absatz = []
}
for (let i = 0; i < zeilen.length; i++) {
const roh = zeilen[i] ?? ''
const zeile = roh.trimEnd()
const gekuerzt = zeile.trim()
// --- Codeblock ---------------------------------------------------------
if (gekuerzt.startsWith('```')) {
absatzSchliessen()
const inhalt: string[] = []
i += 1
while (i < zeilen.length && !(zeilen[i] ?? '').trim().startsWith('```')) {
inhalt.push(zeilen[i] ?? '')
i += 1
}
bloecke.push({ art: 'code', zeilen: inhalt })
continue
}
if (gekuerzt === '') {
absatzSchliessen()
continue
}
if (/^-{3,}$/.test(gekuerzt) || /^\*{3,}$/.test(gekuerzt)) {
absatzSchliessen()
bloecke.push({ art: 'trenner' })
continue
}
// --- Ueberschriften ----------------------------------------------------
const ueberschrift = gekuerzt.match(/^(#{1,4})\s+(.*)$/)
if (ueberschrift) {
absatzSchliessen()
const ebene = ueberschrift[1]!.length
// Die H1 ist der Dokumenttitel - den setzt die Seite selbst.
if (ebene === 1 && ohneTitel) continue
bloecke.push({
art: 'ueberschrift',
ebene: (Math.min(Math.max(ebene, 2), 4) as 2 | 3 | 4),
text: ueberschrift[2]!,
})
continue
}
// --- Zitatblock --------------------------------------------------------
if (gekuerzt.startsWith('>')) {
absatzSchliessen()
const inhalt: string[] = []
while (i < zeilen.length && (zeilen[i] ?? '').trim().startsWith('>')) {
inhalt.push((zeilen[i] ?? '').trim().replace(/^>\s?/, ''))
i += 1
}
i -= 1
bloecke.push({ art: 'zitat', zeilen: [inhalt.join(' ')] })
continue
}
// --- Tabelle -----------------------------------------------------------
if (gekuerzt.startsWith('|')) {
absatzSchliessen()
const spalten = (z: string) =>
z
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((s) => s.trim())
const kopf = spalten(gekuerzt)
i += 1 // Trennzeile ueberspringen
const zeilenInhalt: string[][] = []
while (i + 1 < zeilen.length && (zeilen[i + 1] ?? '').trim().startsWith('|')) {
i += 1
zeilenInhalt.push(spalten(zeilen[i] ?? ''))
}
bloecke.push({ art: 'tabelle', kopf, zeilen: zeilenInhalt })
continue
}
// --- Aufzaehlung -------------------------------------------------------
const punkt = gekuerzt.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (punkt) {
absatzSchliessen()
const nummeriert = /^\d+\./.test(gekuerzt)
const punkte: string[] = [punkt[1]!]
while (i + 1 < zeilen.length) {
const naechste = (zeilen[i + 1] ?? '').trim()
const weiterer = naechste.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (weiterer && /^\d+\./.test(naechste) === nummeriert) {
punkte.push(weiterer[1]!)
i += 1
continue
}
// Eingerueckte Fortsetzung gehoert zum letzten Punkt.
if (naechste !== '' && /^\s{2,}\S/.test(zeilen[i + 1] ?? '')) {
punkte[punkte.length - 1] += ` ${naechste}`
i += 1
continue
}
break
}
bloecke.push({ art: 'liste', nummeriert, punkte })
continue
}
absatz.push(gekuerzt)
}
absatzSchliessen()
return bloecke
}
export function MarkdownSeite({
inhalt,
ohneTitel = true,
className,
}: {
inhalt: string
/** Die erste H1 weglassen - die Seite setzt ihre Ueberschrift selbst. */
ohneTitel?: boolean
className?: string
}) {
const bloecke = zerlegen(inhalt, ohneTitel)
return (
<article className={className}>
{bloecke.map((block, i) => {
switch (block.art) {
case 'ueberschrift': {
const Tag = `h${block.ebene}` as 'h2' | 'h3' | 'h4'
const groesse =
block.ebene === 2
? 'mt-10 text-xl font-semibold'
: block.ebene === 3
? 'mt-7 text-lg font-semibold'
: 'mt-5 text-base font-semibold'
return (
<Tag key={i} className={`${groesse} scroll-mt-20 first:mt-0`}>
{block.text}
</Tag>
)
}
case 'absatz':
return (
<p key={i} className="mt-3 leading-relaxed">
{inline(block.text, String(i))}
</p>
)
case 'liste': {
const Tag = block.nummeriert ? 'ol' : 'ul'
return (
<Tag
key={i}
className={`mt-3 space-y-1.5 pl-6 ${
block.nummeriert ? 'list-decimal' : 'list-disc'
}`}
>
{block.punkte.map((punkt, j) => (
<li key={j} className="leading-relaxed">
{inline(punkt, `${i}-${j}`)}
</li>
))}
</Tag>
)
}
case 'tabelle':
return (
// Tabellen scrollen im eigenen Rahmen - am Handy darf nie die
// ganze Seite seitlich wandern.
<div key={i} className="mt-4 w-full overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{block.kopf.map((spalte, j) => (
<th key={j} className="px-3 py-2 text-left font-semibold">
{inline(spalte, `${i}-k-${j}`)}
</th>
))}
</tr>
</thead>
<tbody>
{block.zeilen.map((zeile, j) => (
<tr key={j} className="border-b last:border-0">
{zeile.map((zelle, k) => (
<td key={k} className="px-3 py-2 align-top">
{inline(zelle, `${i}-${j}-${k}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
case 'zitat':
return (
<blockquote
key={i}
className="mt-4 rounded-r-md border-l-4 border-primary/40 bg-muted/40 py-3 pl-4 pr-3"
>
{block.zeilen.map((zeile, j) => (
<p key={j} className="leading-relaxed">
{inline(zeile, `${i}-${j}`)}
</p>
))}
</blockquote>
)
case 'code':
return (
<pre
key={i}
className="mt-4 overflow-x-auto rounded-lg border bg-muted/60 p-3 text-sm"
>
<code className="font-mono">{block.zeilen.join('\n')}</code>
</pre>
)
case 'trenner':
return <hr key={i} className="my-8" />
}
})}
</article>
)
}