feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht

Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+338
View File
@@ -0,0 +1,338 @@
{
"version": 1,
"karten": [
{
"schluessel": "wille-leitschnur",
"titel": "Der Wille der betreuten Person gibt die Richtung vor",
"text": "Seit der Reform 2023 gilt: Nicht das, was du für vernünftig hältst, ist maßgeblich, sondern das, was die betreute Person will. Du musst ihre Wünsche ermitteln, ihnen entsprechen und sie bei der Umsetzung unterstützen. Abweichen darfst du nur, wenn sie sich dadurch erheblich selbst schädigen würde und das selbst nicht erkennen kann. Halte geäußerte Wünsche im Kontaktprotokoll fest – im Jahresbericht willst du zeigen können, dass du gefragt hast.",
"rechtsgrundlage": "§ 1821 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1821.html",
"kontext": ["STAMMDATEN", "KONTAKTE", "BERICHTE", "GESUNDHEIT", "FRIST:jahresbericht"],
"gewicht": 10
},
{
"schluessel": "persoenlicher-kontakt",
"titel": "Persönlicher Kontakt ist Pflicht, nicht Kür",
"text": "Du musst die betreute Person in dem erforderlichen Umfang persönlich aufsuchen und ihre Angelegenheiten mit ihr besprechen. Ein Telefonat ersetzt den Besuch nicht. Wie oft „erforderlich“ ist, hängt vom Einzelfall ab; bei einem Heimaufenthalt hält die Praxis in der Regel mindestens einen Besuch im Quartal für nachvollziehbar. Jeder Besuch gehört ins Kontaktprotokoll, denn der Jahresbericht will Zahl und Art der Kontakte wissen.",
"rechtsgrundlage": "§ 1863 Abs. 2 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1863.html",
"kontext": ["KONTAKTE", "BERICHTE", "FRIST:jahresbericht"],
"gewicht": 9
},
{
"schluessel": "wohnraum-aufgeben",
"titel": "Wohnung kündigen? Erst dem Gericht Bescheid geben",
"text": "Willst du den Wohnraum der betreuten Person aufgeben, musst du das dem Betreuungsgericht vorher mitteilen; die Kündigung selbst braucht die Genehmigung des Gerichts. Kündige nie zuerst und hole die Genehmigung hinterher – eine ohne Genehmigung ausgesprochene Kündigung kann unwirksam sein, und der Wohnraum ist trotzdem weg. Der Grund für die strenge Regel: Die Wohnung ist oft der letzte Rückweg aus dem Heim.",
"rechtsgrundlage": "§ 1833 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1833.html",
"kontext": ["IMMOBILIE", "VORGANG:IMMOBILIE", "VORLAGE:wohnungsaufloesung", "VORLAGE:heimeinzug"],
"gewicht": 10
},
{
"schluessel": "grundstueck-genehmigung",
"titel": "Grundstücksgeschäfte brauchen das Gericht",
"text": "Verkauf, Belastung mit einer Grundschuld, Bestellung eines Wohnrechts, Aufhebung eines Rechts am Grundstück: All das darfst du nur mit Genehmigung des Betreuungsgerichts. Das gilt auch dann, wenn der Verkauf wirtschaftlich offensichtlich verständig ist. Plane die Genehmigung als eigenen Vorgang mit eigener Frist ein – zwischen Antrag und Rechtskraft vergehen erfahrungsgemäß Wochen bis Monate.",
"rechtsgrundlage": "§ 1850 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1850.html",
"kontext": ["IMMOBILIE", "VORGANG:IMMOBILIE", "VORLAGE:hausverkauf", "VORLAGE:darlehen-91-sgb-xii"],
"gewicht": 10
},
{
"schluessel": "genehmigung-vor-geschaeft",
"titel": "Die Genehmigung muss vor dem Geschäft vorliegen",
"text": "Ein genehmigungsbedürftiges Rechtsgeschäft wird erst wirksam, wenn die Genehmigung des Gerichts vorliegt und dem Vertragspartner mitgeteilt worden ist. Unterschreibst du vorher, hängt der Vertrag in der Schwebe – und wenn das Gericht nein sagt, ist er endgültig unwirksam. Sag dem Notar oder der Gegenseite vorher, dass eine betreuungsgerichtliche Genehmigung nötig ist, und lass eine entsprechende Klausel aufnehmen.",
"rechtsgrundlage": "§ 1855, § 1858 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1858.html",
"kontext": ["VORGAENGE", "IMMOBILIE", "VORGANG:IMMOBILIE", "VORGANG:GERICHT", "VORLAGE:hausverkauf"],
"gewicht": 10
},
{
"schluessel": "rechtspfleger-fragen",
"titel": "Im Zweifel vorher beim Rechtspfleger anrufen",
"text": "Für Vermögensangelegenheiten in der Betreuung ist beim Amtsgericht der Rechtspfleger zuständig, nicht der Richter. Ein Anruf vor einem größeren Schritt kostet zehn Minuten und erspart im Zweifel ein unwirksames Geschäft. Halte das Ergebnis schriftlich fest – mit Datum, Namen und dem, was gesagt wurde. Diese Notiz ist später dein Beleg dafür, dass du sorgfältig vorgegangen bist.",
"rechtsgrundlage": "§ 3 Nr. 2 Buchst. b RPflG",
"link": "https://www.gesetze-im-internet.de/rpflg_1969/__3.html",
"kontext": ["VORGAENGE", "VORGANG:GERICHT", "IMMOBILIE", "FINANZEN"],
"gewicht": 8
},
{
"schluessel": "vermoegensverzeichnis-frist",
"titel": "Vermögensverzeichnis: drei Monate nach der Bestellung",
"text": "Nach deiner Bestellung musst du dem Gericht ein Verzeichnis des Vermögens der betreuten Person einreichen – Konten, Bargeld, Grundbesitz, Versicherungen, Forderungen und Schulden, jeweils mit Stand zum Tag der Bestellung. Die Frist beträgt drei Monate. Fordere Kontostandsbestätigungen und Rückkaufswertmitteilungen früh an, die Institute brauchen oft mehrere Wochen.",
"rechtsgrundlage": "§ 1835 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1835.html",
"kontext": ["FINANZEN", "BERICHTE", "FRIST:vermoegensverzeichnis"],
"gewicht": 10
},
{
"schluessel": "anfangsbericht",
"titel": "Anfangsbericht: die Lage zu Beginn schildern",
"text": "Zu Beginn der Betreuung berichtest du dem Gericht über die persönlichen Verhältnisse der betreuten Person, ihre Wünsche und die Ziele, die du in deinen Aufgabenkreisen verfolgst. Auch dafür gilt die Drei-Monats-Frist. Der Anfangsbericht ist zugleich dein Ausgangspunkt: Woran du später im Jahresbericht die Entwicklung misst, muss hier einmal beschrieben sein.",
"rechtsgrundlage": "§ 1863 Abs. 1 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1863.html",
"kontext": ["BERICHTE", "FRIST:anfangsbericht"],
"gewicht": 9
},
{
"schluessel": "jahresbericht",
"titel": "Jahresbericht: einmal im Betreuungsjahr",
"text": "Einmal jährlich berichtest du dem Betreuungsgericht über die persönlichen Verhältnisse, über Art und Umfang deiner Kontakte, über die Wünsche der betreuten Person und darüber, ob die Betreuung fortzuführen, zu erweitern oder einzuschränken ist. Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Der Entwurf aus dieser Anwendung ist ein Textvorschlag – lies ihn durch und schreib ihn in deinen eigenen Worten zu Ende.",
"rechtsgrundlage": "§ 1863 Abs. 3 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1863.html",
"kontext": ["BERICHTE", "FRIST:jahresbericht"],
"gewicht": 10
},
{
"schluessel": "vermoegensuebersicht-befreit",
"titel": "Als befreiter Betreuer: Übersicht statt voller Rechnungslegung",
"text": "Bist du als Angehöriger von der Rechnungslegung befreit, genügt dem Gericht jährlich eine Übersicht über den Bestand des Vermögens – welche Konten mit welchem Stand, welche Vermögenswerte, welche Verbindlichkeiten. Die volle Rechnungslegung mit jeder einzelnen Einnahme und Ausgabe entfällt. Belege solltest du trotzdem sammeln: Das Gericht kann sie anfordern, und am Ende der Betreuung wollen Erben sie sehen.",
"rechtsgrundlage": "§ 1859 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1859.html",
"kontext": ["FINANZEN", "BERICHTE", "FRIST:vermoegensuebersicht"],
"gewicht": 8
},
{
"schluessel": "getrennte-konten",
"titel": "Niemals eigenes Geld mit dem der Betreuten vermischen",
"text": "Das Geld der betreuten Person gehört auf ihre eigenen, auf ihren Namen lautenden Konten. Zahle Rechnungen nicht „schnell mal“ von deinem Konto und hol es dir später zurück, und lege nie Geld der Betreuten auf dein Konto. Lässt es sich ausnahmsweise nicht vermeiden, dokumentiere Auslage und Erstattung mit Beleg. Vermischtes Vermögen ist der häufigste Vorwurf in Haftungsfällen und im Streit mit Miterben.",
"rechtsgrundlage": "§ 1841 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1841.html",
"kontext": ["FINANZEN", "VORGANG:GERICHT"],
"gewicht": 10
},
{
"schluessel": "kontoauszuege-aufbewahren",
"titel": "Kontoauszüge lückenlos aufbewahren",
"text": "Jede Bewegung auf den Konten der betreuten Person muss sich später mit einem Beleg erklären lassen. Sammle Kontoauszüge fortlaufend und ohne Lücke, dazu Rechnungen, Quittungen und Heimabrechnungen. Fehlt ein Auszug, fordere ihn nach, solange die Bank ihn noch hat. Die Belegpflicht endet nicht mit dem Jahresbericht: Am Ende der Betreuung ist Schlussrechnung zu legen, und dann liegt die Beweislast bei dir.",
"rechtsgrundlage": "§ 1865, § 1872 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1865.html",
"kontext": ["FINANZEN", "DOKUMENTE", "BERICHTE"],
"gewicht": 9
},
{
"schluessel": "aufwandspauschale",
"titel": "Aufwandspauschale: jährlich beantragen, sonst verfällt sie",
"text": "Als ehrenamtlicher Betreuer steht dir eine jährliche Aufwandspauschale zu. Sie wird nicht von selbst gezahlt: Du musst sie beantragen, und zwar innerhalb von sechs Monaten nach dem Ende des Betreuungsjahres. Das ist eine Ausschlussfrist – läuft sie ab, ist der Anspruch für dieses Jahr endgültig weg, ohne Nachsicht und ohne Wiedereinsetzung. Setz dir die Frist mit Vorwarnung in den Kalender.",
"rechtsgrundlage": "§ 1878 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1878.html",
"kontext": ["FRISTEN", "FINANZEN", "FRIST:aufwandspauschale"],
"gewicht": 10
},
{
"schluessel": "betreuerausweis",
"titel": "Betreuerausweis rechtzeitig verlängern lassen",
"text": "Die Bestellungsurkunde ist dein Ausweis gegenüber Banken, Behörden und dem Heim. Sie ist oft befristet, weil auch die Betreuung befristet angeordnet wird. Läuft sie ab, verweigern Banken den Zugriff auf die Konten – mitten in laufenden Zahlungen. Leg dir die Gültigkeit als Wiedervorlage an und beantrage die Verlängerung zwei bis drei Monate vorher beim Betreuungsgericht.",
"rechtsgrundlage": "§ 1875 Abs. 2 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1875.html",
"kontext": ["DOKUMENTE", "FRISTEN", "STAMMDATEN"],
"gewicht": 8
},
{
"schluessel": "haftung-betreuer",
"titel": "Du haftest für schuldhafte Pflichtverletzungen",
"text": "Verletzt du deine Pflichten und entsteht der betreuten Person dadurch ein Schaden, haftest du dafür – auch als ehrenamtlicher Angehöriger. Dein Schutz heißt Sorgfalt und Dokumentation: Entscheidungen begründen, Rückfragen bei Gericht und Bezirk festhalten, Belege sammeln. Für ehrenamtliche Betreuer besteht über die Länder in der Regel eine Sammelhaftpflichtversicherung; frag beim örtlichen Betreuungsverein nach, was sie abdeckt.",
"rechtsgrundlage": "§ 1826 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1826.html",
"kontext": ["STAMMDATEN", "VORGAENGE", "FINANZEN"],
"gewicht": 7
},
{
"schluessel": "zwangsmassnahme",
"titel": "Ärztliche Zwangsmaßnahmen: nur mit Genehmigung",
"text": "Eine ärztliche Behandlung gegen den natürlichen Willen der betreuten Person ist nur unter engen Voraussetzungen und nur mit Genehmigung des Betreuungsgerichts zulässig. Vorausgesetzt wird unter anderem, dass zuvor ernsthaft versucht wurde, die Person zu überzeugen, dass die Maßnahme im Rahmen eines stationären Aufenthalts stattfindet und dass der drohende Schaden erheblich ist. Frag im Zweifel sofort beim Gericht nach – Eile ist kein Grund, den Weg abzukürzen.",
"rechtsgrundlage": "§ 1832 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1832.html",
"kontext": ["GESUNDHEIT", "VORGANG:HEIM_PFLEGE"],
"gewicht": 9
},
{
"schluessel": "unterbringung",
"titel": "Freiheitsentziehende Maßnahmen nur mit Beschluss",
"text": "Eine geschlossene Unterbringung und freiheitsentziehende Maßnahmen wie Bettgitter, Bauchgurt oder ein ruhigstellendes Medikament zur Bewegungsbeschränkung bedürfen der Genehmigung des Betreuungsgerichts, wenn sie regelmäßig oder über längere Zeit erfolgen. Das gilt auch im Pflegeheim. Ohne Beschluss darf die Einrichtung solche Maßnahmen nicht dauerhaft anwenden – frag nach, wenn dir etwas auffällt.",
"rechtsgrundlage": "§ 1831 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1831.html",
"kontext": ["GESUNDHEIT", "VORGANG:HEIM_PFLEGE"],
"gewicht": 9
},
{
"schluessel": "schonvermoegen",
"titel": "Schonvermögen: 10.000 Euro bleiben geschützt",
"text": "Sozialhilfe – dazu gehört die Hilfe zur Pflege – setzt voraus, dass eigenes Vermögen eingesetzt wird. Geschützt bleibt aber ein Schonbetrag von derzeit 10.000 Euro je leistungsberechtigter Person. Erst was darüber liegt, ist einzusetzen, und auch das nur, soweit es keine Härte bedeutet. Rechne den Betrag nach, bevor du dem Bezirk eine Verwertung zusagst.",
"rechtsgrundlage": "§ 90 Abs. 2 Nr. 9 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__90.html",
"kontext": ["SOZIALHILFE", "FINANZEN", "VORGANG:SOZIALHILFE", "VORLAGE:antrag-hilfe-zur-pflege"],
"gewicht": 10
},
{
"schluessel": "hausgrundstueck-geschuetzt",
"titel": "Das selbst bewohnte Hausgrundstück ist geschützt",
"text": "Ein angemessenes Hausgrundstück, das die leistungsberechtigte Person allein oder mit Angehörigen ganz oder teilweise bewohnt, darf nicht zur Verwertung verlangt werden. Beim Umzug ins Heim wird das strittig: Der Träger sieht das Objekt dann oft nicht mehr als „bewohnt“ an. Argumentiere mit der konkreten Rückkehrperspektive, mit dem Wohnrecht von Angehörigen und – falls das nicht trägt – mit der Härteregelung.",
"rechtsgrundlage": "§ 90 Abs. 2 Nr. 8 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__90.html",
"kontext": ["SOZIALHILFE", "IMMOBILIE", "VORGANG:IMMOBILIE", "VORGANG:SOZIALHILFE"],
"gewicht": 10
},
{
"schluessel": "haerte-verwertung",
"titel": "Härteregelung: wenn die Verwertung unzumutbar wäre",
"text": "Auch Vermögen, das nach dem Katalog eigentlich einzusetzen wäre, darf nicht verlangt werden, wenn das für die betroffene Person oder ihre Angehörigen eine Härte bedeuten würde. Das ist der Auffangtatbestand für alles, was der Gesetzgeber nicht vorhergesehen hat – etwa ein Verkauf weit unter Wert oder der Verlust der Wohnung eines pflegenden Angehörigen. Die Härte muss man geltend machen und begründen; von allein prüft sie kaum ein Träger.",
"rechtsgrundlage": "§ 90 Abs. 3 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__90.html",
"kontext": ["SOZIALHILFE", "IMMOBILIE", "VORGANG:SOZIALHILFE"],
"gewicht": 9
},
{
"schluessel": "bestattungsvorsorge",
"titel": "Bestattungsvorsorge ist geschütztes Vermögen",
"text": "Geld, das zweckgebunden für die eigene Bestattung zurückgelegt ist – ein Bestattungsvorsorgevertrag oder ein treuhänderisch gebundenes Vorsorgekonto in angemessener Höhe –, ist als Härtefall geschützt und muss nicht für die Heimkosten eingesetzt werden. Entscheidend ist die Zweckbindung: Ein normales Sparbuch „für später“ genügt nicht. Lege dem Bezirk den Vertrag oder die Verpfändungserklärung vor.",
"rechtsgrundlage": "§ 90 Abs. 3 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__90.html",
"kontext": ["SOZIALHILFE", "FINANZEN", "VERTRAEGE", "VORGANG:SOZIALHILFE"],
"gewicht": 8
},
{
"schluessel": "darlehen-statt-verwertung",
"titel": "Darlehen statt sofortigem Verkauf",
"text": "Kann Vermögen – typischerweise eine Immobilie – nicht sofort oder nur mit erheblichem Verlust verwertet werden, ist die Sozialhilfe als Darlehen zu leisten. Der Träger sichert sich dann meist durch eine Grundschuld ab. Das ist oft die bessere Lösung als ein Notverkauf: Die Leistung fließt sofort, das Haus bleibt vorerst, und die Rückzahlung fällt erst später an. Beantrage das ausdrücklich und begründe, warum ein sofortiger Verkauf unzumutbar wäre.",
"rechtsgrundlage": "§ 91 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__91.html",
"kontext": ["SOZIALHILFE", "IMMOBILIE", "VORLAGE:darlehen-91-sgb-xii", "VORGANG:SOZIALHILFE"],
"gewicht": 10
},
{
"schluessel": "sozialhilfe-kenntnisgrundsatz",
"titel": "Sozialhilfe beginnt mit der Kenntnis des Trägers",
"text": "Anders als andere Sozialleistungen setzt die Sozialhilfe nicht erst mit dem förmlichen Antrag ein, sondern sobald der Träger von der Notlage Kenntnis erlangt. Deshalb gilt: sofort melden, auch wenn noch Unterlagen fehlen. Eine kurze schriftliche Mitteilung mit Datum sichert dir den früheren Leistungsbeginn; die Unterlagen kannst du nachreichen. Bewahre den Sendenachweis auf.",
"rechtsgrundlage": "§ 18 SGB XII",
"link": "https://www.gesetze-im-internet.de/sgb_12/__18.html",
"kontext": ["SOZIALHILFE", "VORLAGE:antrag-hilfe-zur-pflege", "VORGANG:SOZIALHILFE"],
"gewicht": 10
},
{
"schluessel": "schenkungsrueckforderung",
"titel": "Früher verschenkt? Danach wird gefragt",
"text": "Wer eine Schenkung gemacht hat und später selbst bedürftig wird, kann sie innerhalb von zehn Jahren nach der Übergabe zurückfordern, soweit er seinen Unterhalt nicht mehr bestreiten kann. Der Sozialhilfeträger lässt sich diesen Anspruch regelmäßig überleiten und wendet sich dann an den Beschenkten. Rechne bei der Antragstellung damit, dass nach Übertragungen der letzten zehn Jahre gefragt wird, und lege sie von sich aus offen.",
"rechtsgrundlage": "§ 528 BGB, § 93 SGB XII",
"link": "https://www.gesetze-im-internet.de/bgb/__528.html",
"kontext": ["SOZIALHILFE", "IMMOBILIE", "FINANZEN", "VORLAGE:antrag-hilfe-zur-pflege"],
"gewicht": 9
},
{
"schluessel": "widerspruchsfrist",
"titel": "Widerspruch: ein Monat ab Bekanntgabe",
"text": "Gegen einen Bescheid des Sozialhilfeträgers kannst du innerhalb eines Monats nach Bekanntgabe Widerspruch einlegen. Maßgeblich ist nicht das Datum auf dem Bescheid, sondern der Zugang – notiere deshalb immer das Eingangsdatum. Ein Widerspruch braucht zunächst keine Begründung: „Hiermit lege ich Widerspruch ein, die Begründung reiche ich nach“ wahrt die Frist.",
"rechtsgrundlage": "§ 84 SGG",
"link": "https://www.gesetze-im-internet.de/sgg/__84.html",
"kontext": ["SOZIALHILFE", "FRISTEN", "DOKUMENTE", "VORLAGE:widerspruch-bescheid"],
"gewicht": 10
},
{
"schluessel": "fehlende-rechtsbehelfsbelehrung",
"titel": "Fehlt die Rechtsbehelfsbelehrung, hast du ein Jahr Zeit",
"text": "Ist ein Bescheid ohne oder mit falscher Rechtsbehelfsbelehrung ergangen, beträgt die Widerspruchsfrist nicht einen Monat, sondern ein Jahr ab Bekanntgabe. Prüfe deshalb bei jedem Bescheid: Steht am Ende eine Belehrung? Nennt sie die richtige Stelle, die richtige Frist und die richtige Form? Wenn nicht, ist ein scheinbar verfristeter Widerspruch oft noch möglich.",
"rechtsgrundlage": "§ 66 Abs. 2 SGG",
"link": "https://www.gesetze-im-internet.de/sgg/__66.html",
"kontext": ["SOZIALHILFE", "FRISTEN", "DOKUMENTE", "VORLAGE:widerspruch-bescheid"],
"gewicht": 9
},
{
"schluessel": "leistungszuschlag",
"titel": "Leistungszuschlag: der Eigenanteil sinkt mit den Jahren",
"text": "Die Pflegekasse zahlt zum einrichtungseinheitlichen Eigenanteil einen Zuschlag, der mit der Dauer des Heimaufenthalts steigt – gestaffelt nach dem ersten, zweiten, dritten und ab dem vierten Jahr. Er wird von der Einrichtung mit der Pflegekasse abgerechnet und muss nicht gesondert beantragt werden. Prüfe die Heimrechnung trotzdem: Der Wechsel in die nächste Stufe wird gelegentlich übersehen.",
"rechtsgrundlage": "§ 43c SGB XI",
"link": "https://www.gesetze-im-internet.de/sgb_11/__43c.html",
"kontext": ["GESUNDHEIT", "SOZIALHILFE", "FINANZEN", "VORGANG:HEIM_PFLEGE"],
"gewicht": 8
},
{
"schluessel": "pflegegrad-hoeherstufung",
"titel": "Verschlechtert sich der Zustand: Höherstufung beantragen",
"text": "Verschlechtert sich der Gesundheitszustand dauerhaft, kannst du jederzeit eine erneute Begutachtung durch den Medizinischen Dienst beantragen; ein formloses Schreiben an die Pflegekasse genügt. Bereite die Begutachtung vor: ein Pflegetagebuch über ein bis zwei Wochen, aktuelle Arztberichte, die Einschätzung der Pflegekräfte im Heim. Ein höherer Pflegegrad bedeutet mehr Leistung der Pflegekasse und damit einen geringeren Eigenanteil aus dem Vermögen.",
"rechtsgrundlage": "§ 18 SGB XI",
"link": "https://www.gesetze-im-internet.de/sgb_11/__18.html",
"kontext": ["GESUNDHEIT", "VORLAGE:pflegegrad-hoeherstufung", "FRIST:pflegegrad-pruefung", "VORGANG:HEIM_PFLEGE"],
"gewicht": 9
},
{
"schluessel": "heimkosten-abwesenheit",
"titel": "Krankenhaus und Abwesenheit mindern den Heimsatz",
"text": "Ist die betreute Person vorübergehend abwesend – etwa im Krankenhaus oder in Kurzzeitpflege –, darf die Einrichtung nach den vereinbarten Abwesenheitsregeln nur einen geminderten Satz berechnen. Die genauen Abschläge stehen im Heimvertrag und im Rahmenvertrag des Landes. Notiere jeden Abwesenheitstag mit Datum und prüfe die Heimrechnung daraufhin nach; hier liegt häufig Geld auf der Straße.",
"rechtsgrundlage": "§ 87a SGB XI",
"link": "https://www.gesetze-im-internet.de/sgb_11/__87a.html",
"kontext": ["GESUNDHEIT", "FINANZEN", "VORGANG:HEIM_PFLEGE"],
"gewicht": 8
},
{
"schluessel": "zuzahlungsbefreiung",
"titel": "Zuzahlungsbefreiung: jedes Jahr neu beantragen",
"text": "Zuzahlungen zu Medikamenten, Heil- und Hilfsmitteln sind auf zwei Prozent der jährlichen Bruttoeinnahmen begrenzt, bei chronisch Kranken auf ein Prozent. Ist die Belastungsgrenze erreicht, stellt die Krankenkasse eine Befreiung für den Rest des Kalenderjahres aus – aber nur auf Antrag und nur für dieses Jahr. Sammle die Quittungen und stelle den Antrag zu Jahresbeginn erneut.",
"rechtsgrundlage": "§ 62 SGB V",
"link": "https://www.gesetze-im-internet.de/sgb_5/__62.html",
"kontext": ["GESUNDHEIT", "FRISTEN", "FRIST:zuzahlungsbefreiung"],
"gewicht": 8
},
{
"schluessel": "landespflegegeld",
"titel": "Bayerisches Landespflegegeld: Antrag bis 31. Dezember",
"text": "Bayern zahlt Menschen mit Pflegegrad 2 bis 5 und Hauptwohnsitz in Bayern ein jährliches Landespflegegeld. Es wird unabhängig von Einkommen und Vermögen gezahlt und nicht auf die Sozialhilfe angerechnet. Der Antrag für das laufende Jahr muss bis zum 31. Dezember gestellt sein. Nach der ersten Bewilligung läuft die Zahlung in der Regel weiter – prüfe trotzdem jährlich den Eingang.",
"rechtsgrundlage": "Art. 1 ff. BayLPflGG",
"link": "https://www.gesetze-bayern.de/Content/Document/BayLPflGG",
"kontext": ["GESUNDHEIT", "FINANZEN", "FRISTEN", "FRIST:landespflegegeld"],
"gewicht": 8
},
{
"schluessel": "rundfunkbeitragsbefreiung",
"titel": "Rundfunkbeitrag: Befreiung oder Ermäßigung prüfen",
"text": "Wer Leistungen der Hilfe zur Pflege in einer Einrichtung bezieht, kann sich vom Rundfunkbeitrag befreien lassen; bei Merkzeichen RF ermäßigt er sich auf ein Drittel. Für die Wohnung, die nach dem Heimeinzug leer steht, entfällt die Beitragspflicht ohnehin, sobald dort niemand mehr gemeldet ist – das muss man dem Beitragsservice aber mitteilen. Beides läuft nicht von allein.",
"rechtsgrundlage": "§ 4 RBStV",
"link": "https://www.gesetze-bayern.de/Content/Document/RBStV-4",
"kontext": ["SOZIALHILFE", "VERTRAEGE", "IMMOBILIE", "VORLAGE:heimeinzug"],
"gewicht": 6
},
{
"schluessel": "leerstand-versicherung",
"titel": "Leerstand dem Gebäudeversicherer melden",
"text": "Steht ein Gebäude leer, ist das eine Gefahrerhöhung im Sinne des Versicherungsvertragsrechts. Meldest du sie nicht, kann der Versicherer im Schadensfall die Leistung kürzen oder ganz verweigern – und ein Wasserschaden im unbeheizten Haus ist teuer. Melde den Leerstand schriftlich, lass dir die Fortgeltung des Schutzes bestätigen und halte die vereinbarten Auflagen ein, etwa regelmäßige Kontrollgänge.",
"rechtsgrundlage": "§ 23 VVG",
"link": "https://www.gesetze-im-internet.de/vvg_2008/__23.html",
"kontext": ["IMMOBILIE", "VERTRAEGE", "VORGANG:VERSICHERUNG", "VORLAGE:wohnungsaufloesung"],
"gewicht": 9
},
{
"schluessel": "verkehrssicherungspflicht",
"titel": "Auch ein leeres Haus muss sicher sein",
"text": "Wer ein Grundstück hält, muss dafür sorgen, dass von ihm keine Gefahr ausgeht: Winterdienst, Baumkontrolle, lose Dachziegel, gesicherte Zugänge. Diese Pflicht trifft dich als Betreuer stellvertretend für die Eigentümerin. Organisiere feste Kontrollgänge mit Datum und Fotos, beauftrage Räum- und Streudienst schriftlich und prüfe, ob die Haus- und Grundbesitzerhaftpflicht noch läuft.",
"rechtsgrundlage": "§ 823 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__823.html",
"kontext": ["IMMOBILIE", "VORGANG:IMMOBILIE", "VERTRAEGE"],
"gewicht": 8
},
{
"schluessel": "versicherung-rueckkaufswert",
"titel": "Lebensversicherungen: Rückkaufswerte jährlich abfragen",
"text": "Kapitalbildende Lebens- und Rentenversicherungen zählen mit ihrem Rückkaufswert zum Vermögen und interessieren sowohl das Betreuungsgericht als auch den Sozialhilfeträger. Fordere die Werte einmal jährlich schriftlich an, am besten zum selben Stichtag wie die Kontostände. Vor einer Kündigung prüfen: Ein Verwertungsausschluss oder die Umwandlung in eine Sterbegeldversicherung kann günstiger sein als der Rückkauf.",
"rechtsgrundlage": "§ 90 SGB XII, § 1865 BGB",
"link": "https://www.gesetze-im-internet.de/sgb_12/__90.html",
"kontext": ["VERTRAEGE", "FINANZEN", "SOZIALHILFE", "VORLAGE:versicherungsverwertung", "FRIST:rueckkaufswerte"],
"gewicht": 7
},
{
"schluessel": "zugang-eingangsdatum",
"titel": "Bescheide zählen ab Zugang – Eingangsdatum notieren",
"text": "Für jede Rechtsbehelfsfrist kommt es auf den Zugang an, nicht auf das Datum im Briefkopf. Notiere deshalb bei jedem behördlichen Schreiben den Tag, an dem es bei dir angekommen ist, und hefte den Umschlag mit dem Poststempel dazu, wenn es knapp werden könnte. Diese Anwendung fragt das Eingangsdatum beim Erfassen ab – füll es aus, es ist später der Anker jeder Fristberechnung.",
"rechtsgrundlage": "§ 37 SGB X",
"link": "https://www.gesetze-im-internet.de/sgb_10/__37.html",
"kontext": ["DOKUMENTE", "FRISTEN", "SOZIALHILFE"],
"gewicht": 8
},
{
"schluessel": "steuererklaerung-pflicht",
"titel": "Steuererklärung gehört zur Vermögenssorge",
"text": "Zur Vermögenssorge gehört auch, die steuerlichen Pflichten der betreuten Person zu erfüllen. Prüfe jährlich, ob eine Erklärung abzugeben ist – bei Renten oberhalb des Grundfreibetrags ist das häufiger der Fall, als man denkt. Umgekehrt lohnt eine freiwillige Erklärung oft: Heimkosten und Pflegeaufwendungen sind als außergewöhnliche Belastung absetzbar und führen nicht selten zu einer Erstattung.",
"rechtsgrundlage": "§ 33, § 33a EStG",
"link": "https://www.gesetze-im-internet.de/estg/__33.html",
"kontext": ["FINANZEN", "VORGANG:STEUERN", "VORLAGE:steuererklaerung"],
"gewicht": 6
}
]
}
+50
View File
@@ -0,0 +1,50 @@
# =============================================================================
# Nur fuer die Entwicklung: Postgres und MinIO auf dem eigenen Rechner.
# =============================================================================
#
# docker compose -f docker-compose.dev.yml up -d
#
# Anders als im Produktivbetrieb sind die Ports hier freigegeben - aber
# ausschliesslich an 127.0.0.1, damit nichts ins Netz hinausgeht. Die
# Anwendung selbst laeuft dabei mit "npm run dev" auf dem Host.
# =============================================================================
name: betreuungs-cockpit-dev
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: cockpit
POSTGRES_PASSWORD: entwicklung
POSTGRES_DB: betreuungs_cockpit
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8'
LANG: de_DE.UTF-8
TZ: Europe/Berlin
ports:
- '127.0.0.1:5433:5432'
volumes:
- dev-postgres:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U cockpit -d betreuungs_cockpit']
interval: 5s
timeout: 5s
retries: 20
minio:
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
restart: unless-stopped
command: server /daten --console-address ":9001"
environment:
MINIO_ROOT_USER: entwicklung
MINIO_ROOT_PASSWORD: entwicklung-geheim
ports:
- '127.0.0.1:9000:9000'
- '127.0.0.1:9001:9001'
volumes:
- dev-minio:/daten
volumes:
dev-postgres:
dev-minio:
+154
View File
@@ -0,0 +1,154 @@
# =============================================================================
# Betreuungs-Cockpit - Produktivbetrieb
# =============================================================================
#
# docker compose up -d --build
#
# Kein Reverse Proxy in dieser Datei: der ist auf dem Server bereits
# vorhanden. Die Anwendung horcht nur auf 127.0.0.1:3000 - von aussen
# erreichbar wird sie ausschliesslich ueber den vorhandenen Proxy, der
# auch TLS beendet. Siehe README, Abschnitt "Reverse Proxy".
#
# Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das
# interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber
# docker compose exec db psql -U cockpit betreuungs_cockpit
# =============================================================================
name: betreuungs-cockpit
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-cockpit}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in der .env}
POSTGRES_DB: ${POSTGRES_DB:-betreuungs_cockpit}
# Deutsche Sortierung und Zeichensatz - wichtig fuer Namenslisten
# und fuer die Volltextsuche mit der Konfiguration "german".
POSTGRES_INITDB_ARGS: '--encoding=UTF8 --locale=de_DE.UTF-8'
LANG: de_DE.UTF-8
volumes:
- postgres-daten:/var/lib/postgresql/data
- ./backups:/backups
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-cockpit} -d ${POSTGRES_DB:-betreuungs_cockpit}']
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks: [intern]
# Kein "ports:" - die Datenbank ist von aussen nicht erreichbar.
minio:
# S3-kompatibler Objektspeicher fuer die Dokumente. Wer einen externen
# Speicher in der EU nutzt (z.B. Hetzner Object Storage), kann diesen
# Dienst weglassen und in der .env S3_ENDPOINT darauf zeigen lassen.
# Auch dann gilt: AV-Vertrag nach Art. 28 DSGVO abschliessen.
image: minio/minio:RELEASE.2025-01-20T14-49-07Z
restart: unless-stopped
command: server /daten --console-address ":9001"
environment:
MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:?S3_ACCESS_KEY_ID fehlt in der .env}
MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?S3_SECRET_ACCESS_KEY fehlt in der .env}
MINIO_BROWSER_REDIRECT_URL: ${MINIO_KONSOLE_URL:-http://127.0.0.1:9001}
volumes:
- minio-daten:/daten
healthcheck:
test: ['CMD', 'mc', 'ready', 'local']
interval: 15s
timeout: 5s
retries: 10
start_period: 20s
ports:
# Nur an die Loopback-Adresse gebunden. Die Konsole erreichst du
# ueber einen SSH-Tunnel oder ueber deinen vorhandenen Proxy.
- '127.0.0.1:9001:9001'
networks: [intern]
app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
minio:
condition: service_healthy
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${POSTGRES_USER:-cockpit}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-betreuungs_cockpit}?schema=public&connection_limit=10
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
AUTH_TRUST_HOST: 'true'
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
ENC_KEY_V2: ${ENC_KEY_V2:-}
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
SESSION_MAX_AGE: ${SESSION_MAX_AGE:-1800}
REAUTH_MAX_AGE: ${REAUTH_MAX_AGE:-300}
LOGIN_MAX_VERSUCHE: ${LOGIN_MAX_VERSUCHE:-5}
LOGIN_FENSTER_SEKUNDEN: ${LOGIN_FENSTER_SEKUNDEN:-900}
LOGIN_SPERRE_SEKUNDEN: ${LOGIN_SPERRE_SEKUNDEN:-900}
SPEICHER_TREIBER: s3
S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000}
S3_REGION: ${S3_REGION:-eu-central}
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
OCR_AKTIV: ${OCR_AKTIV:-true}
SMTP_HOST: ${SMTP_HOST:-}
SMTP_PORT: ${SMTP_PORT:-587}
SMTP_USER: ${SMTP_USER:-}
SMTP_PASSWORD: ${SMTP_PASSWORD:-}
SMTP_FROM: ${SMTP_FROM:-}
TZ: Europe/Berlin
ports:
# Nur Loopback. Der vorhandene Reverse Proxy auf dem Host reicht
# von aussen hierher durch und beendet TLS.
- '127.0.0.1:${APP_PORT:-3000}:3000'
networks: [intern]
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
sicherung:
# Taeglich um 03:15 Uhr ein verschluesseltes Backup nach ./backups.
# Legt zusaetzlich die Pruefsumme ab und raeumt alte Staende auf.
image: postgres:17-alpine
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
PGHOST: db
PGUSER: ${POSTGRES_USER:-cockpit}
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
TZ: Europe/Berlin
volumes:
- ./backups:/backups
- ./scripts/sicherung.sh:/sicherung.sh:ro
entrypoint: ['/bin/sh', '-c']
command:
- |
apk add --no-cache openssl coreutils >/dev/null
echo "15 3 * * * /bin/sh /sicherung.sh >> /backups/sicherung.log 2>&1" > /etc/crontabs/root
echo "Sicherungsdienst bereit. Naechster Lauf 03:15 Uhr."
crond -f -l 8
networks: [intern]
volumes:
postgres-daten:
minio-daten:
networks:
intern:
driver: bridge
+266
View File
@@ -0,0 +1,266 @@
# Schnittstellen zwischen den Modulen
Verträge, auf die sich mehrere Etappen stützen. Wer eine dieser Schnittstellen
implementiert, hält sich exakt an die hier beschriebene Signatur; wer sie
benutzt, darf sich darauf verlassen.
---
## 1. Dateiablage — `src/lib/speicher/index.ts`
Zwei Treiber hinter einer Schnittstelle: `datei` (lokales Verzeichnis, für
Entwicklung und kleine Installationen) und `s3` (S3-kompatibler Speicher in
der EU). Gesteuert über `SPEICHER_TREIBER` bzw. das Vorhandensein von
`S3_ACCESS_KEY_ID`.
```ts
export type Ablageziel = {
/** Schlüssel im Speicher, z.B. "betreute/<id>/dokumente/<uuid>.pdf" */
schluessel: string
groesseBytes: number
/** SHA-256 hex — Nachweis der Unverändertheit gegenüber Gericht. */
pruefsumme: string
}
/** Legt eine Datei ab. Der Schlüssel wird erzeugt, nie vom Nutzer bestimmt. */
export function ablegen(
betreuteId: string,
bereich: 'dokumente' | 'berichte' | 'exporte' | 'vorschau',
dateiname: string,
inhalt: Buffer,
mimeTyp: string,
): Promise<Ablageziel>
/** Holt eine Datei zurück in den Speicher des Servers. */
export function lesen(schluessel: string): Promise<Buffer>
/**
* Kurzlebige, signierte URL. Gültigkeit in Sekunden, Standard 300.
* Beim Dateitreiber zeigt sie auf eine eigene, ebenfalls signierte Route.
*/
export function signierteUrl(schluessel: string, sekunden?: number): Promise<string>
export function entfernen(schluessel: string): Promise<void>
export function existiert(schluessel: string): Promise<boolean>
```
**Regeln**
- Der Schlüssel enthält immer die `betreuteId` als zweites Segment. Jede
Leseoperation prüft vorher, dass der angemeldete Benutzer Zugriff auf
genau diese `betreuteId` hat.
- Uploads werden nie als HTML ausgeliefert. Ausgehende Antworten tragen
`Content-Disposition: attachment` und `X-Content-Type-Options: nosniff`.
- Erlaubte Typen: `application/pdf`, `image/jpeg`, `image/png`,
`image/heic`, `image/webp`, `image/tiff`. Geprüft wird an den **Magic
Bytes**, nicht am gemeldeten MIME-Typ und nicht an der Endung.
- Größenlimit aus `env.uploadMaxBytes` (Standard 25 MB).
---
## 2. PDF-Erzeugung — `src/lib/pdf/`
Grundlage ist `@react-pdf/renderer` (kein Chromium im Container).
### `src/lib/pdf/rahmen.tsx`
```tsx
export type Berichtskopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
/** z.B. "Amtsgericht Bayreuth · Az. XVII 123/25" */
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
/** Seitenrahmen mit Kopf, Fußzeile (Seite x von y, Erstelldatum) und
* optionalem Unterschriftenfeld. Jeder Bericht benutzt ihn. */
export function Berichtsseite(props: {
kopf: Berichtskopf
mitUnterschrift?: boolean
children: React.ReactNode
}): JSX.Element
```
### `src/lib/pdf/erzeugen.ts`
```ts
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer>
/**
* Fügt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt.
* Nutzt pdf-lib.
*/
export function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer>
/** Seitenzahlen und Fußzeile über ein zusammengeführtes PDF legen. */
export function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer>
```
**Regeln**
- Schrift: die in `@react-pdf/renderer` eingebauten Standardschriften
(Helvetica). Kein Nachladen von Google Fonts.
- Jeder Bericht trägt Kopf (Betreute, Aktenzeichen, Gericht/Behörde,
Zeitraum), Fußzeile mit Seitenzahl und Erstellungsdatum sowie ein
Unterschriftenfeld.
- Beträge über `formatCent()`, Daten über `formatDatum()`.
---
## 3. OCR — `src/lib/ocr/index.ts`
```ts
export type OcrErgebnis = {
text: string
status: 'fertig' | 'fehlgeschlagen' | 'nicht_verfuegbar'
seitenzahl?: number
meldung?: string
}
/** Ruft Tesseract (deutsch) auf. Nie werfen — Status zurückgeben. */
export function ocrLesen(inhalt: Buffer, mimeTyp: string): Promise<OcrErgebnis>
/** Ist Tesseract auf diesem System vorhanden? */
export function ocrVerfuegbar(): Promise<boolean>
```
**Regeln**
- Aufruf über das System-Binary `tesseract` (`env.tesseractBefehl`),
Sprache `deu`. Im Docker-Image ist es installiert, auf einem Windows-
Entwicklungsrechner in der Regel nicht — dann liefert die Funktion
`nicht_verfuegbar`, und der Upload funktioniert trotzdem.
- PDFs werden vorher mit `pdftoppm` in Bilder gewandelt; fehlt auch das,
wird der eingebettete Text des PDFs mit `pdf-lib` gelesen.
- Der OCR-Lauf blockiert den Upload nicht: Dokument wird mit
`ocrStatus = "ausstehend"` gespeichert, die Erkennung läuft danach.
---
## 4. Fristenableitung — `src/lib/fristen/pflichtfristen.ts`
```ts
export type Pflichtfrist = {
/** Stabiler Schlüssel, verhindert Dubletten: "anfangsbericht",
* "jahresbericht-2027", "aufwandspauschale-2026", … */
systemSchluessel: string
titel: string
beschreibung: string
datum: Date
rechtsgrundlage: string
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
ausschlussfrist: boolean
vorwarnungTage: number[]
wiederholungRrule?: string
}
/** Leitet alle Pflichtfristen aus den Stammdaten ab. Rein, ohne Datenbank. */
export function pflichtfristenAbleiten(stammdaten: {
bestellungAm: Date | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
pflegegrad: number | null
jahreVoraus?: number
}): Pflichtfrist[]
/** Schreibt sie in die Datenbank, ohne Dubletten (upsert auf
* betreuteId + systemSchluessel). */
export function pflichtfristenSchreiben(betreuteId: string): Promise<number>
```
Abzuleiten sind mindestens:
| Schlüssel | Frist | Grundlage |
|---|---|---|
| `vermoegensverzeichnis` | 3 Monate nach Bestellung | § 1835 BGB |
| `anfangsbericht` | 3 Monate nach Bestellung | § 1863 Abs. 1 BGB |
| `jahresbericht-<jahr>` | jährlich zum Betreuungsjahr | § 1863 Abs. 3 BGB |
| `vermoegensuebersicht-<jahr>` | jährlich, mit dem Jahresbericht | § 1859, § 1865 BGB |
| `aufwandspauschale-<jahr>` | 6 Monate nach Ende des Betreuungsjahres, **Ausschlussfrist** | § 1878 BGB |
| `landespflegegeld-<jahr>` | 31.12. | Bayerisches Landespflegegeldgesetz |
| `zuzahlungsbefreiung-<jahr>` | jährlich | § 62 SGB V |
| `rueckkaufswerte-<jahr>` | jährlich | selbst gesetzt |
| `pflegegrad-pruefung` | konfigurierbar | § 18 SGB XI |
**Betreuungsjahr**: läuft ab dem Tag der Bestellung, nicht ab dem
Kalenderjahr. Die Aufwandspauschale verfällt sechs Monate nach dessen Ende
— diese Frist wird als `ausschlussfrist: true` markiert und im Dashboard
rot mit ausdrücklicher Verfallwarnung gezeigt.
---
## 5. Hinweis-Wissensbasis — `daten/hinweise.json`
Pflegbare JSON-Datei, kein Hardcoding.
```jsonc
{
"version": 1,
"karten": [
{
"schluessel": "wohnraum-aufgeben",
"titel": "Wohnung kündigen? Erst das Gericht fragen.",
"text": "Die Aufgabe von Wohnraum der betreuten Person ist dem Gericht mitzuteilen und bedarf der Genehmigung.",
"rechtsgrundlage": "§ 1833 BGB",
"link": "https://www.gesetze-im-internet.de/bgb/__1833.html",
"kontext": ["IMMOBILIE", "VORGANG:IMMOBILIE", "VORLAGE:wohnungsaufloesung"],
"gewicht": 10
}
]
}
```
`kontext` ist eine Liste von Auslösern: ein `Modul`, `VORGANG:<Kategorie>`,
`VORLAGE:<schluessel>` oder `FRIST:<systemSchluessel>`. Die Oberfläche
zeigt passende Karten, sortiert nach `gewicht` absteigend, jede mit
`<Rechtshinweis>`.
---
## 6. Vorgangs-Vorlagen — `src/lib/vorgaenge/vorlagen.ts`
```ts
export type Vorgangsvorlage = {
schluessel: string
titel: string
kategorie: VorgangKategorie
beschreibung: string
genehmigungspflichtig: boolean
rechtsgrundlage?: string
/** Vorbefüllte Aufgabenliste. */
aufgaben: { titel: string; beschreibung?: string; tageBisFaellig?: number }[]
/** Fristen, die mit angelegt werden. */
fristen?: { titel: string; tageBisFaellig: number; rechtsgrundlage?: string }[]
}
export const VORLAGEN: Vorgangsvorlage[]
```
Mindestens: `heimeinzug`, `antrag-hilfe-zur-pflege`, `wohnungsaufloesung`,
`hausverkauf`, `darlehen-91-sgb-xii`, `versicherungsverwertung`,
`pflegegrad-hoeherstufung`, `steuererklaerung`, `widerspruch-bescheid`.
---
## 7. E-Mail — `src/lib/mail.ts`
```ts
export function mailSenden(nachricht: {
an: string
betreff: string
text: string
}): Promise<{ gesendet: boolean; meldung?: string }>
```
Ist kein SMTP konfiguriert, wird nichts gesendet und `gesendet: false`
zurückgegeben — ohne Fehler. Erinnerungen sind eine Zugabe, kein
Kernstück; die Fristen stehen auch so im Dashboard.
+180
View File
@@ -0,0 +1,180 @@
# Schema-Wünsche aus Etappe 6 „Substanz“
Das Schema wurde nicht angefasst. Hier steht, was beim Bauen von
Immobilie, Verträgen und Pflege gefehlt hat — mit der Umgehung, die
stattdessen gewählt wurde, damit später klar ist, was zu migrieren wäre.
---
## 1. `Buchung.immobilieId`
**Problem.** Das Kostenkonto der Immobilie (Grundsteuer, Gebäude­versicherung,
Energie, Wasser, Instandhaltung, Schornsteinfeger) kann nur über die
Kategorien `GRUNDSTEUER`, `IMMOBILIE_BETRIEBSKOSTEN`,
`IMMOBILIE_INSTANDHALTUNG` und `MIETEINNAHME` zusammengestellt werden. Eine
Zuordnung „diese Buchung gehört zu diesem Objekt“ gibt es nicht.
**Folge.** Solange nur ein Objekt geführt wird, ist die Zuordnung eindeutig.
Bei einem zweiten Grundstück wären die Zahlen falsch. Die Oberfläche sagt
das an der Auswertung ausdrücklich dazu.
**Wunsch.**
```prisma
model Buchung {
immobilieId String?
immobilie Immobilie? @relation(fields: [immobilieId], references: [id], onDelete: SetNull)
}
```
---
## 2. Eigene Buchungskategorie für die Gebäudeversicherung
`BuchungKategorie.VERSICHERUNG` sammelt alle Versicherungen ein, auch
Sterbegeld und Haftpflicht. Für das Kostenkonto eines Hauses ist das zu
grob; die Gebäudeversicherung muss dort erscheinen, die private
Unfallversicherung nicht.
**Umgehung.** Die Gebäudeversicherung wird als
`IMMOBILIE_BETRIEBSKOSTEN` gebucht. Das steht als Hinweis an der
Auswertung.
**Wunsch.** `IMMOBILIE_VERSICHERUNG` in `BuchungKategorie`.
---
## 3. `Inventarstueck` — Inventarliste der Wertgegenstände
**Problem.** Für die Fotodokumentation und die Inventarliste eines
leerstehenden Hauses fehlt ein Modell mit `bezeichnung`, `raum`,
`geschaetzterWertCent` und `verbleib`. `Vermoegensposten` passt fachlich
(Art `WERTGEGENSTAND`, Verknüpfung auf `immobilieId`, Wert über
`Wertstand`, Fotos über `DokumentVerknuepfung`), hat aber keine Felder für
Raum und Verbleib.
**Umgehung.** Raum, Verbleib und Bemerkung stehen als beschriftete Zeilen
im ohnehin verschlüsselten `notizenVerschluesselt`; gelesen und geschrieben
wird das ausschließlich über `src/lib/immobilie/inventar.ts`. Der
geschätzte Wert liegt als `Wertstand` mit der Quelle „eigene Schätzung“.
**Wunsch.**
```prisma
model Inventarstueck {
id String @id @default(cuid())
betreuteId String
immobilieId String?
bezeichnung String
raum String?
geschaetzterWertCent BigInt?
verbleib String? // "im Haus", "Heimzimmer", "verschenkt", "entsorgt", "verkauft"
bemerkungVerschluesselt String?
}
```
---
## 4. `Immobilie.checklisteUebernommenAm`
Ob die Leerstands-Checkliste schon einmal übernommen wurde, lässt sich nur
daran ablesen, ob `ImmobilienCheckpunkt`-Zeilen existieren. Das genügt,
weil die Übernahme über den stabilen Titel abgleicht und keine Dubletten
erzeugt — sauberer wäre ein `systemSchluessel` am Checkpunkt.
**Wunsch.**
```prisma
model ImmobilienCheckpunkt {
systemSchluessel String?
@@unique([immobilieId, systemSchluessel])
}
```
Bis dahin dient der `titel` als Schlüssel; er darf deshalb beim
Bearbeiten eines übernommenen Punktes nicht geändert werden, ohne dass die
nächste Übernahme ihn erneut anlegt.
---
## 5. Pflegegrad-Historie als eigenes Modell
`Betreute` trägt den aktuellen Pflegegrad, seit wann und das
Bescheiddatum. Frühere Grade gehen verloren, sobald der Grad
hochgestuft wird — für den Jahresbericht und für eine Höherstufung ist
aber genau die Kette interessant.
**Umgehung.** Jede Begutachtung wird als `Gesundheitsereignis` mit der Art
`PFLEGEBEGUTACHTUNG` erfasst; der Verlauf zeigt daraus die Historie. Der
Grad selbst steht im Titel und in der verschlüsselten Beschreibung, ist
also nicht auswertbar.
**Wunsch.**
```prisma
model Pflegegradstand {
id String @id @default(cuid())
betreuteId String
grad Int
gueltigAb DateTime @db.Date
bescheidVom DateTime? @db.Date
antragAm DateTime? @db.Date
ergebnis String? // "hochgestuft", "abgelehnt", "unverändert"
}
```
---
## 6. Strukturierte Felder am `Gesundheitsereignis`
Die Schnellerfassung für COPD braucht Zahlen, die man später auswerten
will: Sauerstofffluss in l/min, Stunden pro Tag, Schweregrad einer
Exazerbation, ob stationär behandelt wurde.
**Umgehung.** Diese Angaben werden als beschriftete Zeilen in die
verschlüsselte Beschreibung geschrieben
(`src/lib/pflege/ereignis-details.ts`). Sie sind damit lesbar, aber nicht
summierbar. Auswertbar bleiben nur Art, Datum, `bisDatum` und
`abwesenheitstage` — das reicht für Exazerbationen je Jahr und
Krankenhaustage je Jahr.
**Wunsch.** Ein `details Json?`-Feld am `Gesundheitsereignis` wäre falsch
(Gesundheitsdaten gehören verschlüsselt). Besser wären wenige typisierte,
unverschlüsselte Kennzahlen ohne Diagnosebezug:
```prisma
model Gesundheitsereignis {
stationaer Boolean @default(false)
schweregrad Int? // 1 leicht, 2 mittel, 3 schwer
}
```
---
## 7. `Vertrag.vertragsnummerVerschluesselt` war nicht registriert
Das Feld steht im Schema, fehlte aber in `VERSCHLUESSELTE_FELDER` in
`src/lib/crypto/feld.ts` — `sealen('Vertrag', { vertragsnummer })` hätte
geworfen. Der Eintrag wurde ergänzt; das Schema blieb unverändert.
---
## 8. `Vertrag.laufzeitJahre`
Der Kündigungsrechner braucht die Vertragsdauer, um die Verlängerung zu
bestimmen. Das Schema kennt nur `beginnAm`, `endeAm` und
`kuendigungsfristMonate`.
**Umgehung.** Fehlt `endeAm`, rechnet
`src/lib/vertraege/kuendigung.ts` mit einer Verlängerung um ein Jahr —
der Regelfall bei Versicherungen — und schreibt diese Annahme als Hinweis
in das Ergebnis.
**Wunsch.**
```prisma
model Vertrag {
laufzeitJahre Int?
verlaengertSichAutomatisch Boolean @default(true)
}
```
+3073 -15
View File
File diff suppressed because it is too large Load Diff
+14 -1
View File
@@ -25,35 +25,48 @@
"postinstall": "prisma generate",
"db:audit-schutz": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql",
"db:sql": "npm run db:volltext && npm run db:audit-schutz",
"protokoll": "node scripts/bauprotokoll.mjs"
"protokoll": "node scripts/bauprotokoll.mjs",
"test:e2e": "playwright test",
"protokoll:md": "node scripts/bauprotokoll.mjs --md"
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "3.735.0",
"@aws-sdk/s3-request-presigner": "3.735.0",
"@node-rs/argon2": "2.0.2",
"@prisma/client": "6.3.1",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-slot": "1.1.1",
"@react-pdf/renderer": "4.1.6",
"archiver": "7.0.1",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"date-fns": "4.1.0",
"docx": "9.1.1",
"lucide-react": "0.474.0",
"next": "15.1.6",
"next-auth": "5.0.0-beta.25",
"nodemailer": "6.10.0",
"otpauth": "9.3.6",
"pdf-lib": "1.17.1",
"qrcode": "1.5.4",
"react": "19.0.0",
"react-dom": "19.0.0",
"rrule": "2.8.1",
"server-only": "^0.0.1",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7",
"zod": "3.24.1"
},
"devDependencies": {
"@playwright/test": "1.50.1",
"@types/archiver": "6.0.3",
"@types/node": "22.13.1",
"@types/nodemailer": "6.4.17",
"@types/qrcode": "1.5.5",
"@types/react": "19.0.8",
"@types/react-dom": "19.0.3",
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+545
View File
@@ -0,0 +1,545 @@
/**
* Startdaten.
*
* npm run db:seed
*
* Der Seed legt bewusst KEINE echten Personendaten an. Er richtet nur den
* ersten Zugang ein; alles Weitere fragt der Ersteinrichtungs-Assistent
* beim ersten Start ab. So landen keine Gesundheitsdaten im Repository,
* und der Seed bleibt auch dann brauchbar, wenn eine zweite Betreuung
* dazukommt.
*
* Zwei Schalter fuer die Entwicklung:
*
* SEED_DEMO=true legt einen vollstaendigen Beispielfall an
* (Erika Musterfrau). Nur ausserhalb der
* Produktion, und in der Oberflaeche als
* Demo gekennzeichnet.
* SEED_DEV_TOTP_SECRET=... aktiviert den zweiten Faktor mit einem
* bekannten Geheimnis, damit automatisierte
* Tests sich anmelden koennen. Wird in der
* Produktion ignoriert.
*
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/
import { PrismaClient } from '@prisma/client'
import { hash, Algorithm } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto'
const db = new PrismaClient()
const ARGON2 = {
algorithm: Algorithm.Argon2id,
memoryCost: 19_456,
timeCost: 2,
parallelism: 1,
} as const
const istProduktion = process.env.NODE_ENV === 'production'
/**
* Verschluesselt wie src/lib/crypto/feld.ts. Bewusst hier noch einmal
* ausgeschrieben: der Seed laeuft ausserhalb von Next.js und kann das
* Modul mit seinem "server-only"-Import nicht laden.
*/
function verschluesseln(klartext: string | null, kontext: string): string | null {
if (!klartext) return null
const version = Number.parseInt(process.env.ENC_KEY_AKTIV ?? '1', 10)
const roh = process.env[`ENC_KEY_V${version}`]
if (!roh) throw new Error(`ENC_KEY_V${version} fehlt. Erzeuge ihn mit "npm run schluessel".`)
const schluessel = Buffer.from(roh, 'base64')
if (schluessel.length !== 32) throw new Error(`ENC_KEY_V${version} muss 32 Bytes lang sein.`)
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: 16 })
cipher.setAAD(Buffer.from(`bc1:${version}:${kontext}`, 'utf8'))
const geheim = Buffer.concat([cipher.update(klartext, 'utf8'), cipher.final()])
return [
'bc1',
version,
iv.toString('base64url'),
cipher.getAuthTag().toString('base64url'),
geheim.toString('base64url'),
].join('.')
}
function datum(text: string): Date {
return new Date(`${text}T00:00:00.000Z`)
}
function cent(euro: number): bigint {
return BigInt(Math.round(euro * 100))
}
async function ersterZugang() {
const email = (process.env.SEED_ADMIN_EMAIL ?? '').trim().toLowerCase()
const passwort = process.env.SEED_ADMIN_PASSWORT
if (!email || !passwort) {
console.log(
'Kein SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORT gesetzt - der erste Zugang wird beim ' +
'Aufruf von /ersteinrichtung im Browser angelegt.',
)
return null
}
const vorhanden = await db.user.findUnique({ where: { email } })
if (vorhanden) {
console.log(`Zugang ${email} besteht bereits.`)
return vorhanden
}
// Der zweite Faktor bleibt normalerweise offen und wird beim ersten
// Anmelden eingerichtet - nur die Entwicklung darf ihn vorbelegen.
const devSecret = !istProduktion ? process.env.SEED_DEV_TOTP_SECRET : undefined
const benutzer = await db.user.create({
data: {
email,
name: process.env.SEED_ADMIN_NAME ?? 'Betreuer',
passwortHash: await hash(passwort, ARGON2),
rolle: 'ADMIN',
...(devSecret
? {
totpSecretVerschluesselt: verschluesseln(devSecret, 'totpSecretVerschluesselt'),
totpAktiviertAm: new Date(),
}
: {}),
},
})
console.log(`Zugang ${email} angelegt.`)
if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
} else {
console.log(' Der zweite Faktor wird beim ersten Anmelden eingerichtet.')
}
return benutzer
}
/**
* Vollstaendiger Beispielfall. Nur mit SEED_DEMO=true und nie in der
* Produktion - er soll zeigen, wie die Anwendung mit Inhalt aussieht.
*/
async function demofall(userId: string) {
const vorhanden = await db.betreute.findFirst({
where: { nachname: 'Musterfrau', vorname: 'Erika' },
})
if (vorhanden) {
console.log('Demofall besteht bereits.')
return
}
const betreute = await db.betreute.create({
data: {
anrede: 'Frau',
vorname: 'Erika',
nachname: 'Musterfrau',
geburtsname: 'Beispiel',
geburtsdatum: datum('1943-04-17'),
geburtsort: 'Bayreuth',
heimName: 'Seniorenzentrum St. Katharina',
heimStrasse: 'Kirchweg 12',
heimPlz: '95444',
heimOrt: 'Bayreuth',
heimZimmer: '218',
heimTelefon: '0921 123456-218',
heimEinzugAm: datum('2026-01-19'),
fruehereStrasse: 'Am Sonnenhang 4',
frueherePlz: '95463',
fruehererOrt: 'Bindlach',
krankenkasse: 'AOK Bayern',
krankenversichertennummerVerschluesselt: verschluesseln(
'A123456789',
'krankenversichertennummerVerschluesselt',
),
pflegekasse: 'AOK Pflegekasse Bayern',
pflegegrad: 3,
pflegegradSeit: datum('2025-11-01'),
pflegegradBescheidVom: datum('2025-10-22'),
schwerbehinderungGdB: 80,
schwerbehinderungMerkzeichen: 'G, B',
diagnosenVerschluesselt: verschluesseln(
[
'COPD GOLD III mit chronischer respiratorischer Insuffizienz (J44.09)',
'Langzeitsauerstofftherapie seit 03/2025, 2 l/min, 16 h/Tag',
'Arterielle Hypertonie (I10)',
'Osteoporose mit Wirbelkörperfraktur BWK 12 (M80.08)',
'Beginnende vaskuläre Demenz (F01.9)',
].join('\n'),
'diagnosenVerschluesselt',
),
ermittelterWilleVerschluesselt: verschluesseln(
'Möchte im Seniorenzentrum bleiben, dort ist eine frühere Nachbarin. ' +
'Will das Haus in Bindlach "nicht einfach hergeben", versteht aber, dass die ' +
'Pflegekosten gedeckt werden müssen. Wünscht keine künstliche Beatmung und keine ' +
'Verlegung in eine Klinik, wenn es zum Ende geht — so auch in der Patientenverfügung ' +
'vom 08.03.2019 festgehalten. Besuche bitte nachmittags, vormittags ist sie erschöpft.',
'ermittelterWilleVerschluesselt',
),
patientenverfuegungVorhanden: true,
vorsorgevollmachtVorhanden: false,
betreuungsgericht: 'Amtsgericht Bayreuth',
aktenzeichenGericht: 'XVII 412/25',
bestellungAm: datum('2026-02-14'),
aufgabenkreise: [
'VERMOEGENSSORGE',
'GESUNDHEITSSORGE',
'AUFENTHALTSBESTIMMUNG',
'WOHNUNGSANGELEGENHEITEN',
'BEHOERDENANGELEGENHEITEN',
],
befreiterBetreuer: true,
ehrenamtlich: true,
aktenzeichenBezirk: 'HzP 2026/04711',
aktenzeichenSozialamt: 'SA-4711-26',
rentenversicherungsnummerVerschluesselt: verschluesseln(
'17 170443 M 512',
'rentenversicherungsnummerVerschluesselt',
),
notizenVerschluesselt: verschluesseln(
'Dies ist ein Demofall zum Ausprobieren. Er lässt sich unter ' +
'Einstellungen jederzeit vollständig löschen.',
'notizenVerschluesselt',
),
zugaenge: { create: { userId, rolle: 'BETREUER' } },
},
})
// --- Kontakte -----------------------------------------------------------
await db.kontakt.createMany({
data: [
{
betreuteId: betreute.id,
name: 'Frau Hartmann',
organisation: 'Amtsgericht Bayreuth',
rolle: 'RECHTSPFLEGER',
telefon: '0921 504-0',
aktenzeichen: 'XVII 412/25',
},
{
betreuteId: betreute.id,
name: 'Herr Doblinger',
organisation: 'Bezirk Oberfranken, Sozialverwaltung',
rolle: 'SACHBEARBEITER_BEZIRK',
telefon: '0921 604-1234',
email: 'sozialverwaltung@bezirk-oberfranken.de',
aktenzeichen: 'HzP 2026/04711',
},
{
betreuteId: betreute.id,
name: 'Frau Kellner',
organisation: 'Seniorenzentrum St. Katharina',
rolle: 'SOZIALDIENST_HEIM',
telefon: '0921 123456-30',
},
{
betreuteId: betreute.id,
name: 'Dr. med. Reuther',
organisation: 'Hausarztpraxis Bindlach',
rolle: 'HAUSARZT',
telefon: '09208 4477',
},
],
})
// --- Konten und Vermoegen ----------------------------------------------
const giro = await db.konto.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Girokonto',
bank: 'Sparkasse Bayreuth',
art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: {
create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
{ stichtag: datum('2026-07-31'), betragCent: cent(2967.11), quelle: 'Kontoauszug 07/2026' },
],
},
},
})
await db.konto.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Verwahrgeld im Heim',
bank: 'Seniorenzentrum St. Katharina',
art: 'VERWAHRGELD_HEIM',
saldostaende: {
create: { stichtag: datum('2026-07-31'), betragCent: cent(184.2), quelle: 'Abrechnung Heim' },
},
},
})
const immobilie = await db.immobilie.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Einfamilienhaus Bindlach',
strasse: 'Am Sonnenhang 4',
plz: '95463',
ort: 'Bindlach',
grundbuchamt: 'Amtsgericht Bayreuth',
grundbuchBlatt: '3241',
gemarkung: 'Bindlach',
flurstueck: '812/3',
eigentumsanteil: '1/1',
wohnflaecheQm: 128,
grundstueckQm: 640,
baujahr: 1972,
bewohntStatus: 'leerstehend',
leerstandSeit: datum('2026-01-19'),
statusSozialhilfe: 'UNGEKLAERT',
statusBegruendungVerschluesselt: verschluesseln(
'Nicht mehr selbst bewohnt, seit dem Heimeinzug leerstehend. Schutz nach ' +
'§ 90 Abs. 2 Nr. 8 SGB XII greift daher voraussichtlich nicht. Zu prüfen: ' +
'Härtefall nach § 90 Abs. 3 SGB XII und Darlehen nach § 91 SGB XII statt ' +
'sofortiger Verwertung. Mit dem Bezirk noch nicht abschließend geklärt.',
'statusBegruendungVerschluesselt',
),
belastungen: {
create: {
art: 'Grundschuld',
abteilung: 'III',
laufendeNr: '2',
glaeubiger: 'Sparkasse Bayreuth',
betragCent: cent(30000),
bemerkung: 'Valutiert nach Auskunft der Bank nicht mehr, Löschungsbewilligung angefordert.',
},
},
},
})
const lebensversicherung = await db.vertrag.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Kapitallebensversicherung',
gesellschaft: 'Musterland Leben AG',
art: 'LEBENSVERSICHERUNG',
status: 'BEITRAGSFREI',
vertragsnummerVerschluesselt: verschluesseln('LV-8842119', 'vertragsnummerVerschluesselt'),
beginnAm: datum('1998-07-01'),
endeAm: datum('2028-07-01'),
bewertungSozialhilfe: 'STRITTIG',
bewertungBegruendungVerschluesselt: verschluesseln(
'Rückkaufswert liegt über dem Schonvermögen. Verwertung wäre grundsätzlich zumutbar, ' +
'aber der Vertrag läuft 2028 aus — Kündigung jetzt bedeutet spürbaren Verlust. ' +
'Härtefall nach § 90 Abs. 3 SGB XII geltend gemacht, Antwort des Bezirks steht aus.',
'bewertungBegruendungVerschluesselt',
),
rueckkaufswertAngefragtAm: datum('2026-03-02'),
},
})
await db.vertrag.create({
data: {
betreuteId: betreute.id,
bezeichnung: 'Sterbegeldversicherung',
gesellschaft: 'Nordheim Sterbekasse VVaG',
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
vertragsnummerVerschluesselt: verschluesseln('SG-220417', 'vertragsnummerVerschluesselt'),
beitragCent: cent(18.4),
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'GESCHUETZT',
bewertungBegruendungVerschluesselt: verschluesseln(
'Angemessene Bestattungsvorsorge. Nach § 90 Abs. 3 SGB XII und ständiger ' +
'Rechtsprechung regelmäßig geschützt, weil sie der Würde der Bestattung dient.',
'bewertungBegruendungVerschluesselt',
),
},
})
await db.vermoegensposten.createMany({
data: [
{ betreuteId: betreute.id, bezeichnung: 'Girokonto Sparkasse', art: 'KONTO', kontoId: giro.id, bewertungSozialhilfe: 'VERWERTBAR' },
{ betreuteId: betreute.id, bezeichnung: 'Einfamilienhaus Bindlach', art: 'IMMOBILIE', immobilieId: immobilie.id, bewertungSozialhilfe: 'STRITTIG' },
{ betreuteId: betreute.id, bezeichnung: 'Kapitallebensversicherung', art: 'VERSICHERUNG', vertragId: lebensversicherung.id, bewertungSozialhilfe: 'STRITTIG' },
],
})
const posten = await db.vermoegensposten.findMany({ where: { betreuteId: betreute.id } })
for (const p of posten) {
const werte: Record<string, { wert: number; quelle: string }> = {
IMMOBILIE: { wert: 268000, quelle: 'Bodenrichtwert und Vergleichspreise, eigene Schätzung' },
VERSICHERUNG: { wert: 14820.55, quelle: 'Rückkaufswertmitteilung vom 26.03.2026' },
KONTO: { wert: 2967.11, quelle: 'Kontoauszug 07/2026' },
}
const w = werte[p.art]
if (w) {
await db.wertstand.create({
data: {
vermoegenspostenId: p.id,
stichtag: datum('2026-07-31'),
wertCent: cent(w.wert),
wertquelle: w.quelle,
},
})
}
}
// --- Sozialhilfeantrag mit Nachforderungen ------------------------------
const antrag = await db.sozialhilfeAntrag.create({
data: {
betreuteId: betreute.id,
traeger: 'Bezirk Oberfranken',
leistungsart: 'Hilfe zur Pflege nach dem 7. Kapitel SGB XII',
aktenzeichen: 'HzP 2026/04711',
sachbearbeiter: 'Herr Doblinger',
telefon: '0921 604-1234',
antragAm: datum('2026-02-26'),
eingangBeimTraegerAm: datum('2026-03-02'),
},
})
await db.nachforderung.createMany({
data: [
{ antragId: antrag.id, bezeichnung: 'Rentenbescheide aller Träger', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 1 },
{ antragId: antrag.id, bezeichnung: 'Kontoauszüge der letzten 3 Monate, lückenlos', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 2 },
{ antragId: antrag.id, bezeichnung: 'Pflegegradbescheid', status: 'ANERKANNT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-03-26'), ausCheckliste: true, reihenfolge: 3 },
{ antragId: antrag.id, bezeichnung: 'Heimvertrag', status: 'UEBERSANDT', angefordertAm: datum('2026-03-18'), uebersandtAm: datum('2026-04-08'), ausCheckliste: true, reihenfolge: 4 },
{ antragId: antrag.id, bezeichnung: 'Aktueller Grundbuchauszug', status: 'UEBERSANDT', angefordertAm: datum('2026-04-21'), uebersandtAm: datum('2026-05-06'), ausCheckliste: true, reihenfolge: 5 },
{ antragId: antrag.id, bezeichnung: 'Rückkaufswert Kapitallebensversicherung zum Antragsdatum', status: 'IN_BESCHAFFUNG', angefordertAm: datum('2026-04-21'), fristBis: datum('2026-09-05'), erlaeuterung: 'Zweite Anfrage an die Gesellschaft am 12.08.2026 raus.', ausCheckliste: true, reihenfolge: 6 },
{ antragId: antrag.id, bezeichnung: 'Notarielle Verträge früherer Grundstücksübertragungen', status: 'OFFEN', angefordertAm: datum('2026-04-21'), fristBis: datum('2026-09-05'), erlaeuterung: 'Der Träger prüft eine Rückforderung wegen Verarmung (§ 528 BGB). Notariat Bayreuth angefragt.', ausCheckliste: true, reihenfolge: 7 },
{ antragId: antrag.id, bezeichnung: 'Erklärung zu Unterhaltspflichtigen', status: 'OFFEN', angefordertAm: datum('2026-07-30'), fristBis: datum('2026-08-31'), ausCheckliste: true, reihenfolge: 8 },
],
})
// --- Vorgaenge ----------------------------------------------------------
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Antrag Hilfe zur Pflege beim Bezirk Oberfranken',
kategorie: 'SOZIALHILFE',
status: 'WARTET_AUF_DRITTE',
gegenstelle: 'Bezirk Oberfranken',
aktenzeichen: 'HzP 2026/04711',
naechsterSchritt: 'Rückkaufswert und Übertragungsverträge nachreichen, dann Sachstand erfragen',
letzterKontaktAm: datum('2026-07-30'),
angelegtVonId: userId,
beschreibungVerschluesselt: verschluesseln(
'Antrag vom 26.02.2026. Der Bezirk hat in zwei Wellen nachgefordert. ' +
'Offen sind noch der Rückkaufswert der Lebensversicherung und die notariellen ' +
'Übertragungsverträge aus den 1990er-Jahren.',
'beschreibungVerschluesselt',
),
},
})
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Haus Bindlach: Verwertung oder Darlehen klären',
kategorie: 'IMMOBILIE',
status: 'IN_ARBEIT',
gegenstelle: 'Bezirk Oberfranken',
genehmigungspflichtig: true,
naechsterSchritt: 'Verkehrswert belastbar ermitteln lassen, dann Gespräch mit dem Bezirk',
angelegtVonId: userId,
beschreibungVerschluesselt: verschluesseln(
'Das Haus ist seit dem Heimeinzug leerstehend. Ein Verkauf wäre nach § 1850 BGB ' +
'genehmigungspflichtig. Vorrangig prüfen: Darlehen nach § 91 SGB XII gegen ' +
'Grundschuld, damit nicht unter Zeitdruck verkauft werden muss.',
'beschreibungVerschluesselt',
),
},
})
await db.vorgang.create({
data: {
betreuteId: betreute.id,
titel: 'Aufwandspauschale für das erste Betreuungsjahr beantragen',
kategorie: 'GERICHT',
status: 'OFFEN',
gegenstelle: 'Amtsgericht Bayreuth',
aktenzeichen: 'XVII 412/25',
naechsterSchritt: 'Antrag nach Ablauf des Betreuungsjahres am 13.02.2027 stellen',
angelegtVonId: userId,
},
})
// --- Kontaktprotokolle --------------------------------------------------
const besuche: { tag: string; dauer: number; inhalt: string; wunsch?: string }[] = [
{ tag: '2026-06-14', dauer: 75, inhalt: 'Besuch im Heim. Gemeinsam im Garten gesessen. Atmung an diesem Tag ruhig, Sauerstoff 2 l/min. Über das Haus gesprochen.', wunsch: 'Möchte das Haus nicht verkaufen, solange es nicht sein muss.' },
{ tag: '2026-07-05', dauer: 60, inhalt: 'Besuch. Erschöpft, blieb im Zimmer. Pflegedienstleitung berichtet von zwei kurzen Luftnotepisoden in der Vorwoche.' },
{ tag: '2026-07-22', dauer: 30, inhalt: 'Telefonat mit dem Sozialdienst wegen der Heimabrechnung Juli.' },
{ tag: '2026-08-09', dauer: 90, inhalt: 'Besuch mit Enkelin. Deutlich wacher als beim letzten Mal. Wünscht sich häufiger Besuch am Nachmittag.', wunsch: 'Besuche bitte nachmittags, vormittags ist sie zu müde.' },
]
for (const b of besuche) {
await db.kontaktprotokoll.create({
data: {
betreuteId: betreute.id,
datum: new Date(`${b.tag}T15:00:00.000Z`),
art: b.dauer === 30 ? 'TELEFONAT' : 'BESUCH',
dauerMinuten: b.dauer,
mitBetreuter: b.dauer !== 30,
inhaltVerschluesselt: verschluesseln(b.inhalt, 'inhaltVerschluesselt'),
wuenscheVerschluesselt: b.wunsch ? verschluesseln(b.wunsch, 'wuenscheVerschluesselt') : null,
},
})
}
// --- Gesundheitsverlauf -------------------------------------------------
await db.gesundheitsereignis.createMany({
data: [
{ betreuteId: betreute.id, datum: datum('2026-03-11'), art: 'EXAZERBATION', titel: 'Infektexazerbation, ambulant behandelt', behandler: 'Dr. Reuther', beschreibungVerschluesselt: verschluesseln('Antibiotikum und kurzzeitig Prednisolon. Keine Einweisung nötig.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-05-02'), bisDatum: datum('2026-05-09'), art: 'KRANKENHAUS', titel: 'Stationär wegen Luftnot', einrichtung: 'Klinikum Bayreuth', abwesenheitstage: 7, beschreibungVerschluesselt: verschluesseln('Aufnahme über die Notaufnahme. Sauerstoffbedarf danach von 1,5 auf 2 l/min erhöht.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-06-20'), art: 'HILFSMITTEL', titel: 'Mobiler Sauerstoffkonzentrator bewilligt', beschreibungVerschluesselt: verschluesseln('Ermöglicht Ausflüge aus dem Zimmer.', 'beschreibungVerschluesselt') },
{ betreuteId: betreute.id, datum: datum('2026-07-15'), art: 'IMPFUNG', titel: 'Pneumokokken-Impfung', behandler: 'Dr. Reuther' },
],
})
// --- Heimkosten ---------------------------------------------------------
for (const monat of [5, 6, 7]) {
await db.heimkostenmonat.create({
data: {
betreuteId: betreute.id,
jahr: 2026,
monat,
pflegeEigenanteilCent: cent(1489.0),
unterkunftVerpflegungCent: cent(878.4),
investitionskostenCent: cent(486.3),
ausbildungsumlageCent: cent(112.5),
pflegekasseCent: cent(1363.0),
leistungszuschlagProzent: 15,
leistungszuschlagCent: cent(223.35),
renteCent: cent(1284.62),
barbetragCent: cent(135.66),
bekleidungspauschaleCent: cent(27.13),
abwesenheitstage: monat === 5 ? 7 : 0,
},
})
}
console.log('Demofall "Erika Musterfrau" angelegt.')
}
async function main() {
const benutzer = await ersterZugang()
if (process.env.SEED_DEMO === 'true' && !istProduktion && benutzer) {
await demofall(benutzer.id)
} else if (process.env.SEED_DEMO === 'true' && istProduktion) {
console.log('SEED_DEMO wird in der Produktion nicht ausgefuehrt.')
}
console.log('\nFertig. Weiter geht es im Browser unter /ersteinrichtung.')
}
main()
.catch((fehler) => {
console.error('Seed fehlgeschlagen:', fehler)
process.exit(1)
})
.finally(() => db.$disconnect())
+264
View File
@@ -0,0 +1,264 @@
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { CalendarClock, FolderKanban, Link2, Pencil, Repeat, Scale } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { ErledigenKnopf } from '@/components/fristen/erledigen-knopf'
import { FristAktionen } from '@/components/fristen/frist-aktionen'
import { FristFormular } from '@/components/fristen/frist-formular'
import { alsAnzeige } from '@/components/fristen/typen'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import { ausIsoTag, nichtWerktagHinweis, wochentagName } from '@/lib/fristen/datum'
import { fristenKontext } from '@/lib/fristen/kontext'
import { rruleBeschreiben } from '@/lib/fristen/wiederholung'
import { FRIST_STATUS_BESCHRIFTUNG, FRIST_TYP_BESCHRIFTUNG } from '@/lib/validierung/fristen'
import { formatDatum } from '@/lib/utils'
import { fristAendern } from '@/server/actions/fristen'
/**
* Eine einzelne Frist.
*
* Das Bearbeitungsformular liegt auf derselben Adresse hinter
* "?bearbeiten=1" statt auf einer eigenen Unterseite: So bleibt der Weg
* zurueck zur Ansicht der Zurueck-Knopf des Browsers.
*/
export default async function FristSeite({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { id } = await params
const parameter = await searchParams
const { betreute, darfSchreiben } = await fristenKontext('lesen')
const datensatz = await db.frist.findFirst({
where: { id, betreuteId: betreute.id, ...nichtGeloescht },
include: {
vorgang: { select: { id: true, titel: true } },
vorgaengerFrist: { select: { id: true, titel: true, datum: true, status: true } },
nachfolgerFrist: { select: { id: true, titel: true, datum: true, status: true } },
},
})
if (!datensatz) notFound()
const frist = alsAnzeige(datensatz)
const datum = ausIsoTag(frist.datum)!
const ampel = fristAmpel(datum, frist.status)
const werktagshinweis = frist.status === 'OFFEN' ? nichtWerktagHinweis(datum) : null
const bearbeiten =
darfSchreiben &&
(Array.isArray(parameter.bearbeiten) ? parameter.bearbeiten[0] : parameter.bearbeiten) === '1'
const vorgaenge = bearbeiten
? await db.vorgang.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht },
select: { id: true, titel: true },
orderBy: { aktualisiertAm: 'desc' },
take: 100,
})
: []
return (
<div className="space-y-6">
<SeitenKopf
titel={frist.titel}
beschreibung={
<span className="inline-flex flex-wrap items-center gap-2">
<Badge variant={ampel.variante}>{ampelText(ampel, frist.ausschlussfrist)}</Badge>
<Badge variant="outline">{FRIST_TYP_BESCHRIFTUNG[frist.typ]}</Badge>
<Badge variant="secondary">{FRIST_STATUS_BESCHRIFTUNG[frist.status]}</Badge>
</span>
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: frist.titel },
]}
aktionen={
darfSchreiben && !bearbeiten ? (
<Button variant="outline" asChild>
<Link href={`/fristen/${frist.id}?bearbeiten=1`}>
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
) : undefined
}
/>
{frist.ausschlussfrist && frist.status === 'OFFEN' && (
<Alert variant="fehler">
<AlertTitle>Ausschlussfrist</AlertTitle>
<AlertDescription>
Läuft dieser Termin ab, ist der Anspruch ersatzlos verloren. Es gibt keine
Nachsicht und keine Wiedereinsetzung — auch nicht bei guten Gründen.
</AlertDescription>
</Alert>
)}
{bearbeiten ? (
<FristFormular
betreuteId={betreute.id}
aktion={fristAendern}
frist={frist}
vorgaenge={vorgaenge}
/>
) : (
<>
<dl className="grid gap-4 rounded-lg border bg-card p-5 sm:grid-cols-2">
<div>
<dt className="text-sm text-muted-foreground">Stichtag</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<CalendarClock className="size-4 shrink-0" aria-hidden="true" />
{wochentagName(datum)}, {formatDatum(datum)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Vorwarnung</dt>
<dd className="mt-0.5 font-medium">
{frist.vorwarnungTage.length === 0
? 'Keine'
: frist.vorwarnungTage.map((t) => (t === 1 ? '1 Tag' : `${t} Tage`)).join(', ') +
' vorher'}
</dd>
</div>
{frist.rechtsgrundlage && (
<div>
<dt className="text-sm text-muted-foreground">Rechtsgrundlage</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<Scale className="size-4 shrink-0" aria-hidden="true" />
{frist.rechtsgrundlage}
</dd>
</div>
)}
<div>
<dt className="text-sm text-muted-foreground">Wiederholung</dt>
<dd className="mt-0.5 flex items-center gap-2 font-medium">
<Repeat className="size-4 shrink-0" aria-hidden="true" />
{rruleBeschreiben(frist.wiederholungRrule)}
</dd>
</div>
{frist.vorgang && (
<div>
<dt className="text-sm text-muted-foreground">Gehört zum Vorgang</dt>
<dd className="mt-0.5">
<Link
href={`/vorgaenge/${frist.vorgang.id}`}
className="inline-flex items-center gap-2 font-medium hover:underline"
>
<FolderKanban className="size-4 shrink-0" aria-hidden="true" />
{frist.vorgang.titel}
</Link>
</dd>
</div>
)}
{frist.erledigtAm && (
<div>
<dt className="text-sm text-muted-foreground">Erledigt am</dt>
<dd className="mt-0.5 font-medium">{formatDatum(ausIsoTag(frist.erledigtAm))}</dd>
</div>
)}
{frist.systemSchluessel && (
<div className="sm:col-span-2">
<dt className="text-sm text-muted-foreground">Abgeleitete Pflichtfrist</dt>
<dd className="mt-0.5 text-sm">
Diese Frist wird aus dem Bestellungsdatum berechnet (Schlüssel{' '}
<code className="rounded bg-muted px-1 py-0.5">{frist.systemSchluessel}</code>).
Ändert sich das Bestellungsdatum, zieht die Anwendung das Datum nach — solange
die Frist noch offen ist.
</dd>
</div>
)}
</dl>
{werktagshinweis && (
<Alert variant="warnung">
<AlertTitle>Stichtag an einem arbeitsfreien Tag</AlertTitle>
<AlertDescription>{werktagshinweis}</AlertDescription>
</Alert>
)}
{frist.beschreibung && (
<section className="rounded-lg border bg-card p-5">
<h2 className="text-base font-semibold">Was ist zu tun?</h2>
<p className="mt-2 whitespace-pre-line text-sm leading-relaxed">
{frist.beschreibung}
</p>
</section>
)}
{(datensatz.vorgaengerFrist || datensatz.nachfolgerFrist) && (
<section className="rounded-lg border bg-card p-5">
<h2 className="flex items-center gap-2 text-base font-semibold">
<Link2 className="size-4" aria-hidden="true" />
Fristkette
</h2>
<ul className="mt-2 space-y-1 text-sm">
{datensatz.vorgaengerFrist && (
<li>
Hervorgegangen aus{' '}
<Link
href={`/fristen/${datensatz.vorgaengerFrist.id}`}
className="font-medium underline"
>
{datensatz.vorgaengerFrist.titel}
</Link>{' '}
vom {formatDatum(datensatz.vorgaengerFrist.datum)}.
</li>
)}
{datensatz.nachfolgerFrist && (
<li>
Nachfolger:{' '}
<Link
href={`/fristen/${datensatz.nachfolgerFrist.id}`}
className="font-medium underline"
>
{datensatz.nachfolgerFrist.titel}
</Link>{' '}
am {formatDatum(datensatz.nachfolgerFrist.datum)}.
</li>
)}
</ul>
</section>
)}
{darfSchreiben && (
<section className="space-y-4 rounded-lg border bg-card p-5">
<h2 className="text-base font-semibold">Was möchtest du tun?</h2>
{frist.status === 'OFFEN' && (
<ErledigenKnopf
betreuteId={betreute.id}
fristId={frist.id}
titel={frist.titel}
wiederkehrend={Boolean(frist.wiederholungRrule)}
groesse="default"
/>
)}
<FristAktionen
betreuteId={betreute.id}
fristId={frist.id}
datum={frist.datum}
ausschlussfrist={frist.ausschlussfrist}
/>
</section>
)}
</>
)}
{frist.rechtsgrundlage && <Rechtshinweis rechtsgrundlage={frist.rechtsgrundlage} />}
</div>
)
}
+108
View File
@@ -0,0 +1,108 @@
import Link from 'next/link'
import { List, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { KalenderAbo } from '@/components/fristen/kalender-abo'
import { KalenderMonat, monatsname } from '@/components/fristen/kalender-monat'
import { alsAnzeige } from '@/components/fristen/typen'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { heuteAlsTag, plusTage, tag } from '@/lib/fristen/datum'
import { feedUrlErzeugen } from '@/lib/fristen/ics'
import { fristenKontext } from '@/lib/fristen/kontext'
export const metadata = { title: 'Fristenkalender' }
/**
* Monatsansicht.
*
* Geladen wird ein Puffer von einer Woche vor und nach dem Monat: Das
* Raster zeigt am Rand Tage der Nachbarmonate, und dort sollen keine
* Fristen unsichtbar bleiben.
*/
export default async function KalenderSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { betreute, benutzer, darfSchreiben } = await fristenKontext('lesen')
const parameter = await searchParams
const einzeln = (name: string): string => {
const wert = parameter[name]
return (Array.isArray(wert) ? wert[0] : wert) ?? ''
}
const heute = heuteAlsTag()
const jahr = ganzzahl(einzeln('jahr'), heute.getUTCFullYear(), 1900, 2200)
const monat = ganzzahl(einzeln('monat'), heute.getUTCMonth() + 1, 1, 12)
const monatsanfang = tag(jahr, monat, 1)
const monatsende = plusTage(tag(monat === 12 ? jahr + 1 : jahr, monat === 12 ? 1 : monat + 1, 1), -1)
const datensaetze = await db.frist.findMany({
where: {
betreuteId: betreute.id,
...nichtGeloescht,
datum: { gte: plusTage(monatsanfang, -7), lte: plusTage(monatsende, 7) },
},
orderBy: [{ datum: 'asc' }, { ausschlussfrist: 'desc' }],
include: { vorgang: { select: { id: true, titel: true } } },
})
const feedUrl = feedUrlErzeugen(betreute.id, benutzer.id)
return (
<div className="space-y-6">
<SeitenKopf
titel="Fristenkalender"
beschreibung={`Alle Termine im Überblick. Am Handy als Tagesliste, am Bildschirm als Monat.`}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: 'Kalender' },
]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/fristen">
<List aria-hidden="true" />
Liste
</Link>
</Button>
{darfSchreiben && (
<Button asChild>
<Link href="/fristen/neu">
<Plus aria-hidden="true" />
Frist anlegen
</Link>
</Button>
)}
</>
}
/>
{(jahr !== heute.getUTCFullYear() || monat !== heute.getUTCMonth() + 1) && (
<div>
<Button variant="ghost" size="sm" asChild>
<Link href="/fristen/kalender">
Zurück zum {monatsname(heute.getUTCMonth() + 1)} {heute.getUTCFullYear()}
</Link>
</Button>
</div>
)}
<KalenderMonat jahr={jahr} monat={monat} fristen={datensaetze.map(alsAnzeige)} />
<KalenderAbo feedUrl={feedUrl} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div>
)
}
function ganzzahl(text: string, standard: number, min: number, max: number): number {
const zahl = Number.parseInt(text, 10)
if (Number.isNaN(zahl) || zahl < min || zahl > max) return standard
return zahl
}
+41
View File
@@ -0,0 +1,41 @@
import { FristFormular } from '@/components/fristen/frist-formular'
import { SeitenKopf } from '@/components/seiten-kopf'
import { db, nichtGeloescht } from '@/lib/db'
import { fristenKontext } from '@/lib/fristen/kontext'
import { fristAnlegen } from '@/server/actions/fristen'
export const metadata = { title: 'Frist anlegen' }
/**
* Neue Frist.
*
* Der Schreibzugriff wird hier geprueft und in der Server Action noch
* einmal - die Seite auszublenden reicht nicht, ein Formular laesst sich
* auch von aussen abschicken.
*/
export default async function NeueFristSeite() {
const { betreute } = await fristenKontext('schreiben')
const vorgaenge = await db.vorgang.findMany({
where: { betreuteId: betreute.id, ...nichtGeloescht, erledigtAm: null },
select: { id: true, titel: true },
orderBy: { aktualisiertAm: 'desc' },
take: 100,
})
return (
<div className="space-y-6">
<SeitenKopf
titel="Frist anlegen"
beschreibung="Für alles, was einen Stichtag hat: Widerspruchsfristen aus Bescheiden, Wiedervorlagen, eigene Vorhaben."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Fristen', href: '/fristen' },
{ titel: 'Neu' },
]}
/>
<FristFormular betreuteId={betreute.id} aktion={fristAnlegen} vorgaenge={vorgaenge} />
</div>
)
}
+232
View File
@@ -0,0 +1,232 @@
import Link from 'next/link'
import type { Prisma } from '@prisma/client'
import { CalendarDays, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Filterleiste } from '@/components/fristen/filterleiste'
import { Fristenliste } from '@/components/fristen/fristenliste'
import { PflichtfristenKnopf } from '@/components/fristen/pflichtfristen-knopf'
import { alsAnzeige } from '@/components/fristen/typen'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag, plusJahre, plusTage } from '@/lib/fristen/datum'
import { fristenKontext } from '@/lib/fristen/kontext'
import { FRIST_STATUS, FRIST_TYPEN } from '@/lib/validierung/fristen'
export const metadata = { title: 'Fristen' }
/**
* Die Fristenliste.
*
* Standardansicht ist "alles Offene", gruppiert nach Dringlichkeit. Wer die
* Seite morgens oeffnet, soll oben sehen, was brennt - nicht eine nach
* Datum sortierte Tabelle, in der das Ueberfaellige zwischen dem Erledigten
* verschwindet.
*/
export default async function FristenSeite({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const { betreute, benutzer, darfSchreiben } = await fristenKontext('lesen')
const parameter = await searchParams
const einzeln = (name: string): string => {
const wert = parameter[name]
return (Array.isArray(wert) ? wert[0] : wert) ?? ''
}
const zeitraum = einzeln('zeitraum') || 'offen'
const kategorie = einzeln('kategorie')
const typ = einzeln('typ')
const status = einzeln('status')
const heute = heuteAlsTag()
const wo: Prisma.FristWhereInput = { betreuteId: betreute.id, ...nichtGeloescht }
// --- Zeitraum -------------------------------------------------------------
switch (zeitraum) {
case 'ueberfaellig':
wo.status = 'OFFEN'
wo.datum = { lt: heute }
break
case 'woche':
wo.status = 'OFFEN'
wo.datum = { lte: plusTage(heute, 7) }
break
case 'monat':
wo.status = 'OFFEN'
wo.datum = { lte: plusTage(heute, 31) }
break
case 'jahr':
wo.status = 'OFFEN'
wo.datum = { lte: plusJahre(heute, 1) }
break
case 'alle':
break
default:
// "offen" ist der Standard: alles, was noch zu tun ist.
wo.status = 'OFFEN'
}
// Ein ausdruecklich gewaehlter Status schlaegt die Voreinstellung des
// Zeitraums - sonst liesse sich "nur Entfallenes" nie anzeigen.
if (status && (FRIST_STATUS as readonly string[]).includes(status)) {
wo.status = status as (typeof FRIST_STATUS)[number]
}
if (typ && (FRIST_TYPEN as readonly string[]).includes(typ)) {
wo.typ = typ as (typeof FRIST_TYPEN)[number]
}
// --- Kategorie ------------------------------------------------------------
switch (kategorie) {
case 'pflicht':
wo.systemSchluessel = { not: null }
break
case 'eigene':
wo.systemSchluessel = null
break
case 'ausschluss':
wo.ausschlussfrist = true
break
case 'wiederkehrend':
wo.wiederholungRrule = { not: null }
break
default:
break
}
const [datensaetze, gesamtOffen, ueberfaellig] = await Promise.all([
db.frist.findMany({
where: wo,
orderBy: [{ datum: 'asc' }],
include: { vorgang: { select: { id: true, titel: true } } },
take: 500,
}),
db.frist.count({ where: { betreuteId: betreute.id, status: 'OFFEN', ...nichtGeloescht } }),
db.frist.count({
where: {
betreuteId: betreute.id,
status: 'OFFEN',
datum: { lt: heute },
...nichtGeloescht,
},
}),
])
const fristen = datensaetze.map(alsAnzeige)
const verfallsgefahr = fristen.filter(
(f) => f.ausschlussfrist && f.status === 'OFFEN' && f.datum <= alsIsoTag(plusTage(heute, 90)),
)
return (
<div className="space-y-6">
<SeitenKopf
titel="Fristen"
beschreibung={
gesamtOffen === 0
? 'Hier stehen alle Termine, die eingehalten werden müssen — gesetzliche wie selbst gesetzte.'
: `${gesamtOffen} offene Frist${gesamtOffen === 1 ? '' : 'en'}` +
(ueberfaellig > 0 ? `, davon ${ueberfaellig} überfällig.` : '.')
}
pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Fristen' }]}
aktionen={
<>
<Button variant="outline" asChild>
<Link href="/fristen/kalender">
<CalendarDays aria-hidden="true" />
Kalender
</Link>
</Button>
{darfSchreiben && (
<Button asChild>
<Link href="/fristen/neu">
<Plus aria-hidden="true" />
Frist anlegen
</Link>
</Button>
)}
</>
}
/>
{verfallsgefahr.length > 0 && (
<Alert variant="fehler">
<AlertTitle>
{verfallsgefahr.length === 1
? 'Eine Ausschlussfrist läuft'
: `${verfallsgefahr.length} Ausschlussfristen laufen`}
</AlertTitle>
<AlertDescription>
<p>
Läuft eine Ausschlussfrist ab, ist der Anspruch ersatzlos weg. Bei der
Aufwandspauschale nach § 1878 BGB ist damit die Vergütung für ein ganzes
Betreuungsjahr verloren — ohne Nachsicht, ohne Wiedereinsetzung.
</p>
<ul className="mt-2 list-inside list-disc">
{verfallsgefahr.map((f) => (
<li key={f.id}>
<Link href={`/fristen/${f.id}`} className="underline">
{f.titel}
</Link>
</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Filterleiste />
{fristen.length === 0 ? (
<Leerzustand
titel={
gesamtOffen === 0 ? 'Noch keine Fristen erfasst' : 'Zu diesem Filter passt nichts'
}
beschreibung={
gesamtOffen === 0
? betreute.bestellungAm
? 'Leite zuerst die gesetzlichen Pflichtfristen aus dem Bestellungsdatum ab — Anfangsbericht, Vermögensverzeichnis, Jahresbericht und die Ausschlussfrist für die Aufwandspauschale.'
: 'Trag zuerst das Bestellungsdatum in den Stammdaten ein. Daraus ergeben sich fast alle gesetzlichen Fristen.'
: 'Setz den Filter zurück, um alle Fristen zu sehen.'
}
aktion={
darfSchreiben && gesamtOffen === 0 && betreute.bestellungAm ? (
<PflichtfristenKnopf betreuteId={betreute.id} variante="default" />
) : (
<Button variant="outline" asChild>
<Link href="/fristen">Alle Fristen anzeigen</Link>
</Button>
)
}
/>
) : (
<Fristenliste fristen={fristen} darfSchreiben={darfSchreiben} />
)}
{darfSchreiben && fristen.length > 0 && (
<div className="rounded-lg border bg-card p-4">
<h2 className="text-base font-semibold">Gesetzliche Pflichtfristen</h2>
<p className="mt-1 text-sm text-muted-foreground">
Berechnet Anfangsbericht, Vermögensverzeichnis, Jahresbericht, Vermögensübersicht
und die Ausschlussfrist für die Aufwandspauschale neu aus dem Bestellungsdatum
{betreute.bestellungAm ? '' : ' — das in den Stammdaten noch fehlt'}. Mehrfaches
Ableiten legt nichts doppelt an.
</p>
<div className="mt-3">
<PflichtfristenKnopf betreuteId={betreute.id} />
</div>
</div>
)}
<Rechtshinweis rechtsgrundlage="§ 1863 BGB, § 1865 BGB, § 1878 BGB" />
<p className="sr-only">
Angemeldet als {benutzer.name}. Fristen der betreuten Person {betreute.vorname}{' '}
{betreute.nachname}.
</p>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { BelegeKachel } from '@/components/dashboard/belege-kachel'
import { FristenAmpel } from '@/components/dashboard/fristen-ampel'
import { Kennzahlen } from '@/components/dashboard/kennzahlen'
import { NachforderungenKachel } from '@/components/dashboard/nachforderungen-kachel'
import { PosteingangKachel } from '@/components/dashboard/posteingang-kachel'
import { WartetAufAndere } from '@/components/dashboard/wartet-auf-andere'
import { darf } from '@/lib/auth/rollen'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { dashboardLaden } from '@/lib/dashboard/laden'
export const metadata: Metadata = { title: 'Heute' }
/**
* Das Dashboard.
*
* Es beantwortet genau eine Frage: was muss ich heute tun? Deshalb endet
* jede Kachel in einer Handlung, und die Reihenfolge ist Dringlichkeit,
* nicht fachliche Systematik. Am Handy einspaltig von oben nach unten, am
* Schreibtisch zweispaltig - die Fristen-Ampel steht immer zuoberst.
*/
export default async function HeutePage() {
const benutzer = await erfordereBenutzer('/heute')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) redirect('/ersteinrichtung')
// Das Dashboard fasst mehrere Module zusammen. Der Zugang wird gegen die
// Stammdaten geprueft; jede einzelne Kachel entfaellt, wenn ihr Modul
// nicht freigegeben ist.
const { betreute, zugang } = await erfordereZugang(aktive.id, 'STAMMDATEN', 'lesen')
const daten = await dashboardLaden(betreute, (modul) => darf(zugang, modul, 'lesen'))
const heuteText = new Intl.DateTimeFormat('de-DE', {
weekday: 'long',
day: '2-digit',
month: 'long',
year: 'numeric',
}).format(daten.heute)
return (
<div className="space-y-6">
<SeitenKopf
titel="Heute"
beschreibung={
<>
{heuteText} · Betreuung für {daten.name}
</>
}
aktionen={
<>
<Button asChild variant="outline">
<Link href="/kontakte/protokoll/neu">Kontakt festhalten</Link>
</Button>
<Button asChild>
<Link href="/dokumente/neu?quelle=kamera">Beleg erfassen</Link>
</Button>
</>
}
/>
{/* Die Ampel steht immer oben - in jeder Bildschirmbreite. */}
<FristenAmpel daten={daten.fristen} />
{/*
Am Handy laeuft alles einspaltig in der Reihenfolge der
Dringlichkeit: Nachforderungen (Geld und Fristen des Traegers),
dann Vorgaenge, dann Post, dann Belege.
*/}
<div className="grid gap-4 lg:grid-cols-2">
<NachforderungenKachel daten={daten.nachforderungen} />
<WartetAufAndere daten={daten.wartet} />
<PosteingangKachel daten={daten.posteingang} />
<BelegeKachel daten={daten.belege} />
</div>
<Kennzahlen daten={daten.kennzahlen} />
</div>
)
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation'
/**
* Die Wurzel der Anwendung hat keinen eigenen Inhalt: Wer angemeldet ist,
* will wissen, was heute zu tun ist.
*/
export default function StartPage() {
redirect('/heute')
}
+209
View File
@@ -0,0 +1,209 @@
import type { Metadata } from 'next'
import { ShieldCheck } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { SeitenKopf, Leerzustand } from '@/components/seiten-kopf'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { entschluesselnWennMoeglich } from '@/lib/crypto/feld'
import { formatDatumZeit } from '@/lib/utils'
import type { AuditAktion } from '@prisma/client'
export const metadata: Metadata = { title: 'Protokoll' }
export const dynamic = 'force-dynamic'
const AKTION_BESCHRIFTUNG: Record<AuditAktion, string> = {
ERSTELLT: 'angelegt',
GEAENDERT: 'geändert',
GELOESCHT: 'in den Papierkorb',
WIEDERHERGESTELLT: 'wiederhergestellt',
ENDGUELTIG_GELOESCHT: 'endgültig gelöscht',
ANGESEHEN: 'angesehen',
EXPORTIERT: 'exportiert',
DOKUMENT_HERUNTERGELADEN: 'Dokument geöffnet',
ANMELDUNG: 'angemeldet',
ANMELDUNG_FEHLGESCHLAGEN: 'Anmeldung fehlgeschlagen',
ABMELDUNG: 'abgemeldet',
ZWEITER_FAKTOR_EINGERICHTET: 'zweiter Faktor eingerichtet',
PASSWORT_GEAENDERT: 'Passwort geändert',
ROLLE_GEAENDERT: 'Rolle geändert',
FREIGABE_GEAENDERT: 'Freigabe geändert',
}
function variante(aktion: AuditAktion) {
if (aktion === 'ANMELDUNG_FEHLGESCHLAGEN' || aktion === 'ENDGUELTIG_GELOESCHT') return 'rot'
if (aktion === 'GELOESCHT' || aktion === 'EXPORTIERT') return 'orange'
if (aktion === 'ERSTELLT') return 'gruen'
return 'secondary'
}
type Aenderung = Record<string, { alt: unknown; neu: unknown }>
function alsText(wert: unknown): string {
if (wert === null || wert === undefined || wert === '') return '—'
if (typeof wert === 'boolean') return wert ? 'ja' : 'nein'
if (Array.isArray(wert)) return wert.length ? wert.join(', ') : '—'
return String(wert)
}
export default async function ProtokollSeite({
searchParams,
}: {
searchParams: Promise<{ seite?: string }>
}) {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
await erfordereZugang(aktive.id, 'STAMMDATEN')
const { seite = '1' } = await searchParams
const proSeite = 50
const nummer = Math.max(1, Number.parseInt(seite, 10) || 1)
const [eintraege, gesamt] = await Promise.all([
db.auditLog.findMany({
where: { betreuteId: aktive.id },
orderBy: { zeitpunkt: 'desc' },
skip: (nummer - 1) * proSeite,
take: proSeite,
}),
db.auditLog.count({ where: { betreuteId: aktive.id } }),
])
const seiten = Math.max(1, Math.ceil(gesamt / proSeite))
return (
<div className="space-y-6">
<SeitenKopf
titel="Protokoll"
beschreibung="Jede Änderung an den Daten dieser Betreuung, lückenlos und in der Reihenfolge, in der sie geschehen ist."
/>
<Alert variant="hinweis">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Unveränderlich — und das ist Absicht</AlertTitle>
<AlertDescription>
Dieses Protokoll kann niemand nachträglich ändern oder löschen, auch nicht mit direktem
Zugriff auf die Datenbank: ein Datenbank-Trigger weist jedes UPDATE und jedes DELETE ab.
Damit ist es dein Nachweis gegenüber dem Betreuungsgericht und gegenüber möglichen
Miterben, dass du sauber gearbeitet hast.
</AlertDescription>
</Alert>
{eintraege.length === 0 ? (
<Leerzustand
titel="Noch keine Einträge"
beschreibung="Sobald du Daten anlegst oder änderst, steht es hier."
/>
) : (
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-44">Zeitpunkt</TableHead>
<TableHead className="w-40">Wer</TableHead>
<TableHead className="w-40">Was</TableHead>
<TableHead>Gegenstand</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{eintraege.map((eintrag) => {
const roh = entschluesselnWennMoeglich(
eintrag.aenderungVerschluesselt,
'aenderungVerschluesselt',
)
let aenderung: Aenderung | null = null
try {
aenderung = roh ? (JSON.parse(roh) as Aenderung) : null
} catch {
aenderung = null
}
return (
<TableRow key={eintrag.id} className="align-top">
<TableCell className="whitespace-nowrap tabular-nums">
{formatDatumZeit(eintrag.zeitpunkt)}
</TableCell>
<TableCell>
<span className="block truncate">{eintrag.userName ?? '—'}</span>
<span className="block truncate text-xs text-muted-foreground">
{eintrag.userEmail}
</span>
</TableCell>
<TableCell>
<Badge variant={variante(eintrag.aktion)}>
{AKTION_BESCHRIFTUNG[eintrag.aktion]}
</Badge>
</TableCell>
<TableCell>
<span className="font-medium">{eintrag.entitaet}</span>
{eintrag.bezeichnung && (
<span className="text-muted-foreground"> · {eintrag.bezeichnung}</span>
)}
{aenderung && Object.keys(aenderung).length > 0 && (
<details className="mt-1.5">
<summary className="cursor-pointer text-sm text-muted-foreground hover:text-foreground">
{Object.keys(aenderung).length} Feld
{Object.keys(aenderung).length === 1 ? '' : 'er'} geändert
</summary>
<dl className="mt-2 space-y-1.5 border-l-2 pl-3 text-sm">
{Object.entries(aenderung).map(([feld, werte]) => (
<div key={feld}>
<dt className="text-xs font-medium text-muted-foreground">
{feld}
</dt>
<dd className="flex flex-wrap items-baseline gap-1.5">
<span className="text-muted-foreground line-through">
{alsText(werte.alt)}
</span>
<span aria-hidden="true">→</span>
<span className="font-medium">{alsText(werte.neu)}</span>
</dd>
</div>
))}
</dl>
</details>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</CardContent>
</Card>
)}
{seiten > 1 && (
<nav aria-label="Seiten" className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">
Seite {nummer} von {seiten} · {gesamt} Einträge
</span>
<span className="flex gap-2">
{nummer > 1 && (
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer - 1}`}>
Neuere
</a>
)}
{nummer < seiten && (
<a className="underline hover:no-underline" href={`/protokoll?seite=${nummer + 1}`}>
Ältere
</a>
)}
</span>
</nav>
)}
</div>
)
}
@@ -0,0 +1,433 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import Link from 'next/link'
import { AlertCircle, Loader2, Save } from 'lucide-react'
import { betreuteAendern, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
/** Prisma liefert Date, das Datumsfeld im Browser will "JJJJ-MM-TT". */
function alsDatumswert(datum: string | null | undefined): string {
if (!datum) return ''
return datum.slice(0, 10)
}
export type StammdatenWerte = {
id: string
vorname: string
nachname: string
geburtsname: string | null
geburtsdatum: string
geburtsort: string | null
anrede: string | null
heimName: string | null
heimStrasse: string | null
heimPlz: string | null
heimOrt: string | null
heimZimmer: string | null
heimTelefon: string | null
heimEinzugAm: string | null
fruehereStrasse: string | null
frueherePlz: string | null
fruehererOrt: string | null
krankenkasse: string | null
krankenversichertennummer: string | null
pflegekasse: string | null
pflegegrad: number | null
pflegegradSeit: string | null
pflegegradBescheidVom: string | null
schwerbehinderungGdB: number | null
schwerbehinderungMerkzeichen: string | null
diagnosen: string | null
ermittelterWille: string | null
patientenverfuegungVorhanden: boolean
vorsorgevollmachtVorhanden: boolean
betreuungsgericht: string | null
aktenzeichenGericht: string | null
bestellungAm: string | null
bestellungBis: string | null
aufgabenkreise: string[]
aufgabenkreiseFreitext: string | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
aktenzeichenBezirk: string | null
aktenzeichenSozialamt: string | null
rentenversicherungsnummer: string | null
steuerId: string | null
notizen: string | null
}
function Speichern() {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? <Loader2 className="animate-spin" aria-hidden="true" /> : <Save aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Speichern'}
</Button>
)
}
export function StammdatenFormular({ werte }: { werte: StammdatenWerte }) {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAendern, {})
/** Nach einem Fehlversuch gilt die Eingabe, sonst der gespeicherte Wert. */
const wert = (feld: keyof StammdatenWerte): string => {
const ausFehlversuch = zustand.werte?.[feld as string]
if (ausFehlversuch !== undefined) return ausFehlversuch
const gespeichert = werte[feld]
if (gespeichert === null || gespeichert === undefined) return ''
return String(gespeichert)
}
return (
<form action={aktion} className="space-y-6" noValidate>
<input type="hidden" name="betreuteId" value={werte.id} />
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{/* --- Person --------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Person</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="anrede" beschriftung="Anrede" fehler={zustand.felder?.anrede}>
<Input name="anrede" defaultValue={wert('anrede')} placeholder="Frau" />
</Feld>
<div className="hidden sm:block" />
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={wert('vorname')} required />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={wert('nachname')} required />
</Feld>
<Feld id="geburtsname" beschriftung="Geburtsname" fehler={zustand.felder?.geburtsname}>
<Input name="geburtsname" defaultValue={wert('geburtsname')} />
</Feld>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={alsDatumswert(wert('geburtsdatum'))}
required
/>
</Feld>
<Feld id="geburtsort" beschriftung="Geburtsort" fehler={zustand.felder?.geburtsort}>
<Input name="geburtsort" defaultValue={wert('geburtsort')} />
</Feld>
</CardContent>
</Card>
{/* --- Aufenthalt ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Aufenthalt und Einrichtung</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld id="heimName" beschriftung="Name der Einrichtung" fehler={zustand.felder?.heimName}>
<Input name="heimName" defaultValue={wert('heimName')} />
</Feld>
<Feld id="heimZimmer" beschriftung="Zimmer" fehler={zustand.felder?.heimZimmer}>
<Input name="heimZimmer" defaultValue={wert('heimZimmer')} />
</Feld>
<Feld id="heimStrasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.heimStrasse}>
<Input name="heimStrasse" defaultValue={wert('heimStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="heimPlz" beschriftung="PLZ" fehler={zustand.felder?.heimPlz}>
<Input name="heimPlz" defaultValue={wert('heimPlz')} inputMode="numeric" />
</Feld>
<Feld id="heimOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.heimOrt}>
<Input name="heimOrt" defaultValue={wert('heimOrt')} />
</Feld>
</div>
<Feld id="heimTelefon" beschriftung="Telefon der Einrichtung" fehler={zustand.felder?.heimTelefon}>
<Input name="heimTelefon" type="tel" defaultValue={wert('heimTelefon')} />
</Feld>
<Feld id="heimEinzugAm" beschriftung="Eingezogen am" fehler={zustand.felder?.heimEinzugAm}>
<Input name="heimEinzugAm" type="date" defaultValue={alsDatumswert(wert('heimEinzugAm'))} />
</Feld>
<Feld
id="fruehereStrasse"
beschriftung="Frühere Straße"
erklaerung="Die letzte eigene Wohnanschrift. Behörden fragen danach."
fehler={zustand.felder?.fruehereStrasse}
>
<Input name="fruehereStrasse" defaultValue={wert('fruehereStrasse')} />
</Feld>
<div className="grid grid-cols-3 gap-3">
<Feld id="frueherePlz" beschriftung="PLZ" fehler={zustand.felder?.frueherePlz}>
<Input name="frueherePlz" defaultValue={wert('frueherePlz')} inputMode="numeric" />
</Feld>
<Feld id="fruehererOrt" beschriftung="Ort" className="col-span-2" fehler={zustand.felder?.fruehererOrt}>
<Input name="fruehererOrt" defaultValue={wert('fruehererOrt')} />
</Feld>
</div>
</CardContent>
</Card>
{/* --- Betreuungsgericht ---------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Betreuungsgericht</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="betreuungsgericht" beschriftung="Gericht" fehler={zustand.felder?.betreuungsgericht}>
<Input name="betreuungsgericht" defaultValue={wert('betreuungsgericht')} />
</Feld>
<Feld id="aktenzeichenGericht" beschriftung="Aktenzeichen" fehler={zustand.felder?.aktenzeichenGericht}>
<Input name="aktenzeichenGericht" defaultValue={wert('aktenzeichenGericht')} />
</Feld>
<Feld
id="bestellungAm"
beschriftung="Bestellt am"
erklaerung="Steuert das Betreuungsjahr. Wird dieses Datum geändert, berechnet die Anwendung alle gesetzlichen Fristen neu."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={alsDatumswert(wert('bestellungAm'))} />
</Feld>
<Feld id="bestellungBis" beschriftung="Befristet bis" fehler={zustand.felder?.bestellungBis}>
<Input name="bestellungBis" type="date" defaultValue={alsDatumswert(wert('bestellungBis'))} />
</Feld>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<div className="grid gap-2 sm:grid-cols-2">
{AUFGABENKREISE.map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={werte.aufgabenkreise.includes(kreis)}
/>
<span className="text-sm">{AUFGABENKREIS_BESCHRIFTUNG[kreis]}</span>
</label>
))}
</div>
</fieldset>
<Feld
id="aufgabenkreiseFreitext"
beschriftung="Ergänzung zu den Aufgabenkreisen"
erklaerung="Falls die Bestellungsurkunde einen Bereich anders oder enger fasst."
fehler={zustand.felder?.aufgabenkreiseFreitext}
>
<Textarea name="aufgabenkreiseFreitext" defaultValue={wert('aufgabenkreiseFreitext')} rows={2} />
</Feld>
<div className="space-y-2">
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked={werte.ehrenamtlich} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Begründet den Anspruch auf die jährliche Aufwandspauschale.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked={werte.befreiterBetreuer} className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer</span>
<span className="mt-0.5 block text-muted-foreground">
Dann genügt statt der vollständigen Rechnungslegung eine Vermögensübersicht.
</span>
</span>
</label>
</div>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
</CardContent>
</Card>
{/* --- Gesundheit ------------------------------------------------------ */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Gesundheit und Pflege</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>
Diagnosen, Versichertennummer und der ermittelte Wille sind besondere Daten nach
Art. 9 DSGVO. Sie werden zusätzlich verschlüsselt gespeichert und sind ohne den
Schlüssel aus der Serverumgebung auch aus einem Backup nicht lesbar.
</AlertDescription>
</Alert>
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="krankenkasse" beschriftung="Krankenkasse" fehler={zustand.felder?.krankenkasse}>
<Input name="krankenkasse" defaultValue={wert('krankenkasse')} />
</Feld>
<Feld
id="krankenversichertennummer"
beschriftung="Versichertennummer"
fehler={zustand.felder?.krankenversichertennummer}
>
<Input name="krankenversichertennummer" defaultValue={wert('krankenversichertennummer')} />
</Feld>
<Feld id="pflegekasse" beschriftung="Pflegekasse" fehler={zustand.felder?.pflegekasse}>
<Input name="pflegekasse" defaultValue={wert('pflegekasse')} />
</Feld>
<Feld id="pflegegrad" beschriftung="Pflegegrad" erklaerung="1 bis 5" fehler={zustand.felder?.pflegegrad}>
<Input name="pflegegrad" type="number" min={1} max={5} defaultValue={wert('pflegegrad')} />
</Feld>
<Feld id="pflegegradSeit" beschriftung="Pflegegrad seit" fehler={zustand.felder?.pflegegradSeit}>
<Input name="pflegegradSeit" type="date" defaultValue={alsDatumswert(wert('pflegegradSeit'))} />
</Feld>
<Feld
id="pflegegradBescheidVom"
beschriftung="Bescheid vom"
fehler={zustand.felder?.pflegegradBescheidVom}
>
<Input
name="pflegegradBescheidVom"
type="date"
defaultValue={alsDatumswert(wert('pflegegradBescheidVom'))}
/>
</Feld>
<Feld
id="schwerbehinderungGdB"
beschriftung="Grad der Behinderung"
erklaerung="20 bis 100"
fehler={zustand.felder?.schwerbehinderungGdB}
>
<Input
name="schwerbehinderungGdB"
type="number"
min={20}
max={100}
step={10}
defaultValue={wert('schwerbehinderungGdB')}
/>
</Feld>
<Feld
id="schwerbehinderungMerkzeichen"
beschriftung="Merkzeichen"
erklaerung="z. B. G, aG, B, H, Bl, Gl"
fehler={zustand.felder?.schwerbehinderungMerkzeichen}
>
<Input
name="schwerbehinderungMerkzeichen"
defaultValue={wert('schwerbehinderungMerkzeichen')}
/>
</Feld>
</div>
<Feld
id="diagnosen"
beschriftung="Diagnosen"
erklaerung="Eine Diagnose je Zeile, gern mit Datum der Feststellung."
fehler={zustand.felder?.diagnosen}
>
<Textarea name="diagnosen" defaultValue={wert('diagnosen')} rows={4} />
</Feld>
<Feld
id="ermittelterWille"
beschriftung="Ermittelter Wille der betreuten Person"
erklaerung="Was möchte sie selbst? Seit der Reform 2023 ist das die Leitschnur deines Handelns — auch dann, wenn du es anders entscheiden würdest."
fehler={zustand.felder?.ermittelterWille}
>
<Textarea name="ermittelterWille" defaultValue={wert('ermittelterWille')} rows={4} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
<div className="space-y-2">
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="patientenverfuegungVorhanden"
defaultChecked={werte.patientenverfuegungVorhanden}
/>
<span className="text-sm font-medium">Patientenverfügung liegt vor</span>
</label>
<label className="flex cursor-pointer items-center gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox
name="vorsorgevollmachtVorhanden"
defaultChecked={werte.vorsorgevollmachtVorhanden}
/>
<span className="text-sm font-medium">Vorsorgevollmacht liegt vor</span>
</label>
</div>
</CardContent>
</Card>
{/* --- Behörden -------------------------------------------------------- */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Behörden und Kennzeichen</CardTitle>
</CardHeader>
<CardContent className="grid gap-4 sm:grid-cols-2">
<Feld
id="aktenzeichenBezirk"
beschriftung="Aktenzeichen Bezirk"
erklaerung="Hilfe zur Pflege nach dem 7. Kapitel SGB XII."
fehler={zustand.felder?.aktenzeichenBezirk}
>
<Input name="aktenzeichenBezirk" defaultValue={wert('aktenzeichenBezirk')} />
</Feld>
<Feld
id="aktenzeichenSozialamt"
beschriftung="Aktenzeichen Sozialamt"
fehler={zustand.felder?.aktenzeichenSozialamt}
>
<Input name="aktenzeichenSozialamt" defaultValue={wert('aktenzeichenSozialamt')} />
</Feld>
<Feld
id="rentenversicherungsnummer"
beschriftung="Rentenversicherungsnummer"
fehler={zustand.felder?.rentenversicherungsnummer}
>
<Input name="rentenversicherungsnummer" defaultValue={wert('rentenversicherungsnummer')} />
</Feld>
<Feld id="steuerId" beschriftung="Steuer-Identifikationsnummer" fehler={zustand.felder?.steuerId}>
<Input name="steuerId" inputMode="numeric" defaultValue={wert('steuerId')} />
</Feld>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Notizen</CardTitle>
</CardHeader>
<CardContent>
<Feld id="notizen" beschriftung="Freitext" fehler={zustand.felder?.notizen}>
<Textarea name="notizen" defaultValue={wert('notizen')} rows={5} />
</Feld>
</CardContent>
</Card>
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button asChild variant="outline" size="lg">
<Link href="/stammdaten">Abbrechen</Link>
</Button>
<Speichern />
</div>
</form>
)
}
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { SeitenKopf } from '@/components/seiten-kopf'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { oeffnen } from '@/lib/crypto/feld'
import { StammdatenFormular, type StammdatenWerte } from './formular'
export const metadata: Metadata = { title: 'Stammdaten bearbeiten' }
export const dynamic = 'force-dynamic'
/** Date → "JJJJ-MM-TT" für das Datumsfeld im Browser. */
function alsText(datum: Date | null): string | null {
return datum ? datum.toISOString().slice(0, 10) : null
}
export default async function StammdatenBearbeitenSeite() {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
// Schreibrecht wird hier geprüft, nicht erst beim Absenden.
const { betreute } = await erfordereZugang(aktive.id, 'STAMMDATEN', 'schreiben')
const offen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
const werte: StammdatenWerte = {
id: betreute.id,
vorname: betreute.vorname,
nachname: betreute.nachname,
geburtsname: betreute.geburtsname,
geburtsdatum: alsText(betreute.geburtsdatum) ?? '',
geburtsort: betreute.geburtsort,
anrede: betreute.anrede,
heimName: betreute.heimName,
heimStrasse: betreute.heimStrasse,
heimPlz: betreute.heimPlz,
heimOrt: betreute.heimOrt,
heimZimmer: betreute.heimZimmer,
heimTelefon: betreute.heimTelefon,
heimEinzugAm: alsText(betreute.heimEinzugAm),
fruehereStrasse: betreute.fruehereStrasse,
frueherePlz: betreute.frueherePlz,
fruehererOrt: betreute.fruehererOrt,
krankenkasse: betreute.krankenkasse,
krankenversichertennummer: offen.krankenversichertennummer,
pflegekasse: betreute.pflegekasse,
pflegegrad: betreute.pflegegrad,
pflegegradSeit: alsText(betreute.pflegegradSeit),
pflegegradBescheidVom: alsText(betreute.pflegegradBescheidVom),
schwerbehinderungGdB: betreute.schwerbehinderungGdB,
schwerbehinderungMerkzeichen: betreute.schwerbehinderungMerkzeichen,
diagnosen: offen.diagnosen,
ermittelterWille: offen.ermittelterWille,
patientenverfuegungVorhanden: betreute.patientenverfuegungVorhanden,
vorsorgevollmachtVorhanden: betreute.vorsorgevollmachtVorhanden,
betreuungsgericht: betreute.betreuungsgericht,
aktenzeichenGericht: betreute.aktenzeichenGericht,
bestellungAm: alsText(betreute.bestellungAm),
bestellungBis: alsText(betreute.bestellungBis),
aufgabenkreise: betreute.aufgabenkreise,
aufgabenkreiseFreitext: betreute.aufgabenkreiseFreitext,
befreiterBetreuer: betreute.befreiterBetreuer,
ehrenamtlich: betreute.ehrenamtlich,
aktenzeichenBezirk: betreute.aktenzeichenBezirk,
aktenzeichenSozialamt: betreute.aktenzeichenSozialamt,
rentenversicherungsnummer: offen.rentenversicherungsnummer,
steuerId: offen.steuerId,
notizen: offen.notizen,
}
return (
<div className="space-y-6">
<SeitenKopf
titel="Stammdaten bearbeiten"
beschreibung="Was hier steht, taucht in jedem Bericht und in jedem Anschreiben wieder auf. Es lohnt sich, es einmal in Ruhe vollständig einzutragen."
pfad={[
{ titel: 'Stammdaten', href: '/stammdaten' },
{ titel: 'Bearbeiten' },
]}
/>
<StammdatenFormular werte={werte} />
</div>
)
}
+302
View File
@@ -0,0 +1,302 @@
import type { Metadata } from 'next'
import Link from 'next/link'
import { CalendarClock, Pencil, ScrollText, Stethoscope } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { oeffnen } from '@/lib/crypto/feld'
import { formatDatum, tageBis } from '@/lib/utils'
import { AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { betreuungsjahr } from '@/lib/stammdaten/betreuungsjahr'
export const metadata: Metadata = { title: 'Stammdaten' }
export const dynamic = 'force-dynamic'
/** Zeigt eine Angabe an, oder einen Gedankenstrich, wenn sie fehlt. */
function Angabe({
bezeichnung,
wert,
hinweis,
}: {
bezeichnung: string
wert: React.ReactNode
hinweis?: string
}) {
const leer = wert === null || wert === undefined || wert === '' || wert === '–'
return (
<div className="py-2.5">
<dt className="text-sm text-muted-foreground">{bezeichnung}</dt>
<dd className={leer ? 'text-muted-foreground' : 'font-medium'}>{leer ? '–' : wert}</dd>
{hinweis && <p className="mt-0.5 text-xs text-muted-foreground">{hinweis}</p>}
</div>
)
}
export default async function StammdatenSeite() {
const benutzer = await erfordereBenutzer()
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { betreute, darfSchreiben } = await erfordereZugang(aktive.id, 'STAMMDATEN')
const offen = oeffnen('Betreute', betreute as unknown as Record<string, unknown>)
const jahr = betreuungsjahr(betreute.bestellungAm)
return (
<div className="space-y-6">
<SeitenKopf
titel={`${betreute.vorname} ${betreute.nachname}`}
beschreibung={
<>
Geboren am {formatDatum(betreute.geburtsdatum)}
{betreute.geburtsname && ` · geborene ${betreute.geburtsname}`}
</>
}
aktionen={
darfSchreiben('STAMMDATEN') && (
<Button asChild>
<Link href="/stammdaten/bearbeiten">
<Pencil aria-hidden="true" />
Bearbeiten
</Link>
</Button>
)
}
/>
{/* Das Betreuungsjahr steuert fast alle Fristen - deshalb ganz oben. */}
{jahr && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarClock className="size-5" aria-hidden="true" />
Laufendes Betreuungsjahr
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p>
<span className="font-medium">
{formatDatum(jahr.beginn)} bis {formatDatum(jahr.ende)}
</span>
<span className="ml-2 text-muted-foreground">
(noch {Math.max(0, tageBis(jahr.ende))} Tage)
</span>
</p>
<p className="text-sm text-muted-foreground">
Das Betreuungsjahr läuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Der
Jahresbericht wird zu seinem Ende fällig; die Aufwandspauschale muss binnen sechs
Monaten danach beantragt werden — bis zum{' '}
<strong className="text-foreground">{formatDatum(jahr.pauschaleBis)}</strong>, sonst
verfällt der Anspruch.
</p>
<Rechtshinweis rechtsgrundlage="§§ 1863, 1878 BGB" kurz />
</CardContent>
</Card>
)}
<div className="grid gap-6 lg:grid-cols-2">
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-base">Person und Aufenthalt</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Anrede" wert={betreute.anrede} />
<Angabe bezeichnung="Geburtsort" wert={betreute.geburtsort} />
<Angabe bezeichnung="Einrichtung" wert={betreute.heimName} />
<Angabe
bezeichnung="Anschrift"
wert={
betreute.heimStrasse
? `${betreute.heimStrasse}, ${betreute.heimPlz ?? ''} ${betreute.heimOrt ?? ''}`.trim()
: null
}
/>
<Angabe bezeichnung="Zimmer" wert={betreute.heimZimmer} />
<Angabe bezeichnung="Telefon Einrichtung" wert={betreute.heimTelefon} />
<Angabe bezeichnung="Eingezogen am" wert={formatDatum(betreute.heimEinzugAm)} />
<Angabe
bezeichnung="Frühere Anschrift"
wert={
betreute.fruehereStrasse
? `${betreute.fruehereStrasse}, ${betreute.frueherePlz ?? ''} ${betreute.fruehererOrt ?? ''}`.trim()
: null
}
hinweis="Bleibt für Behörden und für die Immobilie relevant."
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="flex items-center gap-2 text-base">
<ScrollText className="size-5" aria-hidden="true" />
Betreuungsgericht
</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Gericht" wert={betreute.betreuungsgericht} />
<Angabe bezeichnung="Aktenzeichen" wert={betreute.aktenzeichenGericht} />
<Angabe bezeichnung="Bestellt am" wert={formatDatum(betreute.bestellungAm)} />
<Angabe bezeichnung="Befristet bis" wert={formatDatum(betreute.bestellungBis)} />
<div className="py-2.5">
<dt className="text-sm text-muted-foreground">Aufgabenkreise</dt>
<dd className="mt-1.5 flex flex-wrap gap-1.5">
{betreute.aufgabenkreise.length === 0 ? (
<span className="text-muted-foreground">–</span>
) : (
betreute.aufgabenkreise.map((kreis) => (
<Badge key={kreis} variant="secondary">
{AUFGABENKREIS_BESCHRIFTUNG[kreis]}
</Badge>
))
)}
</dd>
{betreute.aufgabenkreiseFreitext && (
<p className="mt-1.5 text-sm text-muted-foreground">
{betreute.aufgabenkreiseFreitext}
</p>
)}
</div>
<Angabe
bezeichnung="Art der Betreuung"
wert={
<span className="flex flex-wrap gap-1.5">
<Badge variant={betreute.ehrenamtlich ? 'gruen' : 'secondary'}>
{betreute.ehrenamtlich ? 'ehrenamtlich' : 'beruflich'}
</Badge>
<Badge variant={betreute.befreiterBetreuer ? 'gruen' : 'secondary'}>
{betreute.befreiterBetreuer ? 'befreit (§ 1859 BGB)' : 'nicht befreit'}
</Badge>
</span>
}
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="flex items-center gap-2 text-base">
<Stethoscope className="size-5" aria-hidden="true" />
Gesundheit und Pflege
</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe bezeichnung="Krankenkasse" wert={betreute.krankenkasse} />
<Angabe
bezeichnung="Versichertennummer"
wert={offen.krankenversichertennummer}
hinweis="Verschlüsselt gespeichert."
/>
<Angabe bezeichnung="Pflegekasse" wert={betreute.pflegekasse} />
<Angabe
bezeichnung="Pflegegrad"
wert={
betreute.pflegegrad ? (
<>
Pflegegrad {betreute.pflegegrad}
{betreute.pflegegradSeit && (
<span className="ml-2 font-normal text-muted-foreground">
seit {formatDatum(betreute.pflegegradSeit)}
</span>
)}
</>
) : null
}
/>
<Angabe
bezeichnung="Schwerbehinderung"
wert={
betreute.schwerbehinderungGdB
? `GdB ${betreute.schwerbehinderungGdB}${
betreute.schwerbehinderungMerkzeichen
? ` · ${betreute.schwerbehinderungMerkzeichen}`
: ''
}`
: null
}
/>
<Angabe
bezeichnung="Diagnosen"
wert={
offen.diagnosen ? (
<span className="whitespace-pre-line font-normal">{offen.diagnosen}</span>
) : null
}
hinweis="Verschlüsselt gespeichert (Art. 9 DSGVO)."
/>
<Angabe
bezeichnung="Vorsorge"
wert={
<span className="flex flex-wrap gap-1.5">
<Badge variant={betreute.patientenverfuegungVorhanden ? 'gruen' : 'outline'}>
Patientenverfügung {betreute.patientenverfuegungVorhanden ? 'liegt vor' : 'fehlt'}
</Badge>
<Badge variant={betreute.vorsorgevollmachtVorhanden ? 'gruen' : 'outline'}>
Vorsorgevollmacht {betreute.vorsorgevollmachtVorhanden ? 'liegt vor' : 'fehlt'}
</Badge>
</span>
}
/>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-base">Behörden und Kennzeichen</CardTitle>
</CardHeader>
<CardContent>
<dl className="divide-y">
<Angabe
bezeichnung="Aktenzeichen Bezirk"
wert={betreute.aktenzeichenBezirk}
hinweis="Hilfe zur Pflege nach dem 7. Kapitel SGB XII."
/>
<Angabe bezeichnung="Aktenzeichen Sozialamt" wert={betreute.aktenzeichenSozialamt} />
<Angabe
bezeichnung="Rentenversicherungsnummer"
wert={offen.rentenversicherungsnummer}
hinweis="Verschlüsselt gespeichert."
/>
<Angabe
bezeichnung="Steuer-Identifikationsnummer"
wert={offen.steuerId}
hinweis="Verschlüsselt gespeichert."
/>
</dl>
</CardContent>
</Card>
</div>
{offen.ermittelterWille && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Ermittelter Wille</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="whitespace-pre-line">{offen.ermittelterWille}</p>
<Rechtshinweis rechtsgrundlage="§ 1821 BGB" />
</CardContent>
</Card>
)}
{offen.notizen && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Notizen</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-line">{offen.notizen}</p>
</CardContent>
</Card>
)}
</div>
)
}
+175
View File
@@ -0,0 +1,175 @@
import Link from 'next/link'
import { ArrowLeft, ListChecks, Scale } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns'
/**
* Neuen Vorgang anlegen - erst die Vorlage wählen, dann die Angaben.
*
* Zwei Schritte statt einem: Die Vorlage entscheidet über die
* Aufgabenliste und darüber, ob ein Genehmigungsverfahren angelegt wird.
* Das soll man bewusst wählen, nicht nebenbei in einem Auswahlfeld.
*/
export default async function NeuerVorgangSeite({
searchParams,
}: {
searchParams: Promise<{ vorlage?: string; leer?: string }>
}) {
const benutzer = await erfordereBenutzer('/vorgaenge/neu')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
const auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1'
// Schritt 1: Kachelliste
if (!vorlage && !leer) {
return (
<div className="space-y-6">
<SeitenKopf
titel="Neuer Vorgang"
beschreibung="Wähle eine Vorlage - sie bringt die Aufgabenliste in der fachlich richtigen Reihenfolge mit. Oder fang leer an."
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: 'Neu' },
]}
aktionen={
<Button variant="ghost" asChild>
<Link href="/vorgaenge">
<ArrowLeft aria-hidden="true" />
Zurück
</Link>
</Button>
}
/>
<VorlagenKacheln />
</div>
)
}
// Schritt 2: Angaben
const heute = new Date()
return (
<div className="space-y-6">
<SeitenKopf
titel={vorlage ? vorlage.titel : 'Leerer Vorgang'}
beschreibung={
vorlage
? vorlage.beschreibung
: 'Ohne vorbefüllte Aufgaben. Aufgaben lassen sich danach jederzeit ergänzen.'
}
pfad={[
{ titel: 'Start', href: '/heute' },
{ titel: 'Vorgänge', href: '/vorgaenge' },
{ titel: 'Neu', href: '/vorgaenge/neu' },
{ titel: vorlage ? vorlage.titel : 'Leer' },
]}
aktionen={
<Button variant="ghost" asChild>
<Link href="/vorgaenge/neu">
<ArrowLeft aria-hidden="true" />
Andere Vorlage
</Link>
</Button>
}
/>
{vorlage?.genehmigungspflichtig && (
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>
Dieser Vorgang ist genehmigungspflichtig
{vorlage.rechtsgrundlage ? ` (${vorlage.rechtsgrundlage})` : ''}
</AlertTitle>
<AlertDescription>
{GENEHMIGUNG_WARNUNG} Die Genehmigungskette wird beim Anlegen gleich mit erzeugt.
</AlertDescription>
</Alert>
)}
{vorlage && (
<section className="space-y-3 rounded-lg border p-4" aria-labelledby="vorschau">
<h2 id="vorschau" className="flex items-center gap-2 text-base font-semibold">
<ListChecks className="size-4" aria-hidden="true" />
Diese Aufgaben werden angelegt
</h2>
<ol className="space-y-2">
{vorlage.aufgaben.map((aufgabe, index) => (
<li key={aufgabe.titel} className="flex gap-3 text-sm">
<span className="tabular-nums text-muted-foreground">{index + 1}.</span>
<span className="min-w-0 flex-1">
<span className="font-medium">{aufgabe.titel}</span>
{aufgabe.tageBisFaellig !== undefined && (
<span className="text-muted-foreground">
{' '}
· fällig {formatDatum(addDays(heute, aufgabe.tageBisFaellig))}
</span>
)}
</span>
</li>
))}
</ol>
{vorlage.fristen && vorlage.fristen.length > 0 && (
<div className="space-y-2 border-t pt-3">
<h3 className="text-sm font-semibold">Zusätzliche Fristen</h3>
<ul className="space-y-1">
{vorlage.fristen.map((frist) => (
<li key={frist.titel} className="text-sm">
{frist.titel}{' '}
<span className="text-muted-foreground">
· {formatDatum(addDays(heute, frist.tageBisFaellig))}
</span>
{frist.rechtsgrundlage && (
<Badge variant="outline" className="ml-2">
{frist.rechtsgrundlage}
</Badge>
)}
</li>
))}
</ul>
</div>
)}
{vorlage.rechtsgrundlage && <Rechtshinweis rechtsgrundlage={vorlage.rechtsgrundlage} />}
</section>
)}
<VorgangsFormular
modus="anlegen"
betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge"
werte={{
titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES',
status: 'OFFEN',
prioritaet: 2,
beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null,
aktenzeichen: null,
verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null,
letzterKontaktAm: null,
genehmigungspflichtig: vorlage?.genehmigungspflichtig ?? false,
}}
/>
</div>
)
}
+136
View File
@@ -0,0 +1,136 @@
import Link from 'next/link'
import { Plus } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
import { VorgaengeAnsicht, type VorgangsZeile } from '@/components/vorgaenge/vorgaenge-ansicht'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db'
import { KATEGORIE_BESCHRIFTUNG, VORGANG_KATEGORIEN } from '@/lib/validierung/vorgaenge'
/**
* Übersicht aller Vorgänge.
*
* Voreingestellt sind die offenen Vorgänge - Erledigtes soll den Blick auf
* das, was liegt, nicht verstellen. Über den Filter kommt man an alles.
*/
export default async function VorgaengeSeite({
searchParams,
}: {
searchParams: Promise<{ kategorie?: string; erledigte?: string }>
}) {
const benutzer = await erfordereBenutzer('/vorgaenge')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const filter = await searchParams
const kategorie = (VORGANG_KATEGORIEN as readonly string[]).includes(filter.kategorie ?? '')
? (filter.kategorie as VorgangKategorie)
: undefined
const mitErledigten = filter.erledigte === '1'
const gefunden = await db.vorgang.findMany({
where: {
betreuteId: aktive.id,
...nichtGeloescht,
...(kategorie ? { kategorie } : {}),
...(mitErledigten ? {} : { status: { not: 'ERLEDIGT' as VorgangStatus } }),
},
include: { aufgaben: { select: { erledigt: true } } },
orderBy: [{ prioritaet: 'asc' }, { faelligAm: 'asc' }, { aktualisiertAm: 'desc' }],
})
const vorgaenge: VorgangsZeile[] = gefunden.map((vorgang) => ({
id: vorgang.id,
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
aufgabenGesamt: vorgang.aufgaben.length,
aufgabenOffen: vorgang.aufgaben.filter((a) => !a.erledigt).length,
}))
return (
<div className="space-y-6">
<SeitenKopf
titel="Vorgänge"
beschreibung="Jede Sache, die läuft, hat hier ihre Akte: Aufgaben, Fristen, Schriftverkehr und - wo nötig - das Genehmigungsverfahren."
pfad={[{ titel: 'Start', href: '/heute' }, { titel: 'Vorgänge' }]}
aktionen={
darfSchreiben('VORGAENGE') ? (
<Button asChild>
<Link href="/vorgaenge/neu">
<Plus aria-hidden="true" />
Neuer Vorgang
</Link>
</Button>
) : undefined
}
/>
{/* Filter ohne JavaScript: ein normales Formular mit GET. */}
<form
method="get"
className="flex flex-wrap items-end gap-3 rounded-lg border p-4"
aria-label="Vorgänge filtern"
>
<div className="min-w-48 flex-1">
<label htmlFor="kategorie" className="mb-1.5 block text-sm font-medium">
Kategorie
</label>
<Select id="kategorie" name="kategorie" defaultValue={kategorie ?? ''}>
<option value="">Alle Kategorien</option>
{VORGANG_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</div>
<div className="flex items-center gap-2">
<input
id="erledigte"
name="erledigte"
type="checkbox"
value="1"
defaultChecked={mitErledigten}
className="size-5 rounded border-input accent-primary"
/>
<label htmlFor="erledigte" className="text-sm font-medium">
Erledigte mitzeigen
</label>
</div>
<Button type="submit" variant="outline">
Filtern
</Button>
</form>
{vorgaenge.length === 0 ? (
<Leerzustand
titel={kategorie ? 'In dieser Kategorie liegt nichts.' : 'Noch kein Vorgang angelegt.'}
beschreibung="Fang mit dem an, was gerade drückt - für die häufigsten Fälle gibt es Vorlagen mit fertiger Aufgabenliste."
aktion={
darfSchreiben('VORGAENGE') ? (
<Button asChild>
<Link href="/vorgaenge/neu">Vorgang aus Vorlage anlegen</Link>
</Button>
) : undefined
}
/>
) : (
<VorgaengeAnsicht vorgaenge={vorgaenge} darfSchreiben={darfSchreiben('VORGAENGE')} />
)}
</div>
)
}
@@ -0,0 +1,150 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { betreuteAnlegen, type BetreuteZustand } from '@/server/actions/betreute'
import { AUFGABENKREISE, AUFGABENKREIS_BESCHRIFTUNG } from '@/lib/validierung/betreute'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Checkbox } from '@/components/ui/checkbox'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Betreuung anlegen und loslegen'}
</Button>
)
}
/** Nur die vier häufigsten Aufgabenkreise werden vorangestellt. */
const HAEUFIG = ['VERMOEGENSSORGE', 'GESUNDHEITSSORGE', 'AUFENTHALTSBESTIMMUNG', 'WOHNUNGSANGELEGENHEITEN']
export function BetreuungFormular() {
const [zustand, aktion] = useActionState<BetreuteZustand, FormData>(betreuteAnlegen, {})
return (
<form action={aktion} className="space-y-5" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<div className="grid gap-4 sm:grid-cols-2">
<Feld id="vorname" beschriftung="Vorname" fehler={zustand.felder?.vorname} pflicht>
<Input name="vorname" defaultValue={zustand.werte?.vorname} required autoFocus />
</Feld>
<Feld id="nachname" beschriftung="Nachname" fehler={zustand.felder?.nachname} pflicht>
<Input name="nachname" defaultValue={zustand.werte?.nachname} required />
</Feld>
</div>
<Feld
id="geburtsdatum"
beschriftung="Geburtsdatum"
fehler={zustand.felder?.geburtsdatum}
pflicht
>
<Input
name="geburtsdatum"
type="date"
defaultValue={zustand.werte?.geburtsdatum}
required
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-2">
<Feld
id="betreuungsgericht"
beschriftung="Betreuungsgericht"
fehler={zustand.felder?.betreuungsgericht}
>
<Input
name="betreuungsgericht"
placeholder="Amtsgericht …"
defaultValue={zustand.werte?.betreuungsgericht}
/>
</Feld>
<Feld
id="aktenzeichenGericht"
beschriftung="Aktenzeichen"
fehler={zustand.felder?.aktenzeichenGericht}
>
<Input
name="aktenzeichenGericht"
placeholder="XVII 123/25"
defaultValue={zustand.werte?.aktenzeichenGericht}
/>
</Feld>
</div>
<Feld
id="bestellungAm"
beschriftung="Datum der Bestellung zum Betreuer"
erklaerung="Steht in deiner Bestellungsurkunde. Daraus ergibt sich das Betreuungsjahr — und damit, wann Anfangsbericht, Jahresbericht und die Aufwandspauschale fällig sind."
fehler={zustand.felder?.bestellungAm}
>
<Input name="bestellungAm" type="date" defaultValue={zustand.werte?.bestellungAm} />
</Feld>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">Aufgabenkreise</legend>
<p className="text-sm text-muted-foreground">
Die in der Bestellungsurkunde genannten Bereiche.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{[...HAEUFIG, ...AUFGABENKREISE.filter((a) => !HAEUFIG.includes(a))].map((kreis) => (
<label
key={kreis}
className="flex cursor-pointer items-center gap-2.5 rounded-md border p-2.5 hover:bg-accent"
>
<Checkbox
name="aufgabenkreise"
value={kreis}
defaultChecked={
kreis === 'VERMOEGENSSORGE' || kreis === 'GESUNDHEITSSORGE'
}
/>
<span className="text-sm">
{AUFGABENKREIS_BESCHRIFTUNG[kreis as keyof typeof AUFGABENKREIS_BESCHRIFTUNG]}
</span>
</label>
))}
</div>
</fieldset>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="befreiterBetreuer" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Befreiter Betreuer (§ 1859 BGB)</span>
<span className="mt-0.5 block text-muted-foreground">
Trifft auf Angehörige in der Regel zu. Statt der vollständigen Rechnungslegung genügt
dann eine Vermögensübersicht.
</span>
</span>
</label>
<label className="flex cursor-pointer items-start gap-2.5 rounded-md border p-3 hover:bg-accent">
<Checkbox name="ehrenamtlich" defaultChecked className="mt-0.5" />
<span className="text-sm">
<span className="font-medium">Ehrenamtliche Betreuung</span>
<span className="mt-0.5 block text-muted-foreground">
Dann steht dir die jährliche Aufwandspauschale nach § 1878 BGB zu — die Anwendung
erinnert dich rechtzeitig, weil der Anspruch sonst verfällt.
</span>
</span>
</label>
<Rechtshinweis rechtsgrundlage="§§ 1859, 1863, 1878 BGB" />
<Absenden />
</form>
)
}
+82
View File
@@ -0,0 +1,82 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { KeyRound, UserPlus } from 'lucide-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { aktuellerBenutzer, meineBetreuten } from '@/lib/auth/sitzung'
import { db } from '@/lib/db'
import { ZugangFormular } from './zugang-formular'
import { BetreuungFormular } from './betreuung-formular'
export const metadata: Metadata = { title: 'Ersteinrichtung' }
export const dynamic = 'force-dynamic'
/**
* Ersteinrichtung in drei Schritten.
*
* Diese Seite liegt bewusst ausserhalb des Anwendungsrahmens: der Rahmen
* setzt einen Benutzer, einen zweiten Faktor UND eine betreute Person
* voraus. Beim allerersten Start gibt es nichts davon.
*/
export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) {
return (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="size-5" aria-hidden="true" />
Schritt 1 von 3: Dein Zugang
</CardTitle>
<CardDescription>
Dieser erste Zugang verwaltet auch die Anwendung. Danach richtest du den zweiten
Faktor ein und legst die betreute Person an.
</CardDescription>
</CardHeader>
<CardContent>
<ZugangFormular />
</CardContent>
</Card>
<Alert variant="hinweis">
<KeyRound aria-hidden="true" />
<AlertTitle>Zum Passwort</AlertTitle>
<AlertDescription>
Nimm vier zufällige Wörter statt eines kurzen Kunstworts —{' '}
<em>Kastanie-Waschbär-Turm-Grün</em> ist leichter zu merken und deutlich schwerer zu
knacken. Ein Passwortmanager ist noch besser.
</AlertDescription>
</Alert>
</>
)
}
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
// --- Schritt 2: Zweiter Faktor fehlt noch --------------------------------
if (!benutzer.totpAktiviertAm) redirect('/zwei-faktor/einrichten')
// --- Schritt 3: Noch keine betreute Person ------------------------------
const betreute = await meineBetreuten(benutzer)
if (betreute.length > 0) redirect('/heute')
return (
<Card>
<CardHeader>
<CardTitle>Schritt 3 von 3: Die betreute Person</CardTitle>
<CardDescription>
Nur das Nötigste — alles Weitere trägst du später in Ruhe unter Stammdaten nach. Das{' '}
<strong>Bestellungsdatum</strong> ist das wichtigste Feld: daraus berechnet die Anwendung
das Betreuungsjahr und damit deine gesetzlichen Fristen.
</CardDescription>
</CardHeader>
<CardContent>
<BetreuungFormular />
</CardContent>
</Card>
)
}
@@ -0,0 +1,86 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { erstenZugangAnlegen, type ZugangZustand } from '@/server/actions/ersteinrichtung'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird angelegt …' : 'Zugang anlegen'}
</Button>
)
}
export function ZugangFormular() {
const [zustand, aktion] = useActionState<ZugangZustand, FormData>(erstenZugangAnlegen, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="name" beschriftung="Dein Name" fehler={zustand.felder?.name} pflicht>
<Input
name="name"
autoComplete="name"
defaultValue={zustand.werte?.name}
placeholder="Vorname Nachname"
required
autoFocus
/>
</Feld>
<Feld
id="email"
beschriftung="E-Mail-Adresse"
erklaerung="Damit meldest du dich an. Sie wird nicht nach außen versendet."
fehler={zustand.felder?.email}
pflicht
>
<Input
name="email"
type="email"
autoComplete="username"
inputMode="email"
autoCapitalize="none"
spellCheck={false}
defaultValue={zustand.werte?.email}
required
/>
</Feld>
<Feld
id="passwort"
beschriftung="Passwort"
erklaerung="Mindestens 12 Zeichen."
fehler={zustand.felder?.passwort}
pflicht
>
<Input name="passwort" type="password" autoComplete="new-password" required />
</Feld>
<Feld
id="passwortWiederholung"
beschriftung="Passwort wiederholen"
fehler={zustand.felder?.passwortWiederholung}
pflicht
>
<Input name="passwortWiederholung" type="password" autoComplete="new-password" required />
</Feld>
<Absenden />
</form>
)
}
@@ -0,0 +1,178 @@
'use client'
import { useActionState, useState } from 'react'
import { useFormStatus } from 'react-dom'
import { useRouter } from 'next/navigation'
import { AlertCircle, Check, Copy, Loader2, ShieldCheck } from 'lucide-react'
import {
zweitenFaktorBestaetigen,
type BestaetigungsZustand,
} from '@/server/actions/zwei-faktor'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" className="w-full" size="lg" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird geprüft …' : 'Code bestätigen und aktivieren'}
</Button>
)
}
/** Base32 in Vierergruppen — so tippt es sich von Hand deutlich sicherer. */
function gruppiert(secret: string): string {
return secret.replace(/(.{4})/g, '$1 ').trim()
}
export function EinrichtungsFormular({ qrCode, secret }: { qrCode: string; secret: string }) {
const [zustand, aktion] = useActionState<BestaetigungsZustand, FormData>(
zweitenFaktorBestaetigen,
{},
)
const [kopiert, setKopiert] = useState(false)
const router = useRouter()
// --- Nach erfolgreicher Aktivierung: die Codes, genau einmal ------------
if (zustand.wiederherstellungscodes) {
return (
<div className="space-y-5">
<Alert variant="erfolg">
<ShieldCheck aria-hidden="true" />
<AlertTitle>Zweiter Faktor ist aktiv</AlertTitle>
<AlertDescription>
Ab jetzt brauchst du bei jeder Anmeldung den Code aus deiner App.
</AlertDescription>
</Alert>
<div className="space-y-2">
<h3 className="font-semibold">Deine Wiederherstellungscodes</h3>
<p className="text-sm text-muted-foreground">
Der einzige Weg zurück, wenn das Handy mit der App verloren geht. Jeder Code
funktioniert genau einmal. <strong>Sie werden dir nie wieder angezeigt.</strong> Druck
sie aus und leg sie zu deinen Betreuungsunterlagen — nicht auf dasselbe Gerät.
</p>
</div>
<ul className="grid grid-cols-2 gap-2 rounded-md border bg-muted/50 p-4 font-mono text-sm">
{zustand.wiederherstellungscodes.map((code) => (
<li key={code} className="tracking-wide">
{code}
</li>
))}
</ul>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={() => {
void navigator.clipboard
.writeText(zustand.wiederherstellungscodes!.join('\n'))
.then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Codes kopieren'}
</Button>
<Button type="button" variant="outline" className="flex-1" onClick={() => window.print()}>
Drucken
</Button>
</div>
<Button
type="button"
size="lg"
className="w-full"
onClick={() => router.push('/ersteinrichtung')}
>
Ich habe die Codes gesichert — weiter
</Button>
</div>
)
}
// --- Einrichtung -------------------------------------------------------
return (
<div className="space-y-5">
<ol className="space-y-2 text-sm text-muted-foreground">
<li>
<span className="font-medium text-foreground">1.</span> Installiere eine
Authenticator-App — etwa <strong>Aegis</strong> (Android) oder{' '}
<strong>2FAS</strong> (iOS). Beide sind quelloffen und kommen ohne Konto aus.
</li>
<li>
<span className="font-medium text-foreground">2.</span> Scanne den Code oder tippe das
Geheimnis ab.
</li>
<li>
<span className="font-medium text-foreground">3.</span> Gib die sechs Ziffern ein, die
die App anzeigt.
</li>
</ol>
{qrCode && (
<div className="flex justify-center rounded-lg border bg-white p-4">
{/* Der QR-Code entsteht serverseitig und ist als Data-URI eingebettet -
kein externer Dienst, kein CDN. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={qrCode} alt="QR-Code zum Einrichten der Authenticator-App" width={240} height={240} />
</div>
)}
<div className="space-y-1.5">
<p className="text-sm font-medium">Geheimnis zum Abtippen</p>
<div className="flex items-center gap-2">
<code className="flex-1 overflow-x-auto rounded-md border bg-muted px-3 py-2.5 font-mono text-sm">
{gruppiert(secret)}
</code>
<Button
type="button"
variant="outline"
size="icon"
aria-label="Geheimnis kopieren"
onClick={() => {
void navigator.clipboard.writeText(secret).then(() => {
setKopiert(true)
setTimeout(() => setKopiert(false), 2500)
})
}}
>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
</Button>
</div>
</div>
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feld id="code" beschriftung="Sechsstelliger Code aus der App" pflicht>
<Input
name="code"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={7}
placeholder="123456"
className="text-center font-mono text-lg tracking-[0.4em]"
required
autoFocus
/>
</Feld>
<Absenden />
</form>
</div>
)
}
@@ -0,0 +1,33 @@
import type { Metadata } from 'next'
import { redirect } from 'next/navigation'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { aktuellerBenutzer } from '@/lib/auth/sitzung'
import { zweitenFaktorVorbereiten } from '@/server/actions/zwei-faktor'
import { EinrichtungsFormular } from './formular'
export const metadata: Metadata = { title: 'Zweiten Faktor einrichten' }
export const dynamic = 'force-dynamic'
export default async function ZweiFaktorEinrichtenSeite() {
const benutzer = await aktuellerBenutzer()
if (!benutzer) redirect('/anmelden')
if (benutzer.totpAktiviertAm) redirect('/heute')
const daten = await zweitenFaktorVorbereiten()
return (
<Card>
<CardHeader>
<CardTitle>Schritt 2 von 3: Zweiter Faktor</CardTitle>
<CardDescription>
Hier liegen Gesundheits- und Vermögensdaten einer Person, die nicht selbst entscheiden
kann. Ein Passwort allein reicht dafür nicht — der zweite Faktor ist Pflicht und lässt
sich nicht abschalten.
</CardDescription>
</CardHeader>
<CardContent>
<EinrichtungsFormular qrCode={daten.qrCode} secret={daten.secret} />
</CardContent>
</Card>
)
}
+24
View File
@@ -0,0 +1,24 @@
import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
/**
* Betriebsbereitschaft fuer den Healthcheck in docker-compose.
*
* Bewusst ohne Anmeldung, aber auch ohne jede Auskunft ueber Inhalte:
* geprueft wird nur, ob die Datenbank antwortet. Version und Datenbestand
* gehen niemanden etwas an, der die Adresse errät.
*/
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
await db.$queryRaw`SELECT 1`
return NextResponse.json({ status: 'bereit' }, { headers: { 'Cache-Control': 'no-store' } })
} catch {
return NextResponse.json(
{ status: 'datenbank nicht erreichbar' },
{ status: 503, headers: { 'Cache-Control': 'no-store' } },
)
}
}
+140
View File
@@ -0,0 +1,140 @@
import { NextResponse } from 'next/server'
import { darf } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db'
import { env } from '@/lib/env'
import { heuteAlsTag, plusJahre, plusTage } from '@/lib/fristen/datum'
import { icsErzeugen, signaturPruefen, type IcsFrist } from '@/lib/fristen/ics'
/**
* Abonnierbarer Fristenkalender.
*
* Diese Route ist die einzige Stelle der Anwendung, die Fachdaten ohne
* Anmeldung herausgibt - ein Kalenderprogramm kann sich nicht anmelden und
* keinen zweiten Faktor eingeben. Der Ausgleich dafuer:
*
* 1. Der Zugang haengt an einer HMAC-signierten Adresse. Ohne AUTH_SECRET
* laesst sie sich nicht erraten oder faelschen.
* 2. Es wird trotzdem geprueft, ob der in der Adresse genannte Benutzer
* heute noch aktiv ist und noch Lesezugriff auf das Modul FRISTEN hat.
* Wird ein Mitleser entzogen, ist auch sein Kalender sofort leer.
* 3. Ausgeliefert werden nur Titel, Stichtag und Rechtsgrundlage. Kein
* Name der betreuten Person, keine Diagnosen, keine Betraege.
* 4. Kein Caching durch Zwischenstationen.
*/
export const dynamic = 'force-dynamic'
/** Ein Jahr zurueck, drei nach vorn - mehr braucht kein Kalender. */
const RUECKBLICK_TAGE = 365
const VORSCHAU_JAHRE = 3
export async function GET(
_anfrage: Request,
kontext: { params: Promise<{ signatur: string }> },
): Promise<Response> {
const { signatur } = await kontext.params
const gepruefte = signaturPruefen(signatur)
if (!gepruefte) return abgelehnt()
const { betreuteId, userId } = gepruefte
const [benutzer, betreute, zugang] = await Promise.all([
db.user.findUnique({
where: { id: userId },
select: { id: true, aktiv: true, geloeschtAm: true, rolle: true },
}),
db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { id: true, vorname: true, nachname: true },
}),
db.betreuungsZugang.findUnique({
where: { userId_betreuteId: { userId, betreuteId } },
select: { rolle: true, freigegebeneModule: true },
}),
])
if (!benutzer || !benutzer.aktiv || benutzer.geloeschtAm || !betreute) return abgelehnt()
const rechte = zugang
? { rolle: zugang.rolle, freigegebeneModule: zugang.freigegebeneModule }
: benutzer.rolle === 'ADMIN'
? { rolle: 'ADMIN' as const, freigegebeneModule: [] }
: null
if (!darf(rechte, 'FRISTEN', 'lesen')) return abgelehnt()
const heute = heuteAlsTag()
const datensaetze = await db.frist.findMany({
where: {
betreuteId,
...nichtGeloescht,
status: { in: ['OFFEN', 'ERLEDIGT'] },
datum: { gte: plusTage(heute, -RUECKBLICK_TAGE), lte: plusJahre(heute, VORSCHAU_JAHRE) },
},
orderBy: { datum: 'asc' },
select: {
id: true,
titel: true,
beschreibung: true,
datum: true,
typ: true,
status: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorwarnungTage: true,
aktualisiertAm: true,
},
})
const fristen: IcsFrist[] = datensaetze.map((f) => ({
id: f.id,
// Erledigtes bleibt im Kalender stehen, damit die Historie stimmt - aber
// als solches erkennbar und ohne Wecker.
titel: f.status === 'ERLEDIGT' ? `✓ ${f.titel}` : f.titel,
beschreibung: f.beschreibung,
datum: f.datum,
rechtsgrundlage: f.rechtsgrundlage,
typ: f.typ,
ausschlussfrist: f.ausschlussfrist && f.status === 'OFFEN',
vorwarnungTage: f.status === 'OFFEN' ? f.vorwarnungTage : [],
aktualisiertAm: f.aktualisiertAm,
}))
// Nur die Initialen der betreuten Person: Der Kalendername steht auf dem
// Sperrbildschirm des Telefons und in der Kalenderliste des Anbieters.
const initialen = `${betreute.vorname.charAt(0)}${betreute.nachname.charAt(0)}`.toUpperCase()
const ics = icsErzeugen({
kalendername: `Betreuung ${initialen} — Fristen`,
fristen,
basisUrl: env.basisUrl,
})
return new NextResponse(ics, {
status: 200,
headers: {
'Content-Type': 'text/calendar; charset=utf-8',
'Content-Disposition': 'inline; filename="fristen.ics"',
'Cache-Control': 'private, no-store, max-age=0',
'X-Content-Type-Options': 'nosniff',
'X-Robots-Tag': 'noindex, nofollow',
},
})
}
/**
* Immer dieselbe Antwort, egal woran es lag - ob die Signatur falsch war,
* der Benutzer gesperrt oder die betreute Person geloescht, geht niemanden
* etwas an, der die Adresse durchprobiert.
*/
function abgelehnt(): Response {
return new NextResponse('Dieser Kalender ist nicht (mehr) verfügbar.', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
},
})
}
+9
View File
@@ -0,0 +1,9 @@
import { redirect } from 'next/navigation'
/**
* Einstiegspunkt. Wer angemeldet ist, landet auf dem Tagesdashboard;
* alles Weitere entscheidet der Anwendungsrahmen unter (app).
*/
export default function Startseite() {
redirect('/heute')
}
+107
View File
@@ -0,0 +1,107 @@
import Link from 'next/link'
import { ReceiptText } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelZeile } from '@/components/dashboard/kachel'
import type { BelegeDaten } from '@/lib/dashboard/typen'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Belege und Buchungen, die noch nicht zusammengefunden haben.
*
* Fuer die Rechnungslegung gilt Belegzwang: zu jeder Buchung gehoert ein
* Beleg, und wo keiner existiert, gehoert eine Begruendung dazu. Diese
* Kachel zeigt beide Luecken - Belege ohne Buchung und Buchungen ohne
* Beleg.
*/
export function BelegeKachel({ daten }: { daten: BelegeDaten | null }) {
if (!daten) return null
const offen = daten.belegeOhneBuchungGesamt + daten.buchungenOhneBelegGesamt
return (
<Kachel
titel="Unverbuchte Belege"
icon={ReceiptText}
zaehler={offen}
hinweis={
offen > 0
? 'Zu jeder Buchung gehört ein Beleg – oder eine Begründung, warum keiner existiert.'
: undefined
}
aktion={
<Button asChild variant="outline">
<Link href="/finanzen/buchungen?filter=ohne-beleg">Kassenbuch öffnen</Link>
</Button>
}
>
{offen === 0 ? (
<Leerzustand
icon={ReceiptText}
titel="Belege und Buchungen passen zusammen."
beschreibung="Jede erfasste Buchung hat einen Beleg oder eine Begründung, und keine Rechnung wartet auf ihre Buchung."
className="py-8"
/>
) : (
<div className="space-y-5">
{daten.belegeOhneBuchungGesamt > 0 && (
<section aria-labelledby="belege-ohne-buchung">
<h3
id="belege-ohne-buchung"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Rechnungen und Quittungen ohne Buchung ({daten.belegeOhneBuchungGesamt})
</h3>
<ul className="divide-y">
{daten.belegeOhneBuchung.map((beleg) => (
<li key={beleg.id}>
<KachelZeile href={`/finanzen/buchungen/neu?beleg=${beleg.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">{beleg.titel}</span>
<span className="block text-sm text-muted-foreground">
{beleg.eingangAm
? `Eingang ${formatDatum(beleg.eingangAm)}`
: 'ohne Eingangsdatum'}{' '}
· Buchung anlegen
</span>
</span>
</KachelZeile>
</li>
))}
</ul>
</section>
)}
{daten.buchungenOhneBelegGesamt > 0 && (
<section aria-labelledby="buchungen-ohne-beleg">
<h3
id="buchungen-ohne-beleg"
className="mb-1 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
Buchungen ohne Beleg ({daten.buchungenOhneBelegGesamt})
</h3>
<ul className="divide-y">
{daten.buchungenOhneBeleg.map((buchung) => (
<li key={buchung.id}>
<KachelZeile href={`/finanzen/buchungen/${buchung.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">
{buchung.richtung === 'AUSGABE' ? '−' : '+'}
{formatCent(buchung.betragCent)}
{buchung.gegenstelle ? ` · ${buchung.gegenstelle}` : ''}
</span>
<span className="block text-sm text-muted-foreground">
{formatDatum(buchung.datum)} · Beleg zuordnen oder begründen
</span>
</span>
</KachelZeile>
</li>
))}
</ul>
</section>
)}
</div>
)}
</Kachel>
)
}
+172
View File
@@ -0,0 +1,172 @@
import Link from 'next/link'
import { AlertTriangle, CalendarClock } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Leerzustand } from '@/components/seiten-kopf'
import { KachelWeg } from '@/components/dashboard/kachel'
import { fristLage, verfallwarnung } from '@/lib/dashboard/bewertung'
import type { Ampelstufe, FristEintrag, FristenAmpelDaten } from '@/lib/dashboard/typen'
import { cn, formatDatum } from '@/lib/utils'
/**
* Die Fristen-Ampel - die Kachel, die immer ganz oben steht.
*
* Reihenfolge ist Dringlichkeit: ueberfaellig, diese Woche, dieser Monat.
* Farbe ist nie die einzige Information; neben jedem Punkt steht, wie
* viele Tage es sind.
*/
const STUFEN_TITEL: Record<Exclude<Ampelstufe, 'gruen'>, string> = {
rot: 'Überfällig',
orange: 'Diese Woche fällig',
gelb: 'Diesen Monat fällig',
}
const STUFEN_PUNKT: Record<Exclude<Ampelstufe, 'gruen'>, string> = {
rot: 'bg-ampel-rot',
orange: 'bg-ampel-orange',
gelb: 'bg-ampel-gelb',
}
export function FristenAmpel({ daten }: { daten: FristenAmpelDaten | null }) {
if (!daten) return null
const gruppen: { stufe: Exclude<Ampelstufe, 'gruen'>; eintraege: FristEintrag[] }[] = [
{ stufe: 'rot', eintraege: daten.ueberfaellig },
{ stufe: 'orange', eintraege: daten.dieseWoche },
{ stufe: 'gelb', eintraege: daten.diesenMonat },
]
const etwasFaellig = gruppen.some((g) => g.eintraege.length > 0)
const ueberfaellig = daten.ueberfaellig.length
return (
<Card className={cn(ueberfaellig > 0 && 'border-ampel-rot/60')}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<CalendarClock
className={cn('size-5', ueberfaellig > 0 ? 'text-ampel-rot' : 'text-muted-foreground')}
aria-hidden="true"
/>
Fristen
</CardTitle>
<div className="flex flex-wrap gap-1.5">
{daten.ueberfaellig.length > 0 && (
<Badge variant="rot">{daten.ueberfaellig.length} überfällig</Badge>
)}
{daten.dieseWoche.length > 0 && (
<Badge variant="orange">{daten.dieseWoche.length} diese Woche</Badge>
)}
{daten.diesenMonat.length > 0 && (
<Badge variant="gelb">{daten.diesenMonat.length} diesen Monat</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-5">
{/* Ausschlussfristen zuerst - sie verfallen ersatzlos. */}
{daten.ausschlussfristen.map((frist) => (
<Alert key={`ausschluss-${frist.id}`} variant="fehler">
<AlertTriangle aria-hidden="true" />
<AlertTitle>
Ausschlussfrist: {frist.titel} – {formatDatum(frist.datum)}
</AlertTitle>
<AlertDescription>
<p>{verfallwarnung(frist)}</p>
<p className="mt-2">
<Button asChild size="sm" variant="destructive">
<Link href={`/fristen?frist=${frist.id}`}>Frist jetzt bearbeiten</Link>
</Button>
</p>
</AlertDescription>
</Alert>
))}
{!etwasFaellig && (
<Leerzustand
icon={CalendarClock}
titel={
daten.gesamtOffen > 0
? 'Diesen Monat ist nichts fällig.'
: 'Es sind noch keine Fristen erfasst.'
}
beschreibung={
daten.gesamtOffen > 0
? `${daten.weitereOffen} offene ${
daten.weitereOffen === 1 ? 'Frist liegt' : 'Fristen liegen'
} weiter in der Zukunft.`
: 'Aus dem Bestellungsdatum lassen sich die gesetzlichen Pflichtfristen ableiten – Anfangsbericht, Vermögensverzeichnis, Jahresbericht, Aufwandspauschale.'
}
aktion={
<Button asChild>
<Link href="/fristen">Pflichtfristen ableiten</Link>
</Button>
}
/>
)}
{gruppen.map(
(gruppe) =>
gruppe.eintraege.length > 0 && (
<section key={gruppe.stufe} aria-labelledby={`fristen-${gruppe.stufe}`}>
<h3
id={`fristen-${gruppe.stufe}`}
className="mb-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground"
>
{STUFEN_TITEL[gruppe.stufe]} ({gruppe.eintraege.length})
</h3>
<ul className="divide-y">
{gruppe.eintraege.map((frist) => (
<li key={frist.id}>
<Link
href={frist.vorgangId ? `/vorgaenge/${frist.vorgangId}` : '/fristen'}
className={cn(
'flex min-h-11 items-start gap-3 py-2.5 transition-colors',
'hover:bg-accent focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring',
)}
>
<span
aria-hidden="true"
className={cn(
'mt-2 size-2.5 shrink-0 rounded-full',
STUFEN_PUNKT[gruppe.stufe],
)}
/>
<span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span>
<span className="block text-sm text-muted-foreground">
{formatDatum(frist.datum)} · {fristLage(frist.tage)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span>
</span>
{frist.ausschlussfrist && (
<Badge variant="rot" className="shrink-0">
Ausschlussfrist
</Badge>
)}
</Link>
</li>
))}
</ul>
</section>
),
)}
<div className="flex flex-wrap items-center justify-between gap-3">
<KachelWeg href="/fristen">Alle Fristen ansehen</KachelWeg>
{daten.weitereOffen > 0 && etwasFaellig && (
<span className="text-sm text-muted-foreground">
{daten.weitereOffen} weitere offene{' '}
{daten.weitereOffen === 1 ? 'Frist' : 'Fristen'} später im Jahr
</span>
)}
</div>
</CardContent>
</Card>
)
}
+106
View File
@@ -0,0 +1,106 @@
import * as React from 'react'
import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { cn } from '@/lib/utils'
/**
* Der gemeinsame Rahmen aller Dashboard-Kacheln.
*
* Regel fuer jede Kachel: Sie endet in einer Handlung. Der Fuss ist kein
* schmueckendes "mehr anzeigen", sondern der naechste Handgriff - deshalb
* ist er Pflicht.
*/
export function Kachel({
titel,
icon: Icon,
zaehler,
dringend,
hinweis,
aktion,
children,
className,
}: {
titel: string
icon: React.ComponentType<{ className?: string; 'aria-hidden'?: boolean }>
/** Zahl neben der Ueberschrift, z.B. offene Vorgaenge. */
zaehler?: number
/** Faerbt den Rahmen und den Zaehler rot. */
dringend?: boolean
hinweis?: React.ReactNode
aktion: React.ReactNode
children: React.ReactNode
className?: string
}) {
return (
<Card className={cn(dringend && 'border-ampel-rot/50', className)}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<CardTitle className="flex items-center gap-2">
<Icon
className={cn('size-5 shrink-0', dringend ? 'text-ampel-rot' : 'text-muted-foreground')}
aria-hidden={true}
/>
{titel}
</CardTitle>
{zaehler !== undefined && zaehler > 0 && (
<span
className={cn(
'min-w-7 rounded-full px-2 py-0.5 text-center text-sm font-semibold tabular-nums',
dringend ? 'bg-ampel-rot text-white' : 'bg-muted text-muted-foreground',
)}
>
{zaehler}
</span>
)}
</div>
{hinweis && <div className="text-sm text-muted-foreground">{hinweis}</div>}
</CardHeader>
<CardContent className="space-y-4">
{children}
<div className="pt-1">{aktion}</div>
</CardContent>
</Card>
)
}
/** Der Fussweg aus einer Kachel heraus - immer ein konkreter Handgriff. */
export function KachelWeg({ href, children }: { href: string; children: React.ReactNode }) {
return (
<Link
href={href}
className={cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-md text-sm font-medium text-primary',
'hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
{children}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
)
}
/** Eine Zeile in einer Kachel: anklickbar, mindestens 44 px hoch. */
export function KachelZeile({
href,
children,
className,
}: {
href: string
children: React.ReactNode
className?: string
}) {
return (
<Link
href={href}
className={cn(
'flex min-h-11 items-start gap-3 rounded-md border border-transparent px-2 py-2 -mx-2',
'transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring',
className,
)}
>
{children}
</Link>
)
}
+191
View File
@@ -0,0 +1,191 @@
import * as React from 'react'
import Link from 'next/link'
import { Banknote, HeartPulse, ScrollText } from 'lucide-react'
import { Card, CardContent } from '@/components/ui/card'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { fristLage, monatsname } from '@/lib/dashboard/bewertung'
import type { KennzahlenDaten } from '@/lib/dashboard/typen'
import { cn, formatCent, formatDatum } from '@/lib/utils'
/**
* Die drei Zahlen, die den Stand der Betreuung beschreiben: Vermoegen,
* Heimkosten, naechster Jahresbericht.
*
* Fehlt eine Zahl, steht hier nicht "0", sondern der Weg dorthin - eine
* frische Installation hat noch keine Wertstaende, und eine Null waere an
* dieser Stelle schlicht falsch.
*/
export function Kennzahlen({ daten }: { daten: KennzahlenDaten }) {
const etwasSichtbar =
daten.vermoegen !== null || daten.heimkosten !== null || daten.jahresbericht !== null
if (!etwasSichtbar) return null
return (
<section aria-labelledby="kennzahlen" className="space-y-3">
<h2 id="kennzahlen" className="text-lg font-semibold">
Wo stehen wir?
</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{daten.vermoegen !== null && (
<Kennzahl
icon={Banknote}
titel="Vermögen"
href="/finanzen"
wegweiser="Zum Vermögensverzeichnis"
>
{daten.vermoegen.postenGesamt === 0 ? (
<Fehlanzeige text="Es ist noch kein Vermögensposten erfasst. Konten, Versicherungen und Verbindlichkeiten gehören ins Vermögensverzeichnis." />
) : daten.vermoegen.stichtag === null ? (
<Fehlanzeige
text={`${daten.vermoegen.postenGesamt} Posten sind angelegt, aber zu keinem ist ein Wert zum Stichtag erfasst.`}
/>
) : (
<>
<p className="text-2xl font-semibold tabular-nums">
{formatCent(daten.vermoegen.nettoCent)}
</p>
<p className="text-sm text-muted-foreground">
Stand {formatDatum(daten.vermoegen.stichtag)}
</p>
<dl className="mt-2 space-y-0.5 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Vermögenswerte</dt>
<dd className="tabular-nums">{formatCent(daten.vermoegen.aktivaCent)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Verbindlichkeiten</dt>
<dd className="tabular-nums">
−{formatCent(daten.vermoegen.verbindlichkeitenCent)}
</dd>
</div>
</dl>
{daten.vermoegen.postenOhneWert > 0 && (
<p className="mt-2 text-sm text-ampel-orange">
{daten.vermoegen.postenOhneWert}{' '}
{daten.vermoegen.postenOhneWert === 1 ? 'Posten hat' : 'Posten haben'} noch
keinen Wert und {daten.vermoegen.postenOhneWert === 1 ? 'fehlt' : 'fehlen'} in
dieser Summe.
</p>
)}
</>
)}
</Kennzahl>
)}
{daten.heimkosten !== null && (
<Kennzahl
icon={HeartPulse}
titel="Heimkosten"
href="/pflege"
wegweiser="Heimkosten öffnen"
>
<p className="text-sm text-muted-foreground">
{monatsname(daten.heimkosten.monat)} {daten.heimkosten.jahr}
</p>
<p
className={cn(
'text-2xl font-semibold tabular-nums',
daten.heimkosten.deckungsluecke > 0 && 'text-ampel-rot',
)}
>
{daten.heimkosten.deckungsluecke > 0
? formatCent(daten.heimkosten.deckungsluecke)
: formatCent(0)}
</p>
<p className="text-sm text-muted-foreground">
{daten.heimkosten.deckungsluecke > 0
? 'ungedeckt – dieser Betrag muss aus dem Vermögen oder von der Sozialhilfe kommen'
: 'Die Kosten dieses Monats sind gedeckt.'}
</p>
<dl className="mt-2 space-y-0.5 text-sm">
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Kosten</dt>
<dd className="tabular-nums">{formatCent(daten.heimkosten.gesamtkostenCent)}</dd>
</div>
<div className="flex justify-between gap-3">
<dt className="text-muted-foreground">Deckung</dt>
<dd className="tabular-nums">{formatCent(daten.heimkosten.deckungCent)}</dd>
</div>
</dl>
</Kennzahl>
)}
{daten.jahresbericht !== null ? (
<Kennzahl
icon={ScrollText}
titel="Nächster Jahresbericht"
href="/berichte"
wegweiser="Bericht vorbereiten"
>
<p
className={cn(
'text-2xl font-semibold tabular-nums',
daten.jahresbericht.tage < 0 && 'text-ampel-rot',
daten.jahresbericht.tage >= 0 && daten.jahresbericht.tage <= 30 && 'text-ampel-orange',
)}
>
{daten.jahresbericht.tage < 0
? `${Math.abs(daten.jahresbericht.tage)} Tage überfällig`
: `${daten.jahresbericht.tage} Tage`}
</p>
<p className="text-sm text-muted-foreground">
{daten.jahresbericht.titel} · fällig am {formatDatum(daten.jahresbericht.datum)} (
{fristLage(daten.jahresbericht.tage)})
</p>
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" kurz className="mt-2" />
</Kennzahl>
) : (
<Kennzahl
icon={ScrollText}
titel="Nächster Jahresbericht"
href="/fristen"
wegweiser="Pflichtfristen ableiten"
>
<Fehlanzeige text="Es ist keine Frist für den Jahresbericht erfasst. Sie ergibt sich aus dem Bestellungsdatum und lässt sich unter Fristen ableiten." />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" kurz className="mt-2" />
</Kennzahl>
)}
</div>
</section>
)
}
function Kennzahl({
icon: Icon,
titel,
href,
wegweiser,
children,
}: {
icon: React.ComponentType<{ className?: string }>
titel: string
href: string
wegweiser: string
children: React.ReactNode
}) {
return (
<Card>
<CardContent className="space-y-1 p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
<Icon className="size-4" />
{titel}
</h3>
{children}
<p className="pt-2">
<Link
href={href}
className="inline-flex min-h-11 items-center text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{wegweiser}
</Link>
</p>
</CardContent>
</Card>
)
}
function Fehlanzeige({ text }: { text: string }) {
return <p className="text-sm text-muted-foreground">{text}</p>
}
@@ -0,0 +1,119 @@
import Link from 'next/link'
import { Landmark } from 'lucide-react'
import type { NachforderungStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg } from '@/components/dashboard/kachel'
import { fristLage } from '@/lib/dashboard/bewertung'
import type { NachforderungenDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* Die Nachforderungen des Sozialhilfetraegers.
*
* Eigene, prominente Kachel: Solange eine Unterlage fehlt, entscheidet der
* Bezirk nicht - und die Heimrechnung laeuft weiter. Der Balken beantwortet
* auf einen Blick "wie weit bin ich?".
*/
const STATUS_BESCHRIFTUNG: Record<NachforderungStatus, string> = {
OFFEN: 'offen',
IN_BESCHAFFUNG: 'in Beschaffung',
UEBERSANDT: 'übersandt',
ANERKANNT: 'anerkannt',
NICHT_VORHANDEN: 'nicht vorhanden',
}
export function NachforderungenKachel({ daten }: { daten: NachforderungenDaten | null }) {
if (!daten) return null
const ziel = daten.antragId ? `/sozialhilfe?antrag=${daten.antragId}` : '/sozialhilfe'
if (daten.gesamt === 0) {
return (
<Kachel
titel="Nachforderungen des Trägers"
icon={Landmark}
aktion={<KachelWeg href="/sozialhilfe">Zum Sozialhilfeantrag</KachelWeg>}
>
<Leerzustand
icon={Landmark}
titel="Es sind keine Nachforderungen erfasst."
beschreibung="Fordert der Bezirk Unterlagen an, trage jede angeforderte Unterlage als eigene Zeile ein – dann zeigt der Balken hier, was noch fehlt."
className="py-8"
aktion={
<Button asChild variant="outline">
<Link href="/sozialhilfe">Unterlagen erfassen</Link>
</Button>
}
/>
</Kachel>
)
}
return (
<Kachel
titel="Nachforderungen des Trägers"
icon={Landmark}
zaehler={daten.offen.length}
dringend={daten.ueberfaellig > 0}
hinweis={
<>
{daten.traeger ?? 'Sozialhilfeträger'}
{daten.aktenzeichen ? ` · Az. ${daten.aktenzeichen}` : ''}
{daten.ueberfaellig > 0 && (
<>
{' · '}
<span className="font-medium text-ampel-rot">
{daten.ueberfaellig} mit abgelaufener Frist
</span>
</>
)}
</>
}
aktion={
<Button asChild>
<Link href={ziel}>Unterlagen bearbeiten</Link>
</Button>
}
>
<Fortschritt
wert={daten.erledigt}
gesamt={daten.gesamt}
beschriftung="Unterlagen geliefert"
/>
{daten.offen.length === 0 ? (
<p className="text-sm text-muted-foreground">
Alle angeforderten Unterlagen sind übersandt. Jetzt liegt es beim Träger – der Vorgang
gehört auf „wartet auf Dritte“.
</p>
) : (
<ul className="divide-y">
{daten.offen.map((zeile) => (
<li key={zeile.id} className="flex items-start justify-between gap-3 py-2.5">
<div className="min-w-0">
<p className="font-medium">{zeile.bezeichnung}</p>
<p className="text-sm text-muted-foreground">
{STATUS_BESCHRIFTUNG[zeile.status]}
{zeile.fristBis
? ` · Frist ${formatDatum(zeile.fristBis)}${
zeile.tageBisFrist !== null ? ` (${fristLage(zeile.tageBisFrist)})` : ''
}`
: ' · keine Frist gesetzt'}
</p>
</div>
{zeile.tageBisFrist !== null && zeile.tageBisFrist < 0 && (
<Badge variant="rot" className="shrink-0">
überfällig
</Badge>
)}
</li>
))}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,91 @@
import Link from 'next/link'
import { Inbox } from 'lucide-react'
import type { DokumentArt } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg, KachelZeile } from '@/components/dashboard/kachel'
import type { PosteingangDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* Ungepruefter Posteingang.
*
* Ein Bescheid, der ungesichtet liegen bleibt, kostet die Widerspruchsfrist
* (§ 84 SGG: in der Regel ein Monat ab Bekanntgabe). Deshalb steht hier
* nicht "Dokumente", sondern das, was noch niemand angesehen hat.
*/
const ART_BESCHRIFTUNG: Partial<Record<DokumentArt, string>> = {
BESCHEID: 'Bescheid',
ANTRAG: 'Antrag',
SCHREIBEN: 'Schreiben',
RECHNUNG: 'Rechnung',
KONTOAUSZUG: 'Kontoauszug',
VERTRAG: 'Vertrag',
BESCHLUSS: 'Beschluss',
URKUNDE: 'Urkunde',
GUTACHTEN: 'Gutachten',
AUSWEIS: 'Ausweis',
PROTOKOLL: 'Protokoll',
QUITTUNG: 'Quittung',
MAHNUNG: 'Mahnung',
VOLLMACHT: 'Vollmacht',
ATTEST: 'Attest',
GRUNDBUCHAUSZUG: 'Grundbuchauszug',
FOTO: 'Foto',
SONSTIGES: 'Sonstiges',
}
export function PosteingangKachel({ daten }: { daten: PosteingangDaten | null }) {
if (!daten) return null
return (
<Kachel
titel="Ungeprüfter Posteingang"
icon={Inbox}
zaehler={daten.anzahl}
hinweis={
daten.anzahl > 0
? 'Bei Bescheiden zählt der Zugang, nicht das Datum auf dem Brief – bitte zuerst sichten.'
: undefined
}
aktion={<KachelWeg href="/dokumente?posteingang=NEU">Posteingang durchgehen</KachelWeg>}
>
{daten.anzahl === 0 ? (
<Leerzustand
icon={Inbox}
titel="Kein ungeprüfter Posteingang."
beschreibung="Neu eingescannte oder fotografierte Dokumente landen hier, bis sie gesichtet und zugeordnet sind."
className="py-8"
aktion={
<Button asChild variant="outline">
<Link href="/dokumente/neu?quelle=kamera">Beleg fotografieren</Link>
</Button>
}
/>
) : (
<ul className="divide-y">
{daten.neueste.map((dokument) => (
<li key={dokument.id}>
<KachelZeile href={`/dokumente/${dokument.id}`}>
<span className="min-w-0 flex-1">
<span className="block font-medium">{dokument.titel}</span>
<span className="block text-sm text-muted-foreground">
{ART_BESCHRIFTUNG[dokument.art] ?? dokument.art}
{dokument.absender ? ` · ${dokument.absender}` : ''}
{dokument.eingangAm ? ` · Eingang ${formatDatum(dokument.eingangAm)}` : ''}
</span>
</span>
</KachelZeile>
</li>
))}
{daten.anzahl > daten.neueste.length && (
<li className="pt-2.5 text-sm text-muted-foreground">
und {daten.anzahl - daten.neueste.length} weitere
</li>
)}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,90 @@
import Link from 'next/link'
import { Hourglass } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import { Kachel, KachelWeg } from '@/components/dashboard/kachel'
import { WARTEZEIT_DRINGEND_TAGE } from '@/lib/dashboard/bewertung'
import type { WartetAufAndereDaten } from '@/lib/dashboard/typen'
import { formatDatum } from '@/lib/utils'
/**
* "Wartet auf andere" - Vorgaenge, bei denen ich nichts tun kann ausser
* nachzufassen.
*
* Behoerden antworten nicht von allein. Ab drei Wochen ohne Rueckmeldung
* ist eine Sachstandsanfrage faellig; genau die bietet die Zeile an.
*/
export function WartetAufAndere({ daten }: { daten: WartetAufAndereDaten | null }) {
if (!daten) return null
return (
<Kachel
titel="Wartet auf andere"
icon={Hourglass}
zaehler={daten.gesamt}
dringend={daten.dringende > 0}
hinweis={
daten.dringende > 0
? `${daten.dringende} ${
daten.dringende === 1 ? 'Vorgang liegt' : 'Vorgänge liegen'
} seit über ${WARTEZEIT_DRINGEND_TAGE} Tagen ohne Rückmeldung.`
: undefined
}
aktion={<KachelWeg href="/vorgaenge?status=WARTET_AUF_DRITTE">Alle wartenden Vorgänge</KachelWeg>}
>
{daten.eintraege.length === 0 ? (
<Leerzustand
icon={Hourglass}
titel="Zurzeit wartet nichts auf Dritte."
beschreibung="Sobald ein Vorgang auf den Status „wartet auf Dritte“ gesetzt wird, erscheint er hier mit den Tagen seit dem letzten Kontakt."
className="py-8"
/>
) : (
<ul className="divide-y">
{daten.eintraege.map((eintrag) => (
<li key={eintrag.id} className="py-3 first:pt-0">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<Link
href={`/vorgaenge/${eintrag.id}`}
className="font-medium hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{eintrag.titel}
</Link>
<p className="text-sm text-muted-foreground">
{eintrag.gegenstelle ?? 'Gegenstelle nicht vermerkt'}
{eintrag.aktenzeichen ? ` · Az. ${eintrag.aktenzeichen}` : ''}
</p>
<p className="text-sm text-muted-foreground">
{eintrag.letzterKontaktAm
? `Letzter Kontakt am ${formatDatum(eintrag.letzterKontaktAm)}`
: 'Kein Kontakt vermerkt – gerechnet ab dem Anlegen'}
</p>
{eintrag.naechsterSchritt && (
<p className="mt-1 text-sm">
<span className="font-medium">Nächster Schritt:</span>{' '}
{eintrag.naechsterSchritt}
</p>
)}
</div>
<Badge variant={eintrag.dringend ? 'rot' : 'gelb'} className="shrink-0">
{eintrag.tageOhneRueckmeldung}{' '}
{eintrag.tageOhneRueckmeldung === 1 ? 'Tag' : 'Tage'} ohne Antwort
</Badge>
</div>
<div className="mt-2">
<Button asChild size="sm" variant={eintrag.dringend ? 'default' : 'outline'}>
<Link href={`/vorgaenge/${eintrag.id}?aktion=sachstandsanfrage`}>
Sachstandsanfrage erzeugen
</Link>
</Button>
</div>
</li>
))}
</ul>
)}
</Kachel>
)
}
@@ -0,0 +1,71 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Check, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { fristErledigen } from '@/server/actions/fristen'
/**
* Ein Klick, eine Frist erledigt.
*
* Bewusst ohne Rueckfragedialog: Der Handgriff kommt oft unterwegs, mit dem
* Daumen, im Heim. Ein versehentliches Abhaken laesst sich ueber "Bearbeiten"
* wieder zuruecknehmen, und im Audit-Log steht beides.
*/
export function ErledigenKnopf({
betreuteId,
fristId,
titel,
wiederkehrend = false,
groesse = 'sm',
className,
}: {
betreuteId: string
fristId: string
titel: string
wiederkehrend?: boolean
groesse?: 'sm' | 'default'
className?: string
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [fehler, setFehler] = useState<string | null>(null)
function abhaken() {
setFehler(null)
starten(async () => {
const ergebnis = await fristErledigen(betreuteId, fristId)
if (!ergebnis.ok) {
setFehler(ergebnis.fehler)
return
}
router.refresh()
})
}
return (
<div className={className}>
<Button
type="button"
variant="outline"
size={groesse}
onClick={abhaken}
disabled={laeuft}
aria-label={`Frist „${titel}" als erledigt abhaken`}
>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Check aria-hidden="true" />
)}
{laeuft ? 'Wird gespeichert…' : wiederkehrend ? 'Erledigt, nächste anlegen' : 'Erledigt'}
</Button>
{fehler && (
<p role="alert" className="mt-1.5 text-sm font-medium text-destructive">
{fehler}
</p>
)}
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import { useTransition } from 'react'
import { RotateCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Feld } from '@/components/feld'
import { Select } from '@/components/ui/select'
import {
FRIST_STATUS,
FRIST_STATUS_BESCHRIFTUNG,
FRIST_TYPEN,
FRIST_TYP_BESCHRIFTUNG,
} from '@/lib/validierung/fristen'
/**
* Filter fuer die Fristenliste.
*
* Der Zustand steht in der Adresse, nicht im Bauteil: So laesst sich eine
* gefilterte Ansicht als Lesezeichen ablegen ("alle Ausschlussfristen") und
* der Zurueck-Knopf des Browsers tut das Erwartete.
*/
export const ZEITRAEUME = [
{ wert: 'offen', beschriftung: 'Alles Offene' },
{ wert: 'ueberfaellig', beschriftung: 'Nur Überfälliges' },
{ wert: 'woche', beschriftung: 'Nächste 7 Tage' },
{ wert: 'monat', beschriftung: 'Nächste 31 Tage' },
{ wert: 'jahr', beschriftung: 'Nächste 12 Monate' },
{ wert: 'alle', beschriftung: 'Alle, auch Erledigtes' },
] as const
export const KATEGORIEN = [
{ wert: '', beschriftung: 'Alle Kategorien' },
{ wert: 'pflicht', beschriftung: 'Gesetzliche Pflichtfristen' },
{ wert: 'ausschluss', beschriftung: 'Nur Ausschlussfristen' },
{ wert: 'wiederkehrend', beschriftung: 'Nur Wiederkehrende' },
{ wert: 'eigene', beschriftung: 'Selbst angelegte' },
] as const
export function Filterleiste() {
const router = useRouter()
const parameter = useSearchParams()
const [laeuft, starten] = useTransition()
function setzen(name: string, wert: string) {
const neu = new URLSearchParams(parameter.toString())
if (wert === '') neu.delete(name)
else neu.set(name, wert)
const abfrage = neu.toString()
starten(() => router.push(abfrage ? `/fristen?${abfrage}` : '/fristen'))
}
const etwasGesetzt = ['zeitraum', 'kategorie', 'typ', 'status'].some((n) => parameter.get(n))
return (
<div className="rounded-lg border bg-card p-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Feld id="filter-zeitraum" beschriftung="Zeitraum">
<Select
value={parameter.get('zeitraum') ?? 'offen'}
onChange={(e) => setzen('zeitraum', e.target.value)}
disabled={laeuft}
>
{ZEITRAEUME.map((z) => (
<option key={z.wert} value={z.wert}>
{z.beschriftung}
</option>
))}
</Select>
</Feld>
<Feld id="filter-kategorie" beschriftung="Kategorie">
<Select
value={parameter.get('kategorie') ?? ''}
onChange={(e) => setzen('kategorie', e.target.value)}
disabled={laeuft}
>
{KATEGORIEN.map((k) => (
<option key={k.wert} value={k.wert}>
{k.beschriftung}
</option>
))}
</Select>
</Feld>
<Feld id="filter-typ" beschriftung="Art der Frist">
<Select
value={parameter.get('typ') ?? ''}
onChange={(e) => setzen('typ', e.target.value)}
disabled={laeuft}
>
<option value="">Alle Arten</option>
{FRIST_TYPEN.map((t) => (
<option key={t} value={t}>
{FRIST_TYP_BESCHRIFTUNG[t]}
</option>
))}
</Select>
</Feld>
<Feld id="filter-status" beschriftung="Status">
<Select
value={parameter.get('status') ?? ''}
onChange={(e) => setzen('status', e.target.value)}
disabled={laeuft}
>
<option value="">Alle Status</option>
{FRIST_STATUS.map((s) => (
<option key={s} value={s}>
{FRIST_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
</div>
{etwasGesetzt && (
<div className="mt-4">
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={() => starten(() => router.push('/fristen'))}
>
<RotateCcw aria-hidden="true" />
Filter zurücksetzen
</Button>
</div>
)}
</div>
)
}
+169
View File
@@ -0,0 +1,169 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { CalendarClock, Loader2, Trash2, XCircle } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Feld } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
fristEntfallenLassen,
fristInPapierkorb,
fristVerschieben,
} from '@/server/actions/fristen'
/**
* Die drei Wege, eine Frist loszuwerden, ohne sie zu erledigen.
*
* Sie sind bewusst nicht gleich sichtbar wie "Erledigt": Verschieben,
* Entfallen und Papierkorb sind Ausnahmen, und jede hinterlaesst eine Spur
* im Audit-Log.
*/
export function FristAktionen({
betreuteId,
fristId,
datum,
ausschlussfrist,
}: {
betreuteId: string
fristId: string
/** ISO-Tagesdatum, "2026-09-14". */
datum: string
ausschlussfrist: boolean
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [offen, setOffen] = useState<'verschieben' | 'entfallen' | null>(null)
const [neuesDatum, setNeuesDatum] = useState(datum)
const [grund, setGrund] = useState('')
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null)
function melden(ergebnis: { ok: boolean; hinweis?: string; fehler?: string }) {
if (ergebnis.ok) {
setMeldung({ art: 'ok', text: ergebnis.hinweis ?? 'Gespeichert.' })
setOffen(null)
setGrund('')
router.refresh()
} else {
setMeldung({ art: 'fehler', text: ergebnis.fehler ?? 'Das hat nicht geklappt.' })
}
}
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{!ausschlussfrist && (
<Button
type="button"
variant="outline"
size="sm"
disabled={laeuft}
onClick={() => setOffen(offen === 'verschieben' ? null : 'verschieben')}
>
<CalendarClock aria-hidden="true" />
Verschieben
</Button>
)}
<Button
type="button"
variant="outline"
size="sm"
disabled={laeuft}
onClick={() => setOffen(offen === 'entfallen' ? null : 'entfallen')}
>
<XCircle aria-hidden="true" />
Entfallen lassen
</Button>
<Button
type="button"
variant="ghost"
size="sm"
disabled={laeuft}
onClick={() =>
starten(async () => {
const ergebnis = await fristInPapierkorb(betreuteId, fristId)
if (ergebnis.ok) router.push('/fristen')
else melden(ergebnis)
})
}
>
<Trash2 aria-hidden="true" />
In den Papierkorb
</Button>
</div>
{ausschlussfrist && (
<p className="text-sm text-muted-foreground">
Ausschlussfristen lassen sich nicht verschieben — das Verfallsdatum steht im Gesetz
und ändert sich nicht dadurch, dass man es im Kalender umträgt.
</p>
)}
{offen === 'verschieben' && (
<div className="space-y-3 rounded-lg border bg-muted/40 p-4">
<Feld
id="verschieben-datum"
beschriftung="Neuer Stichtag"
erklaerung="Die alte Frist bleibt als „verschoben“ in der Akte stehen."
>
<Input
type="date"
value={neuesDatum}
onChange={(e) => setNeuesDatum(e.target.value)}
/>
</Feld>
<Feld id="verschieben-grund" beschriftung="Grund (empfohlen)">
<Textarea rows={2} value={grund} onChange={(e) => setGrund(e.target.value)} />
</Feld>
<Button
type="button"
disabled={laeuft}
onClick={() =>
starten(async () => melden(await fristVerschieben(betreuteId, fristId, neuesDatum, grund)))
}
>
{laeuft && <Loader2 className="animate-spin" aria-hidden="true" />}
Verschieben
</Button>
</div>
)}
{offen === 'entfallen' && (
<div className="space-y-3 rounded-lg border bg-muted/40 p-4">
<Feld
id="entfallen-grund"
beschriftung="Warum ist die Frist gegenstandslos?"
erklaerung="Zum Beispiel: Antrag zurückgenommen, Konto aufgelöst, Vorgang erledigt sich anders."
>
<Textarea rows={2} value={grund} onChange={(e) => setGrund(e.target.value)} />
</Feld>
<Button
type="button"
variant="secondary"
disabled={laeuft}
onClick={() =>
starten(async () => melden(await fristEntfallenLassen(betreuteId, fristId, grund)))
}
>
{laeuft && <Loader2 className="animate-spin" aria-hidden="true" />}
Als entfallen vermerken
</Button>
</div>
)}
{meldung && (
<p
role="status"
className={
meldung.art === 'fehler'
? 'text-sm font-medium text-destructive'
: 'text-sm text-muted-foreground'
}
>
{meldung.text}
</p>
)}
</div>
)
}
+288
View File
@@ -0,0 +1,288 @@
'use client'
import { useActionState, useState } from 'react'
import Link from 'next/link'
import { useFormStatus } from 'react-dom'
import { AlertCircle, Loader2 } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Feld, Feldgruppe } from '@/components/feld'
import { Input } from '@/components/ui/input'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import type { FristAnzeige } from '@/components/fristen/typen'
import { WIEDERHOLUNGEN } from '@/lib/fristen/wiederholung'
import {
FRIST_TYPEN,
FRIST_TYP_BESCHRIFTUNG,
FRIST_TYP_ERKLAERUNG,
VORWARNUNG_STUFEN,
type FristZustand,
} from '@/lib/validierung/fristen'
/**
* Formular zum Anlegen und Aendern einer Frist.
*
* Die Pflichtangaben stehen oben und sind wenige: Titel und Datum. Alles
* andere ist Feinschliff und darf leer bleiben - eine Frist, die man nicht
* anlegt, weil das Formular zu lang war, hilft niemandem.
*/
export function FristFormular({
betreuteId,
aktion,
frist,
vorgaenge,
}: {
betreuteId: string
aktion: (vorher: FristZustand, formular: FormData) => Promise<FristZustand>
frist?: FristAnzeige
vorgaenge: { id: string; titel: string }[]
}) {
const [zustand, absenden] = useActionState<FristZustand, FormData>(aktion, {})
const werte = zustand.werte ?? {}
const wert = (name: string, standard: string) => {
const w = werte[name]
if (Array.isArray(w)) return w[0] ?? standard
return w ?? standard
}
const vorwarnungVorbelegt: string[] = Array.isArray(werte.vorwarnungTage)
? werte.vorwarnungTage
: (frist?.vorwarnungTage ?? [60, 30, 7, 1]).map(String)
const bekannteRegel = WIEDERHOLUNGEN.some(
(w) => w.wert === (frist?.wiederholungRrule ?? ''),
)
const [eigeneRegelSichtbar, setEigeneRegelSichtbar] = useState(
Boolean(frist?.wiederholungRrule) && !bekannteRegel,
)
return (
<form action={absenden} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{frist && <input type="hidden" name="fristId" value={frist.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feldgruppe
titel="Was ist zu tun?"
beschreibung="Schreib den Titel so, dass du in einem Jahr noch weißt, was gemeint war."
>
<Feld
id="titel"
beschriftung="Titel"
pflicht
fehler={zustand.felder?.titel}
className="sm:col-span-2"
>
<Input
name="titel"
defaultValue={wert('titel', frist?.titel ?? '')}
maxLength={200}
required
autoComplete="off"
/>
</Feld>
<Feld
id="datum"
beschriftung="Stichtag"
pflicht
fehler={zustand.felder?.datum}
erklaerung="Der Tag, an dem es erledigt sein muss — nicht der Tag, an dem du anfangen willst."
>
<Input type="date" name="datum" defaultValue={wert('datum', frist?.datum ?? '')} required />
</Feld>
<Feld
id="typ"
beschriftung="Art der Frist"
fehler={zustand.felder?.typ}
erklaerung={FRIST_TYP_ERKLAERUNG[frist?.typ ?? 'SELBST_GESETZT']}
>
<Select name="typ" defaultValue={wert('typ', frist?.typ ?? 'SELBST_GESETZT')}>
{FRIST_TYPEN.map((t) => (
<option key={t} value={t}>
{FRIST_TYP_BESCHRIFTUNG[t]}
</option>
))}
</Select>
</Feld>
<Feld
id="beschreibung"
beschriftung="Was genau ist zu tun?"
fehler={zustand.felder?.beschreibung}
erklaerung="Der nächste konkrete Handgriff, benötigte Unterlagen, Ansprechpartner."
className="sm:col-span-2"
>
<Textarea
name="beschreibung"
rows={5}
maxLength={4000}
defaultValue={wert('beschreibung', frist?.beschreibung ?? '')}
/>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Einordnung"
beschreibung="Woraus ergibt sich die Frist, und wozu gehört sie?"
>
<Feld
id="rechtsgrundlage"
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
erklaerung="Freitext, z. B. „§ 1863 Abs. 3 BGB“ oder „Bescheid vom 12.03.2026“."
>
<Input
name="rechtsgrundlage"
maxLength={120}
defaultValue={wert('rechtsgrundlage', frist?.rechtsgrundlage ?? '')}
autoComplete="off"
/>
</Feld>
<Feld
id="vorgangId"
beschriftung="Gehört zu einem Vorgang"
fehler={zustand.felder?.vorgangId}
erklaerung="Optional. Die Frist taucht dann auch in der Fallakte auf."
>
<Select name="vorgangId" defaultValue={wert('vorgangId', frist?.vorgang?.id ?? '')}>
<option value="">Keinem Vorgang zuordnen</option>
{vorgaenge.map((v) => (
<option key={v.id} value={v.id}>
{v.titel}
</option>
))}
</Select>
</Feld>
<div className="sm:col-span-2">
<label className="flex items-start gap-3 rounded-md border border-ampel-rot/30 bg-ampel-rot/5 p-3">
<Checkbox
name="ausschlussfrist"
defaultChecked={
werte.ausschlussfrist ? werte.ausschlussfrist === 'on' : (frist?.ausschlussfrist ?? false)
}
className="mt-0.5"
/>
<span className="text-sm">
<span className="font-medium">Das ist eine Ausschlussfrist.</span>{' '}
<span className="text-muted-foreground">
Läuft sie ab, ist der Anspruch ersatzlos weg — es gibt keine Nachsicht und
keine Wiedereinsetzung. Solche Fristen werden deutlich rot markiert und früher
vorgewarnt. Beispiel: die Aufwandspauschale nach § 1878 BGB.
</span>
</span>
</label>
</div>
</Feldgruppe>
<Feldgruppe
titel="Erinnerung und Wiederholung"
beschreibung="Wann soll die Frist vorher auffallen, und kommt sie regelmäßig wieder?"
>
<fieldset className="sm:col-span-2">
<legend className="text-sm font-medium">Vorwarnung</legend>
<p className="mt-0.5 text-sm text-muted-foreground">
An diesen Tagen vor dem Stichtag rückt die Frist im Dashboard nach oben und
erscheint als Wecker im abonnierten Kalender.
</p>
<div className="mt-2 flex flex-wrap gap-x-5 gap-y-2">
{VORWARNUNG_STUFEN.map((stufe) => (
<label key={stufe} className="flex items-center gap-2 py-1.5 text-sm">
<Checkbox
name="vorwarnungTage"
value={String(stufe)}
defaultChecked={vorwarnungVorbelegt.includes(String(stufe))}
/>
{stufe === 1 ? '1 Tag' : `${stufe} Tage`}
</label>
))}
</div>
{zustand.felder?.vorwarnungTage && (
<p className="mt-1.5 text-sm font-medium text-destructive">
{zustand.felder.vorwarnungTage}
</p>
)}
</fieldset>
<Feld
id="wiederholungRrule"
beschriftung="Wiederholung"
fehler={zustand.felder?.wiederholungRrule}
erklaerung="Beim Abhaken legt die Anwendung den nächsten Termin automatisch an."
>
<Select
name="wiederholungRrule"
defaultValue={bekannteRegel ? (frist?.wiederholungRrule ?? '') : ''}
onChange={(e) => {
if (e.target.value !== '') setEigeneRegelSichtbar(false)
}}
>
{WIEDERHOLUNGEN.map((w) => (
<option key={w.wert} value={w.wert}>
{w.beschriftung}
</option>
))}
</Select>
</Feld>
<div className="space-y-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setEigeneRegelSichtbar((s) => !s)}
>
{eigeneRegelSichtbar ? 'Eigene Regel verwerfen' : 'Eigene Regel eingeben'}
</Button>
{eigeneRegelSichtbar && (
<Feld
id="wiederholungEigene"
beschriftung="Eigene Wiederholungsregel"
erklaerung="iCalendar-Schreibweise, z. B. FREQ=MONTHLY;INTERVAL=4. Sie hat Vorrang vor der Auswahl."
>
<Input
name="wiederholungEigene"
defaultValue={bekannteRegel ? '' : (frist?.wiederholungRrule ?? '')}
autoComplete="off"
spellCheck={false}
/>
</Feld>
)}
</div>
</Feldgruppe>
<Rechtshinweis rechtsgrundlage="§ 1863 BGB, § 1878 BGB" />
<div className="flex flex-wrap gap-3">
<Absendeknopf neu={!frist} />
<Button type="button" variant="outline" asChild>
<Link href={frist ? `/fristen/${frist.id}` : '/fristen'}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
function Absendeknopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert…' : neu ? 'Frist anlegen' : 'Änderungen speichern'}
</Button>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { Fristkarte } from '@/components/fristen/fristkarte'
import type { FristAnzeige } from '@/components/fristen/typen'
import {
AMPEL_ERKLAERUNG,
AMPEL_REIHENFOLGE,
AMPEL_UEBERSCHRIFT,
fristAmpel,
type Ampelstufe,
} from '@/lib/fristen/ampel'
import { ausIsoTag } from '@/lib/fristen/datum'
/**
* Fristen, gruppiert nach Ampelstufe.
*
* Die Reihenfolge ist nicht verhandelbar: Ueberfaellig steht oben. Wer die
* Seite oeffnet, soll ohne Scrollen sehen, was schiefliegt - alles andere
* kann warten.
*/
export function Fristenliste({
fristen,
darfSchreiben,
}: {
fristen: FristAnzeige[]
darfSchreiben: boolean
}) {
const gruppen = new Map<Ampelstufe, FristAnzeige[]>()
for (const stufe of AMPEL_REIHENFOLGE) gruppen.set(stufe, [])
for (const frist of fristen) {
const datum = ausIsoTag(frist.datum)
if (!datum) continue
gruppen.get(fristAmpel(datum, frist.status).stufe)!.push(frist)
}
// Innerhalb einer Gruppe nach Datum: bei den ueberfaelligen zuerst die
// aeltesten, sonst die naechstliegenden.
for (const [stufe, liste] of gruppen) {
liste.sort((a, b) => a.datum.localeCompare(b.datum))
if (stufe === 'erledigt') liste.reverse()
}
return (
<div className="space-y-8">
{AMPEL_REIHENFOLGE.map((stufe) => {
const liste = gruppen.get(stufe)!
if (liste.length === 0) return null
return (
<section key={stufe} aria-labelledby={`gruppe-${stufe}`}>
<div className="mb-3 flex flex-wrap items-baseline gap-x-3 gap-y-1">
<h2 id={`gruppe-${stufe}`} className="text-lg font-semibold tracking-tight">
{AMPEL_UEBERSCHRIFT[stufe]}
<span className="ml-2 text-sm font-normal text-muted-foreground">
{liste.length}
</span>
</h2>
<p className="text-sm text-muted-foreground">{AMPEL_ERKLAERUNG[stufe]}</p>
</div>
<ul className="space-y-3">
{liste.map((frist) => (
<li key={frist.id}>
<Fristkarte frist={frist} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
</section>
)
})}
</div>
)
}
+111
View File
@@ -0,0 +1,111 @@
import Link from 'next/link'
import { AlertTriangle, CalendarClock, FolderKanban, Repeat, Scale } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { ErledigenKnopf } from '@/components/fristen/erledigen-knopf'
import type { FristAnzeige } from '@/components/fristen/typen'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import { ausIsoTag, nichtWerktagHinweis, wochentagName } from '@/lib/fristen/datum'
import { rruleBeschreiben } from '@/lib/fristen/wiederholung'
import { FRIST_TYP_BESCHRIFTUNG } from '@/lib/validierung/fristen'
import { formatDatum } from '@/lib/utils'
/**
* Eine Frist als Karte.
*
* Reihenfolge der Information ist die Reihenfolge der Fragen, die sich im
* Alltag stellen: Wie dringend? Was ist zu tun? Bis wann? Und erst dann,
* woraus sich das ergibt.
*/
export function Fristkarte({
frist,
darfSchreiben,
}: {
frist: FristAnzeige
darfSchreiben: boolean
}) {
const datum = ausIsoTag(frist.datum)
if (!datum) return null
const ampel = fristAmpel(datum, frist.status)
const text = ampelText(ampel, frist.ausschlussfrist)
const werktagshinweis = frist.status === 'OFFEN' ? nichtWerktagHinweis(datum) : null
const abgeschlossen = ampel.stufe === 'erledigt'
return (
<article
className={[
'rounded-lg border bg-card p-4 shadow-sm transition-colors',
abgeschlossen ? 'opacity-70' : '',
frist.ausschlussfrist && !abgeschlossen ? 'border-ampel-rot/50' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="flex flex-wrap items-center gap-2">
<Badge variant={ampel.variante}>
{ampel.stufe === 'ueberfaellig' && <AlertTriangle className="size-3" aria-hidden="true" />}
{text}
</Badge>
{frist.ausschlussfrist && !abgeschlossen && (
<Badge variant="rot">Ausschlussfrist — Anspruch verfällt</Badge>
)}
<Badge variant="outline">{FRIST_TYP_BESCHRIFTUNG[frist.typ]}</Badge>
{frist.wiederholungRrule && (
<Badge variant="secondary">
<Repeat className="size-3" aria-hidden="true" />
{rruleBeschreiben(frist.wiederholungRrule)}
</Badge>
)}
</div>
<h3 className="mt-2.5 text-base font-semibold leading-snug">
<Link href={`/fristen/${frist.id}`} className="hover:underline">
{frist.titel}
</Link>
</h3>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
<CalendarClock className="size-4 shrink-0" aria-hidden="true" />
<span>
{wochentagName(datum)}, {formatDatum(datum)}
</span>
{frist.rechtsgrundlage && (
<span className="inline-flex items-center gap-1">
<Scale className="size-3.5 shrink-0" aria-hidden="true" />
{frist.rechtsgrundlage}
</span>
)}
{frist.vorgang && (
<Link
href={`/vorgaenge/${frist.vorgang.id}`}
className="inline-flex items-center gap-1 hover:text-foreground hover:underline"
>
<FolderKanban className="size-3.5 shrink-0" aria-hidden="true" />
{frist.vorgang.titel}
</Link>
)}
</p>
{werktagshinweis && (
<p className="mt-2 text-sm text-ampel-orange">{werktagshinweis}</p>
)}
{frist.status === 'ERLEDIGT' && frist.erledigtAm && (
<p className="mt-2 text-sm text-muted-foreground">
Erledigt am {formatDatum(ausIsoTag(frist.erledigtAm))}.
</p>
)}
{darfSchreiben && frist.status === 'OFFEN' && (
<div className="mt-3 flex flex-wrap gap-2">
<ErledigenKnopf
betreuteId={frist.betreuteId}
fristId={frist.id}
titel={frist.titel}
wiederkehrend={Boolean(frist.wiederholungRrule)}
/>
</div>
)}
</article>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use client'
import { useState } from 'react'
import { Check, Copy } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
/**
* Die Adresse des abonnierbaren Kalenders zum Kopieren.
*
* Die Adresse ist der Schluessel - wer sie hat, sieht die Fristen. Deshalb
* steht der Warnhinweis direkt daneben und nicht in einer Hilfeseite.
*/
export function KalenderAbo({ feedUrl }: { feedUrl: string }) {
const [kopiert, setKopiert] = useState(false)
async function kopieren() {
try {
await navigator.clipboard.writeText(feedUrl)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 2500)
} catch {
// Ohne Zwischenablage-Recht bleibt das Feld zum Markieren von Hand -
// deshalb hier kein Fehler, sondern einfach nichts.
setKopiert(false)
}
}
return (
<div className="space-y-3 rounded-lg border bg-card p-4">
<div>
<h2 className="text-base font-semibold">Fristen im eigenen Kalender abonnieren</h2>
<p className="mt-1 text-sm text-muted-foreground">
Diese Adresse in Google Kalender, Apple Kalender oder Outlook als Abonnement
eintragen (nicht als Import — sonst aktualisiert sich nichts). Die Vorwarnstufen
kommen als Erinnerungen mit.
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<Feld
id="kalender-feed"
beschriftung="Kalenderadresse"
className="min-w-0 flex-1"
erklaerung="Wer diese Adresse kennt, sieht die Termine — behandle sie wie ein Passwort."
>
<Input readOnly value={feedUrl} onFocus={(e) => e.currentTarget.select()} spellCheck={false} />
</Feld>
<Button type="button" variant="outline" onClick={kopieren} className="shrink-0">
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Kopieren'}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Der Kalender enthält bewusst nur Titel und Stichtag — keine Diagnosen, keine
Kontostände, keinen Namen der betreuten Person.
</p>
</div>
)
}
+256
View File
@@ -0,0 +1,256 @@
import Link from 'next/link'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Leerzustand } from '@/components/seiten-kopf'
import type { FristAnzeige } from '@/components/fristen/typen'
import { ampelText, fristAmpel } from '@/lib/fristen/ampel'
import {
alsIsoTag,
ausIsoTag,
feiertagName,
heuteAlsTag,
istWochenende,
plusTage,
tag,
} from '@/lib/fristen/datum'
import { formatDatum } from '@/lib/utils'
/**
* Monatsansicht am Bildschirm, Agenda am Handy.
*
* Ein Gitter mit 42 winzigen Zellen ist auf einem Telefon unbrauchbar -
* die Zellen waeren kleiner als eine Fingerkuppe. Deshalb steht dort
* dieselbe Information als Liste: Tag fuer Tag, nur die Tage mit Fristen.
*/
const MONATSNAMEN = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
] as const
const WOCHENTAGE_KURZ = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] as const
export function monatsname(monat: number): string {
return MONATSNAMEN[monat - 1] ?? ''
}
export function KalenderMonat({
jahr,
monat,
fristen,
}: {
jahr: number
monat: number
fristen: FristAnzeige[]
}) {
const heute = heuteAlsTag()
const ersterTag = tag(jahr, monat, 1)
// getUTCDay liefert 0 fuer Sonntag; in Deutschland beginnt die Woche am
// Montag, deshalb die Verschiebung.
const versatz = (ersterTag.getUTCDay() + 6) % 7
const rasterStart = plusTage(ersterTag, -versatz)
const nachTag = new Map<string, FristAnzeige[]>()
for (const frist of fristen) {
const liste = nachTag.get(frist.datum) ?? []
liste.push(frist)
nachTag.set(frist.datum, liste)
}
const vorigerMonat = monat === 1 ? { jahr: jahr - 1, monat: 12 } : { jahr, monat: monat - 1 }
const naechsterMonat = monat === 12 ? { jahr: jahr + 1, monat: 1 } : { jahr, monat: monat + 1 }
const zellen = Array.from({ length: 42 }, (_, i) => plusTage(rasterStart, i))
// Sechste Zeile nur zeigen, wenn sie noch Tage dieses Monats enthaelt.
const zeilen = zellen[35]!.getUTCMonth() === monat - 1 ? 6 : 5
const tageMitFristen = [...nachTag.entries()]
.filter(([schluessel]) => schluessel.startsWith(`${jahr}-${String(monat).padStart(2, '0')}`))
.sort((a, b) => a[0].localeCompare(b[0]))
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-2">
<Button variant="outline" size="icon" asChild>
<Link
href={`/fristen/kalender?jahr=${vorigerMonat.jahr}&monat=${vorigerMonat.monat}`}
aria-label={`Zum ${monatsname(vorigerMonat.monat)} ${vorigerMonat.jahr}`}
>
<ChevronLeft aria-hidden="true" />
</Link>
</Button>
<h2 className="text-lg font-semibold tracking-tight" aria-live="polite">
{monatsname(monat)} {jahr}
</h2>
<Button variant="outline" size="icon" asChild>
<Link
href={`/fristen/kalender?jahr=${naechsterMonat.jahr}&monat=${naechsterMonat.monat}`}
aria-label={`Zum ${monatsname(naechsterMonat.monat)} ${naechsterMonat.jahr}`}
>
<ChevronRight aria-hidden="true" />
</Link>
</Button>
</div>
{/* ---------------- Monatsgitter, ab Tablet ---------------- */}
<div className="hidden overflow-x-auto rounded-lg border sm:block">
<table className="w-full min-w-[42rem] table-fixed border-collapse">
<caption className="sr-only">
Fristen im {monatsname(monat)} {jahr}
</caption>
<thead>
<tr>
{WOCHENTAGE_KURZ.map((name) => (
<th
key={name}
scope="col"
className="border-b bg-muted/50 p-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{name}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: zeilen }, (_, woche) => (
<tr key={woche}>
{Array.from({ length: 7 }, (_, wochentag) => {
const datum = zellen[woche * 7 + wochentag]!
const schluessel = alsIsoTag(datum)
const imMonat = datum.getUTCMonth() === monat - 1
const istHeute = schluessel === alsIsoTag(heute)
const feiertag = feiertagName(datum)
const eintraege = nachTag.get(schluessel) ?? []
return (
<td
key={wochentag}
className={[
'h-28 border border-border/60 align-top p-1.5',
imMonat ? '' : 'bg-muted/30 text-muted-foreground',
istWochenende(datum) || feiertag ? 'bg-muted/40' : '',
istHeute ? 'ring-2 ring-inset ring-primary' : '',
]
.filter(Boolean)
.join(' ')}
>
<div className="flex items-baseline justify-between gap-1">
<span
className={['text-sm', istHeute ? 'font-bold text-primary' : '']
.filter(Boolean)
.join(' ')}
>
{datum.getUTCDate()}
{istHeute && <span className="sr-only"> (heute)</span>}
</span>
{feiertag && (
<span
className="truncate text-[0.65rem] text-muted-foreground"
title={feiertag}
>
{feiertag}
</span>
)}
</div>
<ul className="mt-1 space-y-1">
{eintraege.map((frist) => {
const fristDatum = ausIsoTag(frist.datum)!
const ampel = fristAmpel(fristDatum, frist.status)
return (
<li key={frist.id}>
<Link
href={`/fristen/${frist.id}`}
className={[
'block truncate rounded px-1.5 py-1 text-xs hover:underline',
ampel.variante === 'rot'
? 'bg-ampel-rot/15 text-ampel-rot'
: ampel.variante === 'orange'
? 'bg-ampel-orange/15 text-ampel-orange'
: ampel.variante === 'gelb'
? 'bg-ampel-gelb/15 text-ampel-gelb'
: ampel.variante === 'gruen'
? 'bg-ampel-gruen/15 text-ampel-gruen'
: 'bg-secondary text-secondary-foreground',
].join(' ')}
title={`${frist.titel} — ${ampelText(ampel, frist.ausschlussfrist)}`}
>
{frist.ausschlussfrist && (
<span aria-hidden="true">! </span>
)}
{frist.titel}
</Link>
</li>
)
})}
</ul>
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
{/* ---------------- Agenda am Handy ---------------- */}
<div className="sm:hidden">
{tageMitFristen.length === 0 ? (
<Leerzustand
titel={`Im ${monatsname(monat)} ${jahr} steht nichts an`}
beschreibung="Blättere zum nächsten Monat oder lege eine Frist an."
/>
) : (
<ol className="space-y-4">
{tageMitFristen.map(([schluessel, eintraege]) => {
const datum = ausIsoTag(schluessel)!
const feiertag = feiertagName(datum)
return (
<li key={schluessel}>
<h3 className="mb-2 text-sm font-semibold">
{formatDatum(datum)}
{feiertag && (
<span className="ml-2 font-normal text-muted-foreground">{feiertag}</span>
)}
</h3>
<ul className="space-y-2">
{eintraege.map((frist) => {
const ampel = fristAmpel(ausIsoTag(frist.datum)!, frist.status)
return (
<li key={frist.id}>
<Link
href={`/fristen/${frist.id}`}
className="block rounded-lg border bg-card p-3 hover:bg-accent"
>
<Badge variant={ampel.variante}>
{ampelText(ampel, frist.ausschlussfrist)}
</Badge>
<span className="mt-1.5 block text-sm font-medium">{frist.titel}</span>
</Link>
</li>
)
})}
</ul>
</li>
)
})}
</ol>
)}
</div>
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Loader2, Scale } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { pflichtfristenNeuAbleiten } from '@/server/actions/fristen'
/**
* Leitet die gesetzlichen Pflichtfristen aus dem Bestellungsdatum ab.
*
* Der Knopf darf beliebig oft gedrueckt werden - die Ableitung legt nichts
* doppelt an und macht nichts wieder auf, was schon erledigt ist. Deshalb
* auch keine Sicherheitsabfrage.
*/
export function PflichtfristenKnopf({
betreuteId,
variante = 'outline',
}: {
betreuteId: string
variante?: 'default' | 'outline'
}) {
const router = useRouter()
const [laeuft, starten] = useTransition()
const [meldung, setMeldung] = useState<{ art: 'ok' | 'fehler'; text: string } | null>(null)
function ableiten() {
setMeldung(null)
starten(async () => {
const ergebnis = await pflichtfristenNeuAbleiten(betreuteId)
if (!ergebnis.ok) {
setMeldung({ art: 'fehler', text: ergebnis.fehler })
return
}
setMeldung({ art: 'ok', text: ergebnis.hinweis ?? 'Fertig.' })
router.refresh()
})
}
return (
<div className="space-y-1.5">
<Button type="button" variant={variante} onClick={ableiten} disabled={laeuft}>
{laeuft ? (
<Loader2 className="animate-spin" aria-hidden="true" />
) : (
<Scale aria-hidden="true" />
)}
{laeuft ? 'Wird berechnet…' : 'Pflichtfristen ableiten'}
</Button>
{meldung && (
<p
role="status"
className={
meldung.art === 'fehler'
? 'text-sm font-medium text-destructive'
: 'text-sm text-muted-foreground'
}
>
{meldung.text}
</p>
)}
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
import type { Frist } from '@prisma/client'
import { alsIsoTag } from '@/lib/fristen/datum'
import type { FristStatusWert, FristTypWert } from '@/lib/validierung/fristen'
/**
* Die Form, in der eine Frist durch die Oberflaeche wandert.
*
* Bewusst mit `datum` als "2026-09-14" statt als Date: Diese Objekte gehen
* von Server Components an Client Components, und ein Datum ohne Uhrzeit
* ueberlebt den Weg als Zeichenkette unbeschadet - ein Date wuerde in der
* Zeitzone des Browsers wieder interpretiert und koennte einen Tag
* springen. Genau dieser Fehler kostet im Fristenwesen einen Tag zu viel.
*/
export type FristAnzeige = {
id: string
betreuteId: string
titel: string
beschreibung: string | null
/** ISO-Tagesdatum, "2026-09-14". */
datum: string
typ: FristTypWert
status: FristStatusWert
rechtsgrundlage: string | null
vorwarnungTage: number[]
wiederholungRrule: string | null
ausschlussfrist: boolean
systemSchluessel: string | null
/** ISO-Tagesdatum oder null. */
erledigtAm: string | null
vorgang: { id: string; titel: string } | null
hatVorgaenger: boolean
}
type FristMitVorgang = Frist & { vorgang?: { id: string; titel: string } | null }
/** Wandelt einen Datensatz aus der Datenbank in die Anzeigeform. */
export function alsAnzeige(frist: FristMitVorgang): FristAnzeige {
return {
id: frist.id,
betreuteId: frist.betreuteId,
titel: frist.titel,
beschreibung: frist.beschreibung,
datum: alsIsoTag(frist.datum),
typ: frist.typ,
status: frist.status,
rechtsgrundlage: frist.rechtsgrundlage,
vorwarnungTage: frist.vorwarnungTage,
wiederholungRrule: frist.wiederholungRrule,
ausschlussfrist: frist.ausschlussfrist,
systemSchluessel: frist.systemSchluessel,
erledigtAm: frist.erledigtAm ? alsIsoTag(frist.erledigtAm) : null,
vorgang: frist.vorgang ?? null,
hatVorgaenger: Boolean(frist.vorgaengerFristId),
}
}
@@ -0,0 +1,157 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte'
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/**
* Formular fuer einen Kontakt - Behoerde, Heim, Arzt, Notar.
*
* Das Aktenzeichen steht bewusst gleich neben dem Namen: Am Telefon ist
* es das Erste, wonach gefragt wird.
*/
export type KontaktWerte = {
id: string
name: string
organisation: string | null
rolle: string
telefon: string | null
mobil: string | null
fax: string | null
email: string | null
strasse: string | null
plz: string | null
ort: string | null
aktenzeichen: string | null
notizen: string | null
}
const ANFANG: KontaktZustand = {}
export function KontaktFormular({
betreuteId,
kontakt,
}: {
betreuteId: string
kontakt?: KontaktWerte
}) {
const [zustand, aktion] = useActionState(kontaktSpeichern, ANFANG)
const wert = (feld: keyof KontaktWerte): string =>
zustand.werte?.[feld] ?? (kontakt ? (kontakt[feld] ?? '') : '')
return (
<form action={aktion} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{kontakt && <input type="hidden" name="id" value={kontakt.id} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
<Feldgruppe titel="Wer ist das?">
<Feld id="name" beschriftung="Name" pflicht fehler={zustand.felder?.name}>
<Input name="name" defaultValue={wert('name')} autoComplete="name" required />
</Feld>
<Feld
id="organisation"
beschriftung="Behörde, Heim oder Kanzlei"
fehler={zustand.felder?.organisation}
>
<Input name="organisation" defaultValue={wert('organisation')} autoComplete="organization" />
</Feld>
<Feld
id="rolle"
beschriftung="Rolle"
pflicht
erklaerung="Nach der Rolle wird das Verzeichnis gruppiert."
fehler={zustand.felder?.rolle}
>
<Select name="rolle" defaultValue={wert('rolle') || 'SONSTIGES'}>
{KONTAKT_ROLLEN.map((rolle) => (
<option key={rolle} value={rolle}>
{KONTAKT_ROLLE_BESCHRIFTUNG[rolle]}
</option>
))}
</Select>
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen dort"
erklaerung="Das Zeichen, unter dem diese Stelle den Vorgang führt – nicht mein eigenes."
fehler={zustand.felder?.aktenzeichen}
>
<Input name="aktenzeichen" defaultValue={wert('aktenzeichen')} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Wie erreiche ich die Stelle?">
<Feld id="telefon" beschriftung="Telefon" fehler={zustand.felder?.telefon}>
<Input name="telefon" type="tel" defaultValue={wert('telefon')} autoComplete="tel" />
</Feld>
<Feld id="mobil" beschriftung="Mobil" fehler={zustand.felder?.mobil}>
<Input name="mobil" type="tel" defaultValue={wert('mobil')} />
</Feld>
<Feld id="email" beschriftung="E-Mail" fehler={zustand.felder?.email}>
<Input name="email" type="email" defaultValue={wert('email')} autoComplete="email" />
</Feld>
<Feld id="fax" beschriftung="Fax" fehler={zustand.felder?.fax}>
<Input name="fax" type="tel" defaultValue={wert('fax')} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Anschrift">
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input name="strasse" defaultValue={wert('strasse')} autoComplete="street-address" />
</Feld>
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input name="plz" defaultValue={wert('plz')} autoComplete="postal-code" inputMode="numeric" />
</Feld>
<Feld id="ort" beschriftung="Ort" fehler={zustand.felder?.ort}>
<Input name="ort" defaultValue={wert('ort')} autoComplete="address-level2" />
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Sprechzeiten, Durchwahl der Vertretung, wie das letzte Gespräch lief. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={wert('notizen')} rows={4} />
</Feld>
<SpeichernKnopf neu={!kontakt} />
</form>
)
}
function SpeichernKnopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" disabled={pending}>
{pending ? 'Wird gespeichert …' : neu ? 'Kontakt anlegen' : 'Änderungen speichern'}
</Button>
)
}
@@ -0,0 +1,368 @@
'use client'
import * as React from 'react'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, ChevronDown } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import {
DAUER_SCHNELLWAHL,
KONTAKT_ARTEN,
KONTAKT_ARTEN_SCHNELLWAHL,
KONTAKT_ART_BESCHRIFTUNG,
} from '@/lib/validierung/kontakte'
import { protokollSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
import { cn } from '@/lib/utils'
/**
* Die Schnellerfassung.
*
* Sie muss am Handy in unter 20 Sekunden zu bedienen sein - im Flur des
* Pflegeheims, direkt nach dem Besuch, mit einer Hand. Deshalb:
*
* - Das Datum ist auf heute vorbelegt.
* - Die Art ist eine Knopfreihe, kein Auswahlmenue.
* - Die Dauer hat vier Schnellwahlen und daneben ein freies Feld.
* - Ein Haekchen fuer den persoenlichen Kontakt - die Angabe, auf die es
* im Jahresbericht nach § 1863 BGB ankommt.
* - Ein grosses Freitextfeld.
*
* Alles Weitere steckt eingeklappt in "Weitere Angaben". Speichern ist der
* einzige auffaellige Knopf auf der Seite.
*/
export type ProtokollWerte = {
id: string
datum: string
art: string
dauerMinuten: number | null
gespraechspartner: string | null
mitBetreuter: boolean
kontaktId: string | null
vorgangId: string | null
inhalt: string | null
wuensche: string | null
folgeaufgaben: string | null
}
export type Auswahleintrag = { id: string; bezeichnung: string }
const ANFANG: KontaktZustand = {}
/** Die uebrigen Arten, die nicht in der grossen Reihe stehen. */
const WEITERE_ARTEN = KONTAKT_ARTEN.filter(
(art) => !(KONTAKT_ARTEN_SCHNELLWAHL as readonly string[]).includes(art),
)
export function ProtokollErfassung({
betreuteId,
heute,
kontakte,
vorgaenge,
protokoll,
weiter,
}: {
betreuteId: string
/** Vorbelegung fuer das Datumsfeld im Format JJJJ-MM-TT. */
heute: string
kontakte: Auswahleintrag[]
vorgaenge: Auswahleintrag[]
protokoll?: ProtokollWerte
/** Wohin nach dem Speichern? Muss ein Pfad dieser Anwendung sein. */
weiter?: string
}) {
const [zustand, aktion] = useActionState(protokollSpeichern, ANFANG)
const vorgabeDauer =
zustand.werte?.dauerMinuten ??
(protokoll?.dauerMinuten !== null && protokoll?.dauerMinuten !== undefined
? String(protokoll.dauerMinuten)
: '')
const [dauer, setDauer] = React.useState<string>(vorgabeDauer)
const vorgabeArt = zustand.werte?.art ?? protokoll?.art ?? 'BESUCH'
const vorgabeDatum = zustand.werte?.datum ?? protokoll?.datum ?? heute
const vorgabeMitBetreuter =
zustand.werte !== undefined
? zustand.werte.mitBetreuter === 'on'
: (protokoll?.mitBetreuter ?? true)
const mehrOffen = Boolean(
protokoll?.gespraechspartner ||
protokoll?.kontaktId ||
protokoll?.vorgangId ||
protokoll?.wuensche ||
protokoll?.folgeaufgaben ||
zustand.felder?.gespraechspartner ||
zustand.felder?.kontaktId ||
zustand.felder?.vorgangId,
)
return (
<form action={aktion} className="space-y-6">
<input type="hidden" name="betreuteId" value={betreuteId} />
{protokoll && <input type="hidden" name="id" value={protokoll.id} />}
{weiter && <input type="hidden" name="weiter" value={weiter} />}
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertTitle>Das hat noch nicht geklappt</AlertTitle>
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{/* --- Datum ---------------------------------------------------------- */}
<Feld id="datum" beschriftung="Wann war das?" pflicht fehler={zustand.felder?.datum}>
<Input name="datum" type="date" defaultValue={vorgabeDatum} required max={heute} />
</Feld>
{/* --- Art als Knopfreihe --------------------------------------------- */}
<fieldset>
<legend className="mb-2 text-sm font-medium">Wie fand der Kontakt statt?</legend>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-5">
{KONTAKT_ARTEN_SCHNELLWAHL.map((art) => (
<ArtKnopf key={art} art={art} gross vorgabe={vorgabeArt} />
))}
</div>
<div className="mt-2 flex flex-wrap gap-2">
{WEITERE_ARTEN.map((art) => (
<ArtKnopf key={art} art={art} vorgabe={vorgabeArt} />
))}
</div>
{zustand.felder?.art && (
<p className="mt-2 text-sm font-medium text-destructive">{zustand.felder.art}</p>
)}
</fieldset>
{/* --- Dauer ---------------------------------------------------------- */}
<fieldset>
<legend className="mb-2 text-sm font-medium">Wie lange? (optional)</legend>
<div className="flex flex-wrap items-center gap-2">
{DAUER_SCHNELLWAHL.map((minuten) => {
const gewaehlt = dauer === String(minuten)
return (
<button
key={minuten}
type="button"
aria-pressed={gewaehlt}
onClick={() => setDauer(gewaehlt ? '' : String(minuten))}
className={cn(
'min-h-11 min-w-16 rounded-md border px-4 text-base font-medium transition-colors',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
gewaehlt
? 'border-primary bg-primary text-primary-foreground'
: 'border-input bg-background hover:bg-accent',
)}
>
{minuten} min
</button>
)
})}
<label htmlFor="dauerMinuten" className="sr-only">
Dauer in Minuten
</label>
<Input
id="dauerMinuten"
name="dauerMinuten"
type="number"
inputMode="numeric"
min={1}
max={1440}
step={1}
placeholder="frei"
className="w-28"
value={dauer}
onChange={(e) => setDauer(e.target.value)}
aria-invalid={zustand.felder?.dauerMinuten ? true : undefined}
/>
</div>
{zustand.felder?.dauerMinuten && (
<p className="mt-2 text-sm font-medium text-destructive">
{zustand.felder.dauerMinuten}
</p>
)}
</fieldset>
{/* --- Persoenlicher Kontakt ------------------------------------------ */}
<label
htmlFor="mitBetreuter"
className={cn(
'flex min-h-14 cursor-pointer items-center gap-3 rounded-md border border-input',
'bg-background px-4 py-3 hover:bg-accent',
)}
>
<Checkbox
id="mitBetreuter"
name="mitBetreuter"
defaultChecked={vorgabeMitBetreuter}
className="size-6"
/>
<span>
<span className="block font-medium">Persönlicher Kontakt zur betreuten Person</span>
<span className="block text-sm text-muted-foreground">
Zählt für den Jahresbericht nach § 1863 BGB.
</span>
</span>
</label>
{/* --- Freitext -------------------------------------------------------- */}
<Feld
id="inhalt"
beschriftung="Was war?"
erklaerung="Ein oder zwei Sätze reichen. Wird verschlüsselt gespeichert."
fehler={zustand.felder?.inhalt}
>
<Textarea
name="inhalt"
rows={6}
className="min-h-36"
defaultValue={zustand.werte?.inhalt ?? protokoll?.inhalt ?? ''}
placeholder="Besuch im Heim, Mutter war wach und hat gegessen. Sauerstoff läuft. Pflegedienstleitung will sich wegen der Höherstufung melden."
/>
</Feld>
{/* --- Alles Weitere, eingeklappt -------------------------------------- */}
<details className="rounded-md border" open={mehrOffen}>
<summary
className={cn(
'flex min-h-11 cursor-pointer list-none items-center justify-between gap-2 px-4 py-3',
'text-sm font-medium marker:content-none hover:bg-accent',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
>
Weitere Angaben (optional)
<ChevronDown className="size-4 shrink-0" aria-hidden="true" />
</summary>
<div className="space-y-5 border-t p-4">
<Feld
id="gespraechspartner"
beschriftung="Mit wem gesprochen?"
fehler={zustand.felder?.gespraechspartner}
>
<Input
name="gespraechspartner"
defaultValue={
zustand.werte?.gespraechspartner ?? protokoll?.gespraechspartner ?? ''
}
/>
</Feld>
<Feld
id="kontaktId"
beschriftung="Zu welchem Kontakt gehört das?"
fehler={zustand.felder?.kontaktId}
>
<Select
name="kontaktId"
defaultValue={zustand.werte?.kontaktId ?? protokoll?.kontaktId ?? ''}
>
<option value="">– keinem bestimmten –</option>
{kontakte.map((k) => (
<option key={k.id} value={k.id}>
{k.bezeichnung}
</option>
))}
</Select>
</Feld>
<Feld
id="vorgangId"
beschriftung="Zu welchem Vorgang gehört das?"
fehler={zustand.felder?.vorgangId}
>
<Select
name="vorgangId"
defaultValue={zustand.werte?.vorgangId ?? protokoll?.vorgangId ?? ''}
>
<option value="">– keinem bestimmten –</option>
{vorgaenge.map((v) => (
<option key={v.id} value={v.id}>
{v.bezeichnung}
</option>
))}
</Select>
</Feld>
<Feld
id="wuensche"
beschriftung="Geäußerte Wünsche"
erklaerung="Der Wille der betreuten Person ist seit 2023 Leitschnur des Betreuerhandelns (§ 1821 BGB). Was hier steht, gehört in den Jahresbericht."
fehler={zustand.felder?.wuensche}
>
<Textarea
name="wuensche"
rows={3}
defaultValue={zustand.werte?.wuensche ?? protokoll?.wuensche ?? ''}
/>
</Feld>
<Feld
id="folgeaufgaben"
beschriftung="Was folgt daraus?"
erklaerung="Wird nicht automatisch zu einer Aufgabe – für eine Frist bitte zusätzlich eine anlegen."
fehler={zustand.felder?.folgeaufgaben}
>
<Textarea
name="folgeaufgaben"
rows={3}
defaultValue={zustand.werte?.folgeaufgaben ?? protokoll?.folgeaufgaben ?? ''}
/>
</Feld>
</div>
</details>
<SpeichernKnopf neu={!protokoll} />
</form>
)
}
function ArtKnopf({
art,
vorgabe,
gross,
}: {
art: string
vorgabe: string
gross?: boolean
}) {
return (
<label className="block cursor-pointer">
<input
type="radio"
name="art"
value={art}
defaultChecked={vorgabe === art}
className="peer sr-only"
/>
<span
className={cn(
'flex items-center justify-center rounded-md border border-input bg-background',
'text-center font-medium transition-colors hover:bg-accent',
'peer-checked:border-primary peer-checked:bg-primary peer-checked:text-primary-foreground',
'peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2',
gross ? 'min-h-14 px-3 text-base' : 'min-h-11 px-3 text-sm',
)}
>
{KONTAKT_ART_BESCHRIFTUNG[art as keyof typeof KONTAKT_ART_BESCHRIFTUNG] ?? art}
</span>
</label>
)
}
function SpeichernKnopf({ neu }: { neu: boolean }) {
const { pending } = useFormStatus()
return (
<Button type="submit" size="lg" className="w-full text-base" disabled={pending}>
{pending ? 'Wird gespeichert …' : neu ? 'Speichern' : 'Änderungen speichern'}
</Button>
)
}
+2
View File
@@ -10,6 +10,7 @@ import {
FolderKanban,
HeartPulse,
Home,
IdCard,
Landmark,
ScrollText,
Shield,
@@ -38,6 +39,7 @@ export type NavEintrag = {
export const NAV_STRUKTUR: Omit<NavEintrag, 'zaehler' | 'dringend'>[] = [
{ titel: 'Heute', href: '/heute', icon: Home, modul: 'STAMMDATEN' },
{ titel: 'Stammdaten', href: '/stammdaten', icon: IdCard, modul: 'STAMMDATEN' },
{ titel: 'Fristen', href: '/fristen', icon: CalendarClock, modul: 'FRISTEN' },
{ titel: 'Vorgänge', href: '/vorgaenge', icon: FolderKanban, modul: 'VORGAENGE' },
{ titel: 'Dokumente', href: '/dokumente', icon: FileText, modul: 'DOKUMENTE' },
+106
View File
@@ -0,0 +1,106 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4',
'max-h-[90dvh] overflow-y-auto border bg-background p-5 shadow-lg sm:rounded-lg',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close
className={cn(
'absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
)}
>
<X className="size-5" />
<span className="sr-only">Schließen</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('flex flex-col gap-1.5 pr-8', className)} {...props} />
}
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+50
View File
@@ -0,0 +1,50 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
/**
* Fortschrittsbalken, z.B. "7 von 12 Unterlagen geliefert".
*
* Der Wert steht immer auch als Text daneben - ein Balken allein ist fuer
* Screenreader und bei schlechtem Kontrast keine Information.
*/
export function Fortschritt({
wert,
gesamt,
beschriftung,
className,
}: {
wert: number
gesamt: number
beschriftung?: string
className?: string
}) {
const anteil = gesamt > 0 ? Math.round((wert / gesamt) * 100) : 0
const fertig = gesamt > 0 && wert >= gesamt
return (
<div className={cn('space-y-1.5', className)}>
<div className="flex items-baseline justify-between gap-3 text-sm">
<span className="text-muted-foreground">{beschriftung ?? 'Fortschritt'}</span>
<span className="font-medium tabular-nums">
{wert} von {gesamt}
</span>
</div>
<div
role="progressbar"
aria-valuenow={wert}
aria-valuemin={0}
aria-valuemax={gesamt}
aria-label={beschriftung ?? 'Fortschritt'}
className="h-2 w-full overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
'h-full rounded-full transition-all',
fertig ? 'bg-ampel-gruen' : 'bg-primary',
)}
style={{ width: `${anteil}%` }}
/>
</div>
</div>
)
}
+182
View File
@@ -0,0 +1,182 @@
'use client'
import { useActionState, useRef, useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Fortschritt } from '@/components/ui/fortschritt'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { aufgabeAbhaken, aufgabeAnlegen } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn } from '@/lib/utils'
/**
* Die Aufgabenliste eines Vorgangs.
*
* Aus einer Vorlage kommt sie vorbefüllt und in der fachlich richtigen
* Reihenfolge. Abgehakt wird direkt in der Liste - ein Formular je Zeile,
* damit es auch ohne JavaScript funktioniert.
*/
export type AufgabenZeile = {
id: string
titel: string
beschreibung: string | null
erledigt: boolean
faelligAm: Date | null
}
export function AufgabenListe({
vorgangId,
aufgaben,
darfSchreiben,
}: {
vorgangId: string
aufgaben: AufgabenZeile[]
darfSchreiben: boolean
}) {
const erledigt = aufgaben.filter((a) => a.erledigt).length
return (
<div className="space-y-4">
{aufgaben.length > 0 && (
<Fortschritt
wert={erledigt}
gesamt={aufgaben.length}
beschriftung="Aufgaben erledigt"
/>
)}
{aufgaben.length === 0 ? (
<p className="rounded-md border border-dashed px-4 py-6 text-sm text-muted-foreground">
Für diesen Vorgang ist noch kein Handgriff notiert. Trag den nächsten ein - dann
erscheint er auch auf der Startseite.
</p>
) : (
<ul className="divide-y rounded-lg border">
{aufgaben.map((aufgabe) => (
<li key={aufgabe.id}>
<AufgabenZeileAnzeige aufgabe={aufgabe} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
)}
{darfSchreiben && <AufgabeHinzufuegen vorgangId={vorgangId} />}
</div>
)
}
function AufgabenZeileAnzeige({
aufgabe,
darfSchreiben,
}: {
aufgabe: AufgabenZeile
darfSchreiben: boolean
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(aufgabeAbhaken, {})
const formular = useRef<HTMLFormElement>(null)
return (
<form ref={formular} action={aktion} className="flex items-start gap-3 p-3">
<input type="hidden" name="aufgabeId" value={aufgabe.id} />
<input type="hidden" name="erledigt" value={aufgabe.erledigt ? 'false' : 'true'} />
<Checkbox
id={`aufgabe-${aufgabe.id}`}
checked={aufgabe.erledigt}
disabled={!darfSchreiben}
// Das Absenden erledigt das Formular; onChange ist nur der Auslöser.
onChange={() => formular.current?.requestSubmit()}
className="mt-1"
/>
<div className="min-w-0 flex-1 space-y-1">
<label
htmlFor={`aufgabe-${aufgabe.id}`}
className={cn(
'block cursor-pointer text-base font-medium',
aufgabe.erledigt && 'text-muted-foreground line-through',
)}
>
{aufgabe.titel}
</label>
{aufgabe.beschreibung && (
<p className="text-sm text-muted-foreground">{aufgabe.beschreibung}</p>
)}
{!aufgabe.erledigt && <Faelligkeit datum={aufgabe.faelligAm} />}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
</div>
{/* Ohne JavaScript bleibt dieser Knopf der Weg zum Abhaken. */}
<noscript>
<Button type="submit" variant="outline" size="sm">
{aufgabe.erledigt ? 'Öffnen' : 'Erledigt'}
</Button>
</noscript>
</form>
)
}
function AufgabeHinzufuegen({ vorgangId }: { vorgangId: string }) {
const [offen, setOffen] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(aufgabeAnlegen, {})
if (!offen) {
return (
<Button type="button" variant="outline" onClick={() => setOffen(true)}>
<Plus aria-hidden="true" />
Aufgabe hinzufügen
</Button>
)
}
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="vorgangId" value={vorgangId} />
<Feld id="aufgabe-titel" beschriftung="Was ist zu tun?" fehler={zustand.felder?.titel} pflicht>
<Input name="titel" required maxLength={200} />
</Feld>
<Feld
id="aufgabe-beschreibung"
beschriftung="Erklärung"
erklaerung="Woran denkst du in sechs Wochen nicht mehr? Das gehört hierher."
fehler={zustand.felder?.beschreibung}
>
<Textarea name="beschreibung" rows={3} maxLength={2000} />
</Feld>
<Feld id="aufgabe-faellig" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" />
</Feld>
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Hinzufügen'}
</Button>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
</div>
</form>
)
}
+33
View File
@@ -0,0 +1,33 @@
import { Badge } from '@/components/ui/badge'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
/**
* Fälligkeit als Ampel - immer mit Text.
*
* Farbe allein wäre keine Information: Wer rot und grün nicht unterscheiden
* kann, sieht sonst nur zwei graue Punkte. Deshalb steht neben jeder Farbe,
* was sie bedeutet.
*/
export function ampelStufe(
datum: Date | null,
): { variante: 'rot' | 'orange' | 'gelb' | 'gruen'; text: string } | null {
if (!datum) return null
const tage = tageBis(datum)
if (tage < 0) return { variante: 'rot', text: `überfällig, ${relativeTage(tage)}` }
if (tage <= 7) return { variante: 'orange', text: `fällig ${relativeTage(tage)}` }
if (tage <= 31) return { variante: 'gelb', text: `fällig ${relativeTage(tage)}` }
return { variante: 'gruen', text: `fällig am ${formatDatum(datum)}` }
}
export function Faelligkeit({ datum }: { datum: Date | null }) {
const stufe = ampelStufe(datum)
if (!stufe) return null
return (
<Badge variant={stufe.variante}>
<span className="sr-only">Fälligkeit: </span>
{stufe.text}
</Badge>
)
}
@@ -0,0 +1,109 @@
'use client'
import { useActionState, useState } from 'react'
import { AlertCircle, Scale } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { genehmigungAnlegen } from '@/server/actions/genehmigungen'
import { GENEHMIGUNG_WARNUNG, RECHTSGRUNDLAGEN } from '@/lib/vorgaenge/genehmigungskette'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Genehmigungsverfahren zu einem Vorgang anlegen.
*
* Die Auswahl der Rechtsgrundlage ist der eigentliche Zweck dieses
* Formulars: Jede Vorschrift steht hier mit einer Erklärung in normalen
* Worten und mit Beispielen, damit man nicht raten muss. Die Zuordnung im
* Einzelfall bleibt Sache des Rechtspflegers.
*/
export function GenehmigungAnlegen({ vorgangId }: { vorgangId: string }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
genehmigungAnlegen,
{},
)
const [gewaehlt, setGewaehlt] = useState(RECHTSGRUNDLAGEN[0]?.wert ?? '')
const erklaerung = RECHTSGRUNDLAGEN.find((r) => r.wert === gewaehlt)
return (
<form action={aktion} className="space-y-5 rounded-lg border p-4">
<input type="hidden" name="vorgangId" value={vorgangId} />
<div className="flex items-start gap-2">
<Scale className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<div>
<h3 className="font-semibold">Genehmigungsverfahren anlegen</h3>
<p className="text-sm text-muted-foreground">{GENEHMIGUNG_WARNUNG}</p>
</div>
</div>
<Feld
id="genehmigung-titel"
beschriftung="Welches Geschäft soll genehmigt werden?"
fehler={zustand.felder?.titel}
pflicht
>
<Input name="titel" required maxLength={200} placeholder="z. B. Verkauf des Hauses" />
</Feld>
<Feld
id="genehmigung-rechtsgrundlage"
beschriftung="Rechtsgrundlage"
fehler={zustand.felder?.rechtsgrundlage}
pflicht
>
<Select
name="rechtsgrundlage"
value={gewaehlt}
onChange={(e) => setGewaehlt(e.target.value)}
>
{RECHTSGRUNDLAGEN.map((grundlage) => (
<option key={grundlage.wert} value={grundlage.wert}>
{grundlage.wert} — {grundlage.kurz}
</option>
))}
</Select>
</Feld>
{erklaerung && (
<div className="space-y-2 rounded-md bg-muted/50 p-3">
<p className="text-sm">{erklaerung.erklaerung}</p>
<ul className="list-inside list-disc text-sm text-muted-foreground">
{erklaerung.beispiele.map((beispiel) => (
<li key={beispiel}>{beispiel}</li>
))}
</ul>
<Rechtshinweis rechtsgrundlage={erklaerung.wert} kurz />
</div>
)}
<Feld
id="genehmigung-notizen"
beschriftung="Notizen"
erklaerung="Werden verschlüsselt gespeichert - z. B. was der Rechtspfleger am Telefon gesagt hat."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" rows={3} />
</Feld>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Legt an…' : 'Genehmigungsverfahren anlegen'}
</Button>
</form>
)
}
@@ -0,0 +1,289 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle, Check, CircleDashed, FileWarning, Scale, XCircle } from 'lucide-react'
import type { GenehmigungStatus } from '@prisma/client'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Feld } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import {
beschlussVerknuepfen,
genehmigungStatusWechseln,
} from '@/server/actions/genehmigungen'
import {
darfStatusWechseln,
GENEHMIGUNG_STATUS_BESCHRIFTUNG,
GENEHMIGUNG_STUFEN,
GENEHMIGUNG_WARNUNG,
moeglicheStatusWechsel,
rechtsgrundlageFinden,
stufeVon,
} from '@/lib/vorgaenge/genehmigungskette'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn, formatDatum } from '@/lib/utils'
/**
* Die Genehmigungskette als Stufenanzeige.
*
* Über allem steht der Warnhinweis: Die Genehmigung muss VOR dem
* Rechtsgeschäft vorliegen (§ 1858 BGB). Wer die Reihenfolge dreht,
* schließt einen schwebend unwirksamen Vertrag und haftet unter Umständen
* persönlich. Die Anzeige macht deshalb sichtbar, wo man steht - und die
* Auswahl bietet nur an, was fachlich als Nächstes möglich ist.
*/
export type GenehmigungsAnzeige = {
id: string
titel: string
rechtsgrundlage: string
status: GenehmigungStatus
mitteilungAm: Date | null
antragAm: Date | null
anhoerungAm: Date | null
beschlussAm: Date | null
rechtskraftAm: Date | null
vollzugAm: Date | null
beschlussDokumentId: string | null
beschlussDokumentTitel: string | null
/** Dokumente dieser betreuten Person, aus denen der Beschluss gewählt wird. */
dokumente: { id: string; titel: string }[]
}
export function GenehmigungsKette({
genehmigung,
darfSchreiben,
}: {
genehmigung: GenehmigungsAnzeige
darfSchreiben: boolean
}) {
const erklaerung = rechtsgrundlageFinden(genehmigung.rechtsgrundlage)
const versagt = genehmigung.status === 'VERSAGT'
const aktuelleStufe = stufeVon(genehmigung.status)
const vollzugGesperrt = !darfStatusWechseln(genehmigung, 'VOLLZOGEN').erlaubt
return (
<div className="space-y-5">
<Alert variant="warnung">
<Scale aria-hidden="true" />
<AlertTitle>Genehmigungspflichtiger Vorgang</AlertTitle>
<AlertDescription>{GENEHMIGUNG_WARNUNG}</AlertDescription>
</Alert>
<div className="space-y-1">
<h3 className="text-base font-semibold">{genehmigung.titel}</h3>
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{genehmigung.rechtsgrundlage}</span>
{erklaerung && <> · {erklaerung.kurz}</>}
</p>
{erklaerung && <p className="text-sm text-muted-foreground">{erklaerung.erklaerung}</p>}
</div>
{versagt && (
<Alert variant="fehler">
<XCircle aria-hidden="true" />
<AlertTitle>Das Gericht hat die Genehmigung versagt</AlertTitle>
<AlertDescription>
Das Rechtsgeschäft darf nicht ausgeführt werden. Ein geänderter Entwurf kann neu
vorgelegt werden - dann beginnt die Kette wieder bei „Geplant“.
</AlertDescription>
</Alert>
)}
{/* Stufenanzeige: senkrecht, damit sie am Handy nicht seitlich scrollt. */}
<ol className="space-y-0">
{GENEHMIGUNG_STUFEN.map((stufe, index) => {
const erreicht = !versagt && aktuelleStufe >= index
const aktuell = !versagt && aktuelleStufe === index
const datum = stufe.datumsfeld ? genehmigung[stufe.datumsfeld] : null
const letzte = index === GENEHMIGUNG_STUFEN.length - 1
return (
<li key={stufe.status} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className={cn(
'flex size-7 shrink-0 items-center justify-center rounded-full border-2',
erreicht
? 'border-ampel-gruen bg-ampel-gruen/15 text-ampel-gruen'
: 'border-muted-foreground/30 text-muted-foreground',
)}
aria-hidden="true"
>
{erreicht ? <Check className="size-4" /> : <CircleDashed className="size-4" />}
</span>
{!letzte && (
<span
className={cn(
'w-0.5 flex-1',
erreicht ? 'bg-ampel-gruen/40' : 'bg-muted-foreground/20',
)}
aria-hidden="true"
/>
)}
</div>
<div className={cn('min-w-0 flex-1 pb-5', letzte && 'pb-0')}>
<div className="flex flex-wrap items-center gap-2">
<span className={cn('font-medium', !erreicht && 'text-muted-foreground')}>
{stufe.beschriftung}
</span>
{erreicht && <span className="sr-only">(erreicht)</span>}
{aktuell && <Badge variant="secondary">Aktueller Stand</Badge>}
{datum && (
<span className="text-sm tabular-nums text-muted-foreground">
{formatDatum(datum)}
</span>
)}
</div>
<p className="mt-0.5 text-sm text-muted-foreground">{stufe.erklaerung}</p>
</div>
</li>
)
})}
</ol>
{vollzugGesperrt && genehmigung.status !== 'VOLLZOGEN' && (
<Alert variant="hinweis">
<FileWarning aria-hidden="true" />
<AlertTitle>Vollzug ist gesperrt</AlertTitle>
<AlertDescription>
{genehmigung.beschlussDokumentId
? 'Erst wird die Kette bis „Rechtskräftig“ durchlaufen, dann lässt sich der Vollzug eintragen.'
: 'Solange kein Genehmigungsbeschluss als Dokument hinterlegt ist, kann der Vollzug nicht eingetragen werden. Ohne Beleg gibt es gegenüber Gericht und Miterben keinen Nachweis.'}
</AlertDescription>
</Alert>
)}
{darfSchreiben && (
<div className="grid gap-5 md:grid-cols-2">
<StatusFormular genehmigung={genehmigung} />
<BeschlussFormular genehmigung={genehmigung} />
</div>
)}
<Rechtshinweis rechtsgrundlage={genehmigung.rechtsgrundlage} />
</div>
)
}
function StatusFormular({ genehmigung }: { genehmigung: GenehmigungsAnzeige }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
genehmigungStatusWechseln,
{},
)
const moeglich = moeglicheStatusWechsel(genehmigung)
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="genehmigungId" value={genehmigung.id} />
<h4 className="font-semibold">Nächste Stufe eintragen</h4>
{moeglich.length === 0 ? (
<p className="text-sm text-muted-foreground">
Von „{GENEHMIGUNG_STATUS_BESCHRIFTUNG[genehmigung.status]}“ aus geht es hier nicht
weiter.
</p>
) : (
<>
<Feld id={`gstatus-${genehmigung.id}`} beschriftung="Neuer Stand">
<Select name="status" defaultValue={moeglich[0]}>
{moeglich.map((wert) => (
<option key={wert} value={wert}>
{GENEHMIGUNG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id={`gdatum-${genehmigung.id}`}
beschriftung="Datum"
erklaerung="Leer lassen heißt: heute."
>
<Input name="datum" type="date" />
</Feld>
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Stand eintragen'}
</Button>
</>
)}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
function BeschlussFormular({ genehmigung }: { genehmigung: GenehmigungsAnzeige }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
beschlussVerknuepfen,
{},
)
return (
<form action={aktion} className="space-y-4 rounded-lg border p-4">
<input type="hidden" name="genehmigungId" value={genehmigung.id} />
<h4 className="font-semibold">Beschlussdokument</h4>
<p className="text-sm text-muted-foreground">
{genehmigung.beschlussDokumentTitel
? `Hinterlegt: ${genehmigung.beschlussDokumentTitel}`
: 'Noch kein Beschluss hinterlegt. Solange bleibt der Vollzug gesperrt.'}
</p>
{genehmigung.dokumente.length === 0 ? (
<p className="text-sm text-muted-foreground">
In der Akte liegt noch kein Dokument. Lade den Beschluss zuerst unter „Dokumente“
hoch.
</p>
) : (
<>
<Feld
id={`beschluss-${genehmigung.id}`}
beschriftung="Dokument wählen"
erklaerung="Am besten die Ausfertigung mit Rechtskraftvermerk."
>
<Select name="dokumentId" defaultValue={genehmigung.beschlussDokumentId ?? ''}>
<option value="">— keines —</option>
{genehmigung.dokumente.map((dokument) => (
<option key={dokument.id} value={dokument.id}>
{dokument.titel}
</option>
))}
</Select>
</Feld>
<Button type="submit" variant="outline" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Übernehmen'}
</Button>
</>
)}
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
@@ -0,0 +1,115 @@
'use client'
import { useActionState, useState } from 'react'
import { Check, Copy, Mail } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Textarea } from '@/components/ui/textarea'
import { kontaktVermerken } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Sachstandsanfrage erzeugen.
*
* Der Text wird auf dem Server gebaut (dort liegen Aktenzeichen und
* Stammdaten) und hier nur noch zum Kopieren angeboten. Kein PDF: Diese
* Nachrichten gehen meist per E-Mail oder über ein Behördenportal raus.
*
* Wer die Anfrage abgeschickt hat, vermerkt das gleich als letzten
* Kontakt - sonst stimmt beim nächsten Mal das Datum im Text nicht.
*/
export function SachstandsanfrageKnopf({
vorgangId,
betreff,
text,
darfSchreiben,
}: {
vorgangId: string
betreff: string
text: string
darfSchreiben: boolean
}) {
const [offen, setOffen] = useState(false)
const [kopiert, setKopiert] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
kontaktVermerken,
{},
)
async function kopieren() {
try {
await navigator.clipboard.writeText(text)
setKopiert(true)
window.setTimeout(() => setKopiert(false), 3000)
} catch {
// Ohne Zwischenablage-Berechtigung bleibt der Text im Feld stehen
// und lässt sich von Hand markieren - kein Grund für eine Fehlermeldung.
setKopiert(false)
}
}
return (
<Dialog open={offen} onOpenChange={setOffen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Mail aria-hidden="true" />
Sachstandsanfrage erzeugen
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Sachstandsanfrage</DialogTitle>
<DialogDescription>
Fertiger Text zum Kopieren. Betreff: {betreff}
</DialogDescription>
</DialogHeader>
<label htmlFor="sachstand-text" className="sr-only">
Text der Sachstandsanfrage
</label>
<Textarea
id="sachstand-text"
readOnly
value={text}
rows={16}
className="font-mono text-sm"
/>
<div className="flex flex-wrap gap-2">
<Button type="button" onClick={kopieren}>
{kopiert ? <Check aria-hidden="true" /> : <Copy aria-hidden="true" />}
{kopiert ? 'Kopiert' : 'Text kopieren'}
</Button>
{darfSchreiben && (
<form action={aktion}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<Button type="submit" variant="outline" disabled={laeuft}>
{laeuft ? 'Speichert…' : 'Abgeschickt - als letzten Kontakt vermerken'}
</Button>
</form>
)}
</div>
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,77 @@
'use client'
import { useActionState } from 'react'
import { AlertCircle } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Select } from '@/components/ui/select'
import { vorgangStatusWechseln } from '@/server/actions/vorgaenge'
import { VORGANG_STATUS } from '@/lib/validierung/vorgaenge'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
import { cn } from '@/lib/utils'
/**
* Statuswechsel über ein Auswahlfeld.
*
* Bewusst kein Drag & Drop: Am Handy - und dort benutze ich die App im
* Heim und auf dem Amt - ist Ziehen unzuverlässig, und ein Vorgang, der
* versehentlich in die falsche Spalte rutscht, fällt niemandem auf. Ein
* Auswahlfeld mit Knopf trifft man auch mit kalten Fingern.
*/
export function StatusWechsler({
vorgangId,
status,
klein = false,
className,
}: {
vorgangId: string
status: VorgangStatus
klein?: boolean
className?: string
}) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
vorgangStatusWechseln,
{},
)
return (
<form action={aktion} className={cn('space-y-2', className)}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<div className="flex items-end gap-2">
<div className="min-w-0 flex-1">
<label htmlFor={`status-${vorgangId}`} className="sr-only">
Status des Vorgangs
</label>
<Select
id={`status-${vorgangId}`}
name="status"
defaultValue={status}
className={klein ? 'h-11 text-sm' : undefined}
>
{VORGANG_STATUS.map((wert) => (
<option key={wert} value={wert}>
{VORGANG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</div>
<Button type="submit" variant="outline" disabled={laeuft} size={klein ? 'sm' : 'default'}>
{laeuft ? 'Ändert…' : 'Übernehmen'}
</Button>
</div>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
</form>
)
}
@@ -0,0 +1,210 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { Columns3, List, Scale } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { KATEGORIE_BESCHRIFTUNG, VORGANG_STATUS } from '@/lib/validierung/vorgaenge'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
import { cn, formatDatum } from '@/lib/utils'
/**
* Vorgangsliste mit Umschalter Liste ⇄ Kanban.
*
* Die Kanban-Ansicht ist am Handy KEIN waagerechtes Gescrolle, sondern
* eine Folge gestapelter Abschnitte. Seitlich scrollende Spalten sind auf
* einem 5-Zoll-Schirm unbenutzbar; ab Tabletbreite werden daraus Spalten.
*/
export type VorgangsZeile = {
id: string
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
prioritaet: number
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
faelligAm: Date | null
letzterKontaktAm: Date | null
genehmigungspflichtig: boolean
aufgabenOffen: number
aufgabenGesamt: number
}
export function VorgaengeAnsicht({
vorgaenge,
darfSchreiben,
}: {
vorgaenge: VorgangsZeile[]
darfSchreiben: boolean
}) {
const [ansicht, setAnsicht] = useState<'liste' | 'kanban'>('liste')
return (
<div className="space-y-4">
<div
className="flex flex-wrap items-center gap-2"
role="group"
aria-label="Darstellung wählen"
>
<Button
type="button"
variant={ansicht === 'liste' ? 'default' : 'outline'}
size="sm"
aria-pressed={ansicht === 'liste'}
onClick={() => setAnsicht('liste')}
>
<List aria-hidden="true" />
Liste
</Button>
<Button
type="button"
variant={ansicht === 'kanban' ? 'default' : 'outline'}
size="sm"
aria-pressed={ansicht === 'kanban'}
onClick={() => setAnsicht('kanban')}
>
<Columns3 aria-hidden="true" />
Nach Status
</Button>
<span className="ml-auto text-sm text-muted-foreground">
{vorgaenge.length} {vorgaenge.length === 1 ? 'Vorgang' : 'Vorgänge'}
</span>
</div>
{ansicht === 'liste' ? (
<ul className="space-y-3">
{vorgaenge.map((vorgang) => (
<li key={vorgang.id}>
<VorgangsKarte
vorgang={vorgang}
darfSchreiben={darfSchreiben}
mitStatusetikett
/>
</li>
))}
</ul>
) : (
<div className="grid gap-5 lg:grid-cols-5">
{VORGANG_STATUS.map((status) => {
const spalte = vorgaenge.filter((v) => v.status === status)
return (
<section key={status} aria-labelledby={`spalte-${status}`} className="space-y-3">
<h2
id={`spalte-${status}`}
className="flex items-baseline justify-between gap-2 border-b pb-2 text-sm font-semibold"
>
{VORGANG_STATUS_BESCHRIFTUNG[status]}
<span className="text-xs font-normal tabular-nums text-muted-foreground">
{spalte.length}
</span>
</h2>
{spalte.length === 0 ? (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
Nichts in diesem Status.
</p>
) : (
<ul className="space-y-3">
{spalte.map((vorgang) => (
<li key={vorgang.id}>
<VorgangsKarte vorgang={vorgang} darfSchreiben={darfSchreiben} />
</li>
))}
</ul>
)}
</section>
)
})}
</div>
)}
</div>
)
}
function VorgangsKarte({
vorgang,
darfSchreiben,
mitStatusetikett = false,
}: {
vorgang: VorgangsZeile
darfSchreiben: boolean
mitStatusetikett?: boolean
}) {
return (
<Card className={cn(vorgang.prioritaet === 1 && 'border-l-4 border-l-ampel-orange')}>
<CardContent className="space-y-3 p-4">
<div className="flex flex-wrap items-start justify-between gap-2">
<Link
href={`/vorgaenge/${vorgang.id}`}
className="min-w-0 text-base font-semibold hover:underline"
>
{vorgang.titel}
</Link>
{vorgang.prioritaet === 1 && <Badge variant="orange">Hohe Priorität</Badge>}
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
{mitStatusetikett && (
<Badge variant="secondary">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</Badge>
)}
<Faelligkeit datum={vorgang.faelligAm} />
{vorgang.genehmigungspflichtig && (
<Badge variant="gelb">
<Scale className="size-3" aria-hidden="true" />
Genehmigungspflichtig
</Badge>
)}
</div>
{vorgang.naechsterSchritt && (
<p className="text-sm">
<span className="text-muted-foreground">Nächster Schritt: </span>
{vorgang.naechsterSchritt}
</p>
)}
<dl className="flex flex-wrap gap-x-5 gap-y-1 text-sm text-muted-foreground">
{vorgang.gegenstelle && (
<div className="flex gap-1">
<dt>Gegenstelle:</dt>
<dd className="text-foreground">{vorgang.gegenstelle}</dd>
</div>
)}
{vorgang.aktenzeichen && (
<div className="flex gap-1">
<dt>Az.:</dt>
<dd className="text-foreground">{vorgang.aktenzeichen}</dd>
</div>
)}
{vorgang.aufgabenGesamt > 0 && (
<div className="flex gap-1">
<dt>Aufgaben:</dt>
<dd className="text-foreground tabular-nums">
{vorgang.aufgabenGesamt - vorgang.aufgabenOffen} von {vorgang.aufgabenGesamt}{' '}
erledigt
</dd>
</div>
)}
{vorgang.status === 'WARTET_AUF_DRITTE' && (
<div className="flex gap-1">
<dt>Letzter Kontakt:</dt>
<dd className="text-foreground">{formatDatum(vorgang.letzterKontaktAm)}</dd>
</div>
)}
</dl>
{darfSchreiben && (
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,70 @@
'use client'
import { useActionState, useState } from 'react'
import { Trash2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { vorgangInPapierkorb } from '@/server/actions/vorgaenge'
import type { Formularzustand } from '@/lib/validierung/vorgaenge'
/**
* Vorgang in den Papierkorb legen.
*
* Bewusst mit Rückfrage: Eine Fallakte enthält Fristen und Aufgaben, an
* denen Ansprüche hängen. Endgültig gelöscht wird sie erst nach 30 Tagen
* und nur aus dem Papierkorb heraus.
*/
export function VorgangLoeschen({ vorgangId, titel }: { vorgangId: string; titel: string }) {
const [offen, setOffen] = useState(false)
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
vorgangInPapierkorb,
{},
)
return (
<Dialog open={offen} onOpenChange={setOffen}>
<DialogTrigger asChild>
<Button type="button" variant="outline">
<Trash2 aria-hidden="true" />
In den Papierkorb
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Vorgang in den Papierkorb legen?</DialogTitle>
<DialogDescription>
„{titel}“ verschwindet aus den Listen. Aufgaben, Fristen und der Zeitstrahl bleiben
30 Tage erhalten und lassen sich im Papierkorb wiederherstellen.
</DialogDescription>
</DialogHeader>
{zustand.fehler && (
<p className="text-sm font-medium text-destructive" role="alert">
{zustand.fehler}
</p>
)}
<DialogFooter>
<Button type="button" variant="ghost" onClick={() => setOffen(false)}>
Abbrechen
</Button>
<form action={aktion}>
<input type="hidden" name="vorgangId" value={vorgangId} />
<Button type="submit" variant="destructive" disabled={laeuft}>
{laeuft ? 'Verschiebt…' : 'In den Papierkorb'}
</Button>
</form>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,227 @@
'use client'
import { useActionState } from 'react'
import Link from 'next/link'
import { AlertCircle } from 'lucide-react'
import type { VorgangKategorie, VorgangStatus } from '@prisma/client'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { vorgangAendern, vorgangAnlegen } from '@/server/actions/vorgaenge'
import {
KATEGORIE_BESCHRIFTUNG,
PRIORITAET_BESCHRIFTUNG,
VORGANG_KATEGORIEN,
VORGANG_STATUS,
type Formularzustand,
} from '@/lib/validierung/vorgaenge'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { VORGANG_STATUS_BESCHRIFTUNG } from '@/lib/vorgaenge/zeitstrahl'
/** Date -> "2026-08-22" für <input type="date">. */
function alsFeldwert(datum: Date | null | undefined): string {
if (!datum) return ''
return datum.toISOString().slice(0, 10)
}
export type VorgangsFormularWerte = {
titel: string
kategorie: VorgangKategorie
status: VorgangStatus
prioritaet: number
beschreibung: string | null
gegenstelle: string | null
aktenzeichen: string | null
verantwortlich: string | null
naechsterSchritt: string | null
faelligAm: Date | null
letzterKontaktAm: Date | null
genehmigungspflichtig: boolean
}
export function VorgangsFormular({
modus,
betreuteId,
vorgangId,
vorlageSchluessel,
werte,
abbrechenHref,
}: {
modus: 'anlegen' | 'aendern'
betreuteId: string
vorgangId?: string
vorlageSchluessel?: string | null
werte: VorgangsFormularWerte
abbrechenHref: string
}) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
{},
)
return (
<form action={aktion} className="space-y-8">
<input type="hidden" name="betreuteId" value={betreuteId} />
{vorgangId && <input type="hidden" name="vorgangId" value={vorgangId} />}
{modus === 'anlegen' && (
<input type="hidden" name="vorlageSchluessel" value={vorlageSchluessel ?? ''} />
)}
<Feldgruppe titel="Worum geht es?">
<Feld
id="titel"
beschriftung="Titel"
erklaerung="So, dass du den Vorgang in einem Jahr wiedererkennst."
fehler={zustand.felder?.titel}
pflicht
className="sm:col-span-2"
>
<Input name="titel" defaultValue={werte.titel} required maxLength={200} />
</Feld>
<Feld id="kategorie" beschriftung="Kategorie" fehler={zustand.felder?.kategorie} pflicht>
<Select name="kategorie" defaultValue={werte.kategorie}>
{VORGANG_KATEGORIEN.map((wert) => (
<option key={wert} value={wert}>
{KATEGORIE_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VORGANG_STATUS.map((wert) => (
<option key={wert} value={wert}>
{VORGANG_STATUS_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld
id="prioritaet"
beschriftung="Priorität"
erklaerung="Hoch heißt: das treibt gerade."
fehler={zustand.felder?.prioritaet}
>
<Select name="prioritaet" defaultValue={String(werte.prioritaet)}>
{[1, 2, 3].map((wert) => (
<option key={wert} value={wert}>
{PRIORITAET_BESCHRIFTUNG[wert]}
</option>
))}
</Select>
</Feld>
<Feld id="verantwortlich" beschriftung="Verantwortlich" fehler={zustand.felder?.verantwortlich}>
<Input name="verantwortlich" defaultValue={werte.verantwortlich ?? ''} maxLength={120} />
</Feld>
<Feld
id="beschreibung"
beschriftung="Beschreibung"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.beschreibung}
className="sm:col-span-2"
>
<Textarea name="beschreibung" defaultValue={werte.beschreibung ?? ''} rows={5} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Wer ist am Zug?"
beschreibung="Behörde, Aktenzeichen und der letzte Kontakt - daraus entsteht später die Sachstandsanfrage."
>
<Feld id="gegenstelle" beschriftung="Gegenstelle" fehler={zustand.felder?.gegenstelle}>
<Input
name="gegenstelle"
defaultValue={werte.gegenstelle ?? ''}
maxLength={150}
placeholder="z. B. Bezirk Oberfranken"
/>
</Feld>
<Feld
id="aktenzeichen"
beschriftung="Aktenzeichen"
erklaerung="Das Zeichen, unter dem die Gegenstelle den Vorgang führt."
fehler={zustand.felder?.aktenzeichen}
>
<Input name="aktenzeichen" defaultValue={werte.aktenzeichen ?? ''} maxLength={80} />
</Feld>
<Feld
id="naechsterSchritt"
beschriftung="Nächster Schritt"
erklaerung="Der eine konkrete Handgriff, der als Nächstes ansteht."
fehler={zustand.felder?.naechsterSchritt}
className="sm:col-span-2"
>
<Input
name="naechsterSchritt"
defaultValue={werte.naechsterSchritt ?? ''}
maxLength={300}
/>
</Feld>
<Feld id="faelligAm" beschriftung="Fällig am" fehler={zustand.felder?.faelligAm}>
<Input name="faelligAm" type="date" defaultValue={alsFeldwert(werte.faelligAm)} />
</Feld>
<Feld
id="letzterKontaktAm"
beschriftung="Letzter Kontakt am"
fehler={zustand.felder?.letzterKontaktAm}
>
<Input
name="letzterKontaktAm"
type="date"
defaultValue={alsFeldwert(werte.letzterKontaktAm)}
/>
</Feld>
</Feldgruppe>
<div className="space-y-3">
<div className="flex items-start gap-3">
<Checkbox
id="genehmigungspflichtig"
name="genehmigungspflichtig"
defaultChecked={werte.genehmigungspflichtig}
className="mt-1"
/>
<label htmlFor="genehmigungspflichtig" className="text-base font-medium">
Dieser Vorgang braucht eine Genehmigung des Betreuungsgerichts
</label>
</div>
<p className="text-sm text-muted-foreground">{GENEHMIGUNG_WARNUNG}</p>
<Rechtshinweis rechtsgrundlage="§ 1858 BGB" kurz />
</div>
{zustand.fehler && (
<p className="flex items-start gap-1.5 text-sm font-medium text-destructive" role="alert">
<AlertCircle className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
{zustand.fehler}
</p>
)}
{zustand.erfolg && (
<p className="text-sm text-muted-foreground" role="status">
{zustand.erfolg}
</p>
)}
<div className="flex flex-wrap gap-3">
<Button type="submit" disabled={laeuft}>
{laeuft ? 'Speichert…' : modus === 'anlegen' ? 'Vorgang anlegen' : 'Speichern'}
</Button>
<Button type="button" variant="ghost" asChild>
<Link href={abbrechenHref}>Abbrechen</Link>
</Button>
</div>
</form>
)
}
@@ -0,0 +1,79 @@
import Link from 'next/link'
import { FilePlus2, ListChecks, Scale } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { VORLAGEN } from '@/lib/vorgaenge/vorlagen'
import { KATEGORIE_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
/**
* Vorlagenauswahl als Kachelliste.
*
* Jede Kachel sagt vorab, was sie mitbringt: wie viele Aufgaben, ob eine
* gerichtliche Genehmigung nötig ist und auf welcher Vorschrift das beruht.
* Wer erst nach dem Anlegen merkt, dass ein Genehmigungsverfahren daran
* hängt, hat unter Umständen schon etwas unterschrieben.
*/
export function VorlagenKacheln() {
const genehmigungspflichtige = VORLAGEN.some((v) => v.genehmigungspflichtig)
return (
<div className="space-y-6">
<ul className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{VORLAGEN.map((vorlage) => (
<li key={vorlage.schluessel}>
<Card className="h-full transition-colors hover:border-primary">
<CardContent className="flex h-full flex-col gap-3 p-5">
<Link
href={`/vorgaenge/neu?vorlage=${vorlage.schluessel}`}
className="text-base font-semibold hover:underline"
>
{vorlage.titel}
</Link>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorlage.kategorie]}</Badge>
<Badge variant="secondary">
<ListChecks className="size-3" aria-hidden="true" />
{vorlage.aufgaben.length} Aufgaben
</Badge>
{vorlage.genehmigungspflichtig && (
<Badge variant="gelb">
<Scale className="size-3" aria-hidden="true" />
Genehmigung nötig
</Badge>
)}
</div>
<p className="flex-1 text-sm text-muted-foreground">{vorlage.beschreibung}</p>
{vorlage.rechtsgrundlage && (
<p className="text-xs font-medium text-muted-foreground">
{vorlage.rechtsgrundlage}
</p>
)}
</CardContent>
</Card>
</li>
))}
<li>
<Card className="h-full border-dashed transition-colors hover:border-primary">
<CardContent className="flex h-full flex-col gap-3 p-5">
<Link href="/vorgaenge/neu?leer=1" className="text-base font-semibold hover:underline">
Leerer Vorgang
</Link>
<p className="flex-1 text-sm text-muted-foreground">
Ohne vorbefüllte Aufgaben. Für alles, wofür es (noch) keine Vorlage gibt -
Aufgaben lassen sich jederzeit ergänzen.
</p>
<FilePlus2 className="size-5 text-muted-foreground" aria-hidden="true" />
</CardContent>
</Card>
</li>
</ul>
{genehmigungspflichtige && <Rechtshinweis rechtsgrundlage="§ 1858 BGB" />}
</div>
)
}
@@ -0,0 +1,110 @@
import Link from 'next/link'
import {
Banknote,
CalendarClock,
CheckCircle2,
FileText,
FolderPlus,
MessageSquare,
Scale,
Shuffle,
} from 'lucide-react'
import type { Zeitstrahlart, Zeitstrahleintrag } from '@/lib/vorgaenge/zeitstrahl'
import { cn, formatCent, formatDatum } from '@/lib/utils'
/**
* Der Zeitstrahl einer Fallakte, neueste Ereignisse zuerst.
*
* Er beantwortet die Frage, die im Gespräch mit Gericht oder Bezirk immer
* zuerst kommt: "Wann haben Sie das geschickt?" Alles, was zum Vorgang
* gehört, steht in einer Spalte - Dokumente, Gespräche, Fristen, Zahlungen
* und die Stufen des Genehmigungsverfahrens.
*/
const SYMBOL: Record<Zeitstrahlart, React.ComponentType<{ className?: string }>> = {
ANGELEGT: FolderPlus,
DOKUMENT: FileText,
KONTAKT: MessageSquare,
FRIST: CalendarClock,
BUCHUNG: Banknote,
GENEHMIGUNG: Scale,
STATUS: Shuffle,
ERLEDIGT: CheckCircle2,
}
const ARTBESCHRIFTUNG: Record<Zeitstrahlart, string> = {
ANGELEGT: 'Anlage',
DOKUMENT: 'Dokument',
KONTAKT: 'Kontakt',
FRIST: 'Frist',
BUCHUNG: 'Buchung',
GENEHMIGUNG: 'Genehmigung',
STATUS: 'Status',
ERLEDIGT: 'Erledigt',
}
export function ZeitstrahlAnzeige({ eintraege }: { eintraege: Zeitstrahleintrag[] }) {
if (eintraege.length === 0) {
return (
<p className="rounded-md border border-dashed px-4 py-6 text-sm text-muted-foreground">
Hier ist noch nichts passiert. Sobald ein Dokument, ein Gespräch oder eine Zahlung zu
diesem Vorgang gehört, erscheint es hier.
</p>
)
}
return (
<ol className="space-y-0">
{eintraege.map((eintrag, index) => {
const Symbol = SYMBOL[eintrag.art]
const letzter = index === eintraege.length - 1
return (
<li key={eintrag.id} className="flex gap-3">
<div className="flex flex-col items-center">
<span
className="flex size-8 shrink-0 items-center justify-center rounded-full border bg-muted text-muted-foreground"
aria-hidden="true"
>
<Symbol className="size-4" />
</span>
{!letzter && <span className="w-0.5 flex-1 bg-border" aria-hidden="true" />}
</div>
<div className={cn('min-w-0 flex-1 pb-6', letzter && 'pb-0')}>
<p className="text-sm tabular-nums text-muted-foreground">
<span className="sr-only">{ARTBESCHRIFTUNG[eintrag.art]} am </span>
{formatDatum(eintrag.datum)}
</p>
<p className="font-medium">
{eintrag.href ? (
<Link href={eintrag.href} className="hover:underline">
{eintrag.titel}
</Link>
) : (
eintrag.titel
)}
</p>
{eintrag.detail && (
<p className="text-sm text-muted-foreground">{eintrag.detail}</p>
)}
{eintrag.betragCent !== null && (
<p
className={cn(
'text-sm font-medium tabular-nums',
eintrag.betragCent < 0 ? 'text-ampel-rot' : 'text-ampel-gruen',
)}
>
{formatCent(eintrag.betragCent)}
</p>
)}
</div>
</li>
)
})}
</ol>
)
}
+5 -1
View File
@@ -156,7 +156,11 @@ export const VERSCHLUESSELTE_FELDER = {
Buchung: ['verwendungszweckVerschluesselt'],
Vermoegensposten: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Immobilie: ['statusBegruendungVerschluesselt', 'notizenVerschluesselt'],
Vertrag: ['bewertungBegruendungVerschluesselt', 'notizenVerschluesselt'],
Vertrag: [
'vertragsnummerVerschluesselt',
'bewertungBegruendungVerschluesselt',
'notizenVerschluesselt',
],
Kontakt: ['notizenVerschluesselt'],
Kontaktprotokoll: [
'inhaltVerschluesselt',
+383
View File
@@ -0,0 +1,383 @@
import { differenceInCalendarDays, endOfMonth, endOfWeek, startOfDay } from 'date-fns'
import type { FristTyp, NachforderungStatus, VermoegensArt, VorgangKategorie } from '@prisma/client'
import type {
Ampelstufe,
FristEintrag,
FristenAmpelDaten,
NachforderungEintrag,
NachforderungenDaten,
WartendEintrag,
WartetAufAndereDaten,
} from '@/lib/dashboard/typen'
/**
* Die Rechenregeln hinter dem Dashboard - ohne Datenbank, ohne React.
*
* Alles, was hier steht, laesst sich in tests/dashboard.test.ts pruefen.
* laden.ts holt nur die Datensaetze und reicht sie hier durch; die
* Bewertung "ist das dringend?" faellt an genau einer Stelle.
*/
/** Ab so vielen Tagen ohne Rueckmeldung wird eine Sachstandsanfrage faellig. */
export const WARTEZEIT_DRINGEND_TAGE = 21
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
const AUSSCHLUSSFRISTEN_MAX = 3
/**
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
* verschiebt eine Frist um einen Tag. Deshalb: auf den lokalen Tagesanfang
* ziehen, und zwar anhand der UTC-Bestandteile.
*/
export function tagAusDatenbank(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate())
}
/** Tage von heute bis zum Datum. Negativ = liegt in der Vergangenheit. */
export function tageBisTag(datum: Date, heute: Date): number {
return differenceInCalendarDays(datum, startOfDay(heute))
}
// -----------------------------------------------------------------------------
// 1. Fristen-Ampel
// -----------------------------------------------------------------------------
export type FristRoh = {
id: string
titel: string
datum: Date
typ: FristTyp
rechtsgrundlage: string | null
ausschlussfrist: boolean
vorgangId: string | null
}
/**
* Konventionen aus docs/KONVENTIONEN.md:
* rot = ueberfaellig, orange = diese Woche, gelb = dieser Monat,
* gruen = weiter in der Zukunft.
*
* "Diese Woche" endet am Sonntag, "dieser Monat" am Monatsletzten - nicht
* "in 7 Tagen" bzw. "in 30 Tagen". Wer montags auf das Dashboard schaut,
* will wissen, was bis Sonntag liegen bleibt.
*/
export function fristStufe(datum: Date, heute: Date): Ampelstufe {
const tag = startOfDay(datum)
const heuteTag = startOfDay(heute)
if (tag < heuteTag) return 'rot'
if (tag <= endOfWeek(heuteTag, { weekStartsOn: 1 })) return 'orange'
if (tag <= endOfMonth(heuteTag)) return 'gelb'
return 'gruen'
}
/** "seit 3 Tagen ueberfaellig" - der Text, der neben der Farbe stehen muss. */
export function fristLage(tage: number): string {
if (tage < -1) return `seit ${Math.abs(tage)} Tagen überfällig`
if (tage === -1) return 'seit gestern überfällig'
if (tage === 0) return 'heute fällig'
if (tage === 1) return 'morgen fällig'
return `in ${tage} Tagen fällig`
}
/**
* Die Verfallwarnung. Eine Ausschlussfrist kennt keine Nachsicht: laeuft
* sie ab, ist der Anspruch weg - bei der Aufwandspauschale nach § 1878 BGB
* z.B. das ganze Jahresgeld.
*/
export function verfallwarnung(frist: { tage: number; rechtsgrundlage: string | null }): string {
const grundlage = frist.rechtsgrundlage ? `${frist.rechtsgrundlage}: ` : ''
if (frist.tage < 0) {
const tage = Math.abs(frist.tage)
return (
`${grundlage}Diese Ausschlussfrist ist seit ${tage} ${tage === 1 ? 'Tag' : 'Tagen'} ` +
`abgelaufen. Läuft der Anspruch ab, ist er ersatzlos weg. Bitte umgehend beim Gericht ` +
`bzw. bei der Behörde nachfragen.`
)
}
return (
`${grundlage}Ausschlussfrist – keine Verlängerung, keine Nachfrist. ` +
`Läuft der Anspruch ab, ist er ersatzlos weg.`
)
}
export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpelDaten {
const eintraege: FristEintrag[] = fristen.map((frist) => {
const datum = tagAusDatenbank(frist.datum)
const tage = tageBisTag(datum, heute)
return {
id: frist.id,
titel: frist.titel,
datum,
typ: frist.typ,
rechtsgrundlage: frist.rechtsgrundlage,
ausschlussfrist: frist.ausschlussfrist,
vorgangId: frist.vorgangId,
tage,
stufe: fristStufe(datum, heute),
}
})
// Frueheste zuerst - damit steht die aelteste ueberfaellige ganz oben.
eintraege.sort((a, b) => a.datum.getTime() - b.datum.getTime())
return {
ueberfaellig: eintraege.filter((e) => e.stufe === 'rot'),
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX),
gesamtOffen: eintraege.length,
}
}
// -----------------------------------------------------------------------------
// 2. Wartet auf andere
// -----------------------------------------------------------------------------
export type WartendRoh = {
id: string
titel: string
kategorie: VorgangKategorie
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
letzterKontaktAm: Date | null
erstelltAm: Date
}
/**
* Sortiert nach Tagen seit dem letzten Kontakt, laengste Wartezeit zuerst.
* Ist nie ein Kontakt vermerkt worden, zaehlt das Anlegedatum - sonst
* rutschte ausgerechnet der Vorgang nach unten, um den sich noch nie
* jemand gekuemmert hat.
*/
export function wartendeEinordnen(vorgaenge: WartendRoh[], heute: Date): WartetAufAndereDaten {
const eintraege: WartendEintrag[] = vorgaenge.map((vorgang) => {
const bezug = vorgang.letzterKontaktAm
? tagAusDatenbank(vorgang.letzterKontaktAm)
: startOfDay(vorgang.erstelltAm)
const tageOhneRueckmeldung = Math.max(0, -tageBisTag(bezug, heute))
return {
id: vorgang.id,
titel: vorgang.titel,
kategorie: vorgang.kategorie,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
naechsterSchritt: vorgang.naechsterSchritt,
letzterKontaktAm: vorgang.letzterKontaktAm
? tagAusDatenbank(vorgang.letzterKontaktAm)
: null,
tageOhneRueckmeldung,
dringend: tageOhneRueckmeldung >= WARTEZEIT_DRINGEND_TAGE,
}
})
eintraege.sort((a, b) => b.tageOhneRueckmeldung - a.tageOhneRueckmeldung)
return {
eintraege,
gesamt: eintraege.length,
dringende: eintraege.filter((e) => e.dringend).length,
}
}
// -----------------------------------------------------------------------------
// 3. Nachforderungen
// -----------------------------------------------------------------------------
/** Diese Zustaende sind aus meiner Sicht abgearbeitet. */
const NACHFORDERUNG_ERLEDIGT: NachforderungStatus[] = [
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
]
export type NachforderungRoh = {
id: string
bezeichnung: string
status: NachforderungStatus
fristBis: Date | null
reihenfolge: number
antragId: string
antragTraeger: string | null
antragAktenzeichen: string | null
}
export function nachforderungenAuswerten(
zeilen: NachforderungRoh[],
heute: Date,
): NachforderungenDaten {
const offenRoh = zeilen.filter((z) => !NACHFORDERUNG_ERLEDIGT.includes(z.status))
const offen: NachforderungEintrag[] = offenRoh.map((zeile) => {
const fristBis = zeile.fristBis ? tagAusDatenbank(zeile.fristBis) : null
return {
id: zeile.id,
bezeichnung: zeile.bezeichnung,
status: zeile.status,
fristBis,
tageBisFrist: fristBis ? tageBisTag(fristBis, heute) : null,
}
})
// Zuerst, was eine Frist hat, und davon das Dringendste.
offen.sort((a, b) => {
if (a.tageBisFrist === null && b.tageBisFrist === null) return 0
if (a.tageBisFrist === null) return 1
if (b.tageBisFrist === null) return -1
return a.tageBisFrist - b.tageBisFrist
})
const erster = zeilen[0]
return {
antragId: erster?.antragId ?? null,
traeger: erster?.antragTraeger ?? null,
aktenzeichen: erster?.antragAktenzeichen ?? null,
gesamt: zeilen.length,
erledigt: zeilen.filter((z) => NACHFORDERUNG_ERLEDIGT.includes(z.status)).length,
offen,
ueberfaellig: offen.filter((o) => o.tageBisFrist !== null && o.tageBisFrist < 0).length,
}
}
// -----------------------------------------------------------------------------
// 6. Kennzahlen: Vermoegen und Heimkosten
// -----------------------------------------------------------------------------
export type PostenRoh = {
id: string
art: VermoegensArt
wertstaende: { stichtag: Date; wertCent: bigint }[]
}
export type VermoegenslageRoh = {
stichtag: Date | null
aktivaCent: bigint
verbindlichkeitenCent: bigint
nettoCent: bigint
postenGesamt: number
postenOhneWert: number
}
/**
* Vermoegen zum letzten Stichtag: je Posten zaehlt der juengste Wertstand,
* Verbindlichkeiten werden abgezogen.
*
* Der ausgewiesene Stichtag ist der juengste tatsaechlich verwendete - die
* Posten koennen zu unterschiedlichen Terminen bewertet worden sein, und
* genau das muss die Kachel sagen duerfen.
*/
export function vermoegenBerechnen(posten: PostenRoh[]): VermoegenslageRoh {
let aktivaCent = 0n
let verbindlichkeitenCent = 0n
let stichtag: Date | null = null
let postenOhneWert = 0
for (const p of posten) {
let juengster: { stichtag: Date; wertCent: bigint } | null = null
for (const wert of p.wertstaende) {
if (juengster === null || wert.stichtag.getTime() > juengster.stichtag.getTime()) {
juengster = wert
}
}
if (juengster === null) {
postenOhneWert += 1
continue
}
if (p.art === 'VERBINDLICHKEIT') {
verbindlichkeitenCent += juengster.wertCent
} else {
aktivaCent += juengster.wertCent
}
const tag = tagAusDatenbank(juengster.stichtag)
if (stichtag === null || tag.getTime() > stichtag.getTime()) stichtag = tag
}
return {
stichtag,
aktivaCent,
verbindlichkeitenCent,
nettoCent: aktivaCent - verbindlichkeitenCent,
postenGesamt: posten.length,
postenOhneWert,
}
}
export type HeimkostenmonatRoh = {
jahr: number
monat: number
pflegeEigenanteilCent: bigint
unterkunftVerpflegungCent: bigint
investitionskostenCent: bigint
ausbildungsumlageCent: bigint
zusatzleistungenCent: bigint
pflegekasseCent: bigint
leistungszuschlagCent: bigint
renteCent: bigint
sonstigeEinkuenfteCent: bigint
sozialhilfeCent: bigint
eigenmittelCent: bigint
}
export type HeimkostenlageRoh = {
jahr: number
monat: number
gesamtkostenCent: bigint
deckungCent: bigint
deckungsluecke: bigint
}
/**
* Deckungsluecke = Kostenseite minus Deckungsseite, genau nach der
* Gruppierung im Schema. Bleibt etwas uebrig, muss es jemand zahlen - das
* ist die Zahl, mit der ich beim Bezirk vorstellig werde.
*/
export function heimkostenlageBerechnen(monat: HeimkostenmonatRoh): HeimkostenlageRoh {
const gesamtkostenCent =
monat.pflegeEigenanteilCent +
monat.unterkunftVerpflegungCent +
monat.investitionskostenCent +
monat.ausbildungsumlageCent +
monat.zusatzleistungenCent
const deckungCent =
monat.pflegekasseCent +
monat.leistungszuschlagCent +
monat.renteCent +
monat.sonstigeEinkuenfteCent +
monat.sozialhilfeCent +
monat.eigenmittelCent
return {
jahr: monat.jahr,
monat: monat.monat,
gesamtkostenCent,
deckungCent,
deckungsluecke: gesamtkostenCent - deckungCent,
}
}
export const MONATSNAMEN = [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
] as const
export function monatsname(monat: number): string {
return MONATSNAMEN[monat - 1] ?? String(monat)
}
+336
View File
@@ -0,0 +1,336 @@
import 'server-only'
import { startOfDay } from 'date-fns'
import type { Modul } from '@prisma/client'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import {
fristenEinordnen,
heimkostenlageBerechnen,
nachforderungenAuswerten,
tagAusDatenbank,
tageBisTag,
vermoegenBerechnen,
wartendeEinordnen,
type NachforderungRoh,
} from '@/lib/dashboard/bewertung'
import type {
BelegeDaten,
DashboardDaten,
FristenAmpelDaten,
KennzahlenDaten,
NachforderungenDaten,
PosteingangDaten,
WartetAufAndereDaten,
} from '@/lib/dashboard/typen'
/**
* Alles, was das Dashboard "Heute" braucht - in einem Rutsch.
*
* Die Abfragen laufen nebeneinander in einem Promise.all. Keine Schleife
* fragt je Datensatz nach; wo eine Beziehung gebraucht wird (juengster
* Wertstand je Vermoegensposten), holt Prisma sie in derselben Runde mit.
*
* Wer ein Modul nicht lesen darf, bekommt dafuer null zurueck statt
* leerer Listen - die Kachel entfaellt dann ganz, statt faelschlich "0"
* zu behaupten.
*/
/** Wie viele Zeilen zeigt eine Kachel hoechstens? Der Rest steht im Modul. */
const KACHEL_ZEILEN = 5
/** Sicherung gegen ausufernde Abfragen bei sehr langen Akten. */
const FRISTEN_MAX = 200
export type Lesepruefung = (modul: Modul) => boolean
export async function dashboardLaden(
betreute: { id: string; vorname: string; nachname: string },
darfLesen: Lesepruefung,
jetzt: Date = new Date(),
): Promise<DashboardDaten> {
const heute = startOfDay(jetzt)
const betreuteId = betreute.id
const darfFristen = darfLesen('FRISTEN')
const darfVorgaenge = darfLesen('VORGAENGE')
const darfSozialhilfe = darfLesen('SOZIALHILFE')
const darfDokumente = darfLesen('DOKUMENTE')
const darfFinanzen = darfLesen('FINANZEN')
const darfGesundheit = darfLesen('GESUNDHEIT')
const [
fristenRoh,
wartendRoh,
nachforderungenRoh,
posteingang,
posteingangAnzahl,
belegeOhneBuchung,
belegeOhneBuchungGesamt,
buchungenOhneBeleg,
buchungenOhneBelegGesamt,
postenRoh,
heimkostenRoh,
jahresberichtRoh,
] = await Promise.all([
// 1. Fristen - alle offenen, die Einordnung passiert danach im Speicher.
darfFristen
? db.frist.findMany({
where: { betreuteId, status: 'OFFEN', ...nichtGeloescht },
orderBy: { datum: 'asc' },
take: FRISTEN_MAX,
select: {
id: true,
titel: true,
datum: true,
typ: true,
rechtsgrundlage: true,
ausschlussfrist: true,
vorgangId: true,
},
})
: null,
// 2. Vorgaenge, bei denen jemand anderes am Zug ist.
darfVorgaenge
? db.vorgang.findMany({
where: { betreuteId, status: 'WARTET_AUF_DRITTE', ...nichtGeloescht },
orderBy: { letzterKontaktAm: 'asc' },
select: {
id: true,
titel: true,
kategorie: true,
gegenstelle: true,
aktenzeichen: true,
naechsterSchritt: true,
letzterKontaktAm: true,
erstelltAm: true,
},
})
: null,
// 3. Nachforderungen: alle Zeilen, damit "x von y" stimmt.
darfSozialhilfe
? db.nachforderung.findMany({
where: { antrag: { betreuteId, ...nichtGeloescht }, ...nichtGeloescht },
orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }],
select: {
id: true,
bezeichnung: true,
status: true,
fristBis: true,
reihenfolge: true,
antragId: true,
antrag: { select: { traeger: true, aktenzeichen: true } },
},
})
: null,
// 4. Ungeprüfter Posteingang.
darfDokumente
? db.dokument.findMany({
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: KACHEL_ZEILEN,
select: { id: true, titel: true, art: true, absender: true, eingangAm: true },
})
: null,
darfDokumente
? db.dokument.count({
where: { betreuteId, posteingangStatus: 'NEU', ...nichtGeloescht },
})
: null,
// 5a. Rechnungen und Quittungen, an denen keine Buchung haengt.
darfDokumente && darfFinanzen
? db.dokument.findMany({
where: {
betreuteId,
art: { in: ['RECHNUNG', 'QUITTUNG'] },
belegFuer: { none: { geloeschtAm: null } },
...nichtGeloescht,
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
take: KACHEL_ZEILEN,
select: { id: true, titel: true, art: true, eingangAm: true },
})
: null,
darfDokumente && darfFinanzen
? db.dokument.count({
where: {
betreuteId,
art: { in: ['RECHNUNG', 'QUITTUNG'] },
belegFuer: { none: { geloeschtAm: null } },
...nichtGeloescht,
},
})
: null,
// 5b. Buchungen ohne Beleg und ohne Begruendung - Belegzwang.
darfFinanzen
? db.buchung.findMany({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
orderBy: { datum: 'desc' },
take: KACHEL_ZEILEN,
select: {
id: true,
datum: true,
betragCent: true,
richtung: true,
kategorie: true,
gegenstelle: true,
},
})
: null,
darfFinanzen
? db.buchung.count({
where: { betreuteId, belegId: null, belegFehltBegruendung: null, ...nichtGeloescht },
})
: null,
// 6a. Vermoegen: je Posten der juengste Wertstand.
darfFinanzen
? db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
select: {
id: true,
art: true,
wertstaende: {
orderBy: { stichtag: 'desc' },
take: 1,
select: { stichtag: true, wertCent: true },
},
},
})
: null,
// 6b. Der zuletzt erfasste Heimkostenmonat.
darfGesundheit
? db.heimkostenmonat.findFirst({
where: { betreuteId },
orderBy: [{ jahr: 'desc' }, { monat: 'desc' }],
})
: null,
// 6c. Der naechste Jahresbericht.
darfFristen
? db.frist.findFirst({
where: {
betreuteId,
status: 'OFFEN',
systemSchluessel: { startsWith: 'jahresbericht-' },
...nichtGeloescht,
},
orderBy: { datum: 'asc' },
select: { id: true, titel: true, datum: true },
})
: null,
])
// --- Einordnen -----------------------------------------------------------
const fristen: FristenAmpelDaten | null = fristenRoh
? fristenEinordnen(fristenRoh, heute)
: null
const wartet: WartetAufAndereDaten | null = wartendRoh
? (() => {
const alle = wartendeEinordnen(wartendRoh, heute)
return { ...alle, eintraege: alle.eintraege.slice(0, KACHEL_ZEILEN) }
})()
: null
const nachforderungen: NachforderungenDaten | null = nachforderungenRoh
? nachforderungenAuswerten(
nachforderungenRoh.map<NachforderungRoh>((zeile) => ({
id: zeile.id,
bezeichnung: zeile.bezeichnung,
status: zeile.status,
fristBis: zeile.fristBis,
reihenfolge: zeile.reihenfolge,
antragId: zeile.antragId,
antragTraeger: zeile.antrag.traeger,
antragAktenzeichen: zeile.antrag.aktenzeichen,
})),
heute,
)
: null
const posteingangDaten: PosteingangDaten | null =
posteingang && posteingangAnzahl !== null
? {
anzahl: posteingangAnzahl,
neueste: posteingang.map((d) => ({
id: d.id,
titel: d.titel,
art: d.art,
absender: d.absender,
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
})),
}
: null
const belege: BelegeDaten | null =
buchungenOhneBeleg && buchungenOhneBelegGesamt !== null
? {
belegeOhneBuchung: (belegeOhneBuchung ?? []).map((d) => ({
id: d.id,
titel: d.titel,
art: d.art,
eingangAm: d.eingangAm ? tagAusDatenbank(d.eingangAm) : null,
})),
belegeOhneBuchungGesamt: belegeOhneBuchungGesamt ?? 0,
buchungenOhneBeleg: buchungenOhneBeleg.map((b) => ({
id: b.id,
datum: tagAusDatenbank(b.datum),
betragCent: centZuZahl(b.betragCent) ?? 0,
richtung: b.richtung,
kategorie: b.kategorie,
gegenstelle: b.gegenstelle,
})),
buchungenOhneBelegGesamt,
}
: null
const vermoegenRoh = postenRoh ? vermoegenBerechnen(postenRoh) : null
const heimkosten = heimkostenRoh ? heimkostenlageBerechnen(heimkostenRoh) : null
const kennzahlen: KennzahlenDaten = {
vermoegen: vermoegenRoh
? {
stichtag: vermoegenRoh.stichtag,
aktivaCent: centZuZahl(vermoegenRoh.aktivaCent) ?? 0,
verbindlichkeitenCent: centZuZahl(vermoegenRoh.verbindlichkeitenCent) ?? 0,
nettoCent: centZuZahl(vermoegenRoh.nettoCent) ?? 0,
postenGesamt: vermoegenRoh.postenGesamt,
postenOhneWert: vermoegenRoh.postenOhneWert,
}
: null,
heimkosten: heimkosten
? {
jahr: heimkosten.jahr,
monat: heimkosten.monat,
gesamtkostenCent: centZuZahl(heimkosten.gesamtkostenCent) ?? 0,
deckungCent: centZuZahl(heimkosten.deckungCent) ?? 0,
deckungsluecke: centZuZahl(heimkosten.deckungsluecke) ?? 0,
}
: null,
jahresbericht: jahresberichtRoh
? {
id: jahresberichtRoh.id,
titel: jahresberichtRoh.titel,
datum: tagAusDatenbank(jahresberichtRoh.datum),
tage: tageBisTag(tagAusDatenbank(jahresberichtRoh.datum), heute),
}
: null,
}
return {
betreuteId,
name: `${betreute.vorname} ${betreute.nachname}`.trim(),
heute,
fristen,
wartet,
nachforderungen,
posteingang: posteingangDaten,
belege,
kennzahlen,
}
}
+209
View File
@@ -0,0 +1,209 @@
import type {
BuchungKategorie,
BuchungRichtung,
DokumentArt,
FristTyp,
NachforderungStatus,
VorgangKategorie,
} from '@prisma/client'
/**
* Die Form, in der das Dashboard seine Daten an die Kacheln reicht.
*
* Hier stehen bewusst keine BigInt mehr: Cent-Betraege sind bereits ueber
* centZuZahl() in Number gewandelt, damit die Kacheln die Werte auch an
* Client Components weitergeben koennen, ohne dass die Serialisierung
* bricht. Datumswerte sind auf den lokalen Tagesanfang normalisiert - ein
* @db.Date kommt als UTC-Mitternacht aus der Datenbank und wuerde sonst je
* nach Zeitzone einen Tag zu frueh oder zu spaet angezeigt.
*/
export type Ampelstufe = 'rot' | 'orange' | 'gelb' | 'gruen'
export const AMPEL_BESCHRIFTUNG: Record<Ampelstufe, string> = {
rot: 'überfällig',
orange: 'diese Woche fällig',
gelb: 'diesen Monat fällig',
gruen: 'im Plan',
}
// -----------------------------------------------------------------------------
// 1. Fristen-Ampel
// -----------------------------------------------------------------------------
export type FristEintrag = {
id: string
titel: string
datum: Date
typ: FristTyp
rechtsgrundlage: string | null
ausschlussfrist: boolean
vorgangId: string | null
/** Tage bis zum Termin. Negativ bedeutet: so viele Tage ueberfaellig. */
tage: number
stufe: Ampelstufe
}
export type FristenAmpelDaten = {
ueberfaellig: FristEintrag[]
dieseWoche: FristEintrag[]
diesenMonat: FristEintrag[]
/** Offene Fristen, die weiter in der Zukunft liegen. */
weitereOffen: number
/**
* Ausschlussfristen - unabhaengig davon, in welchem Topf sie liegen.
* Sie verfallen ersatzlos und bekommen deshalb eine eigene Warnung.
*/
ausschlussfristen: FristEintrag[]
gesamtOffen: number
}
// -----------------------------------------------------------------------------
// 2. Wartet auf andere
// -----------------------------------------------------------------------------
export type WartendEintrag = {
id: string
titel: string
kategorie: VorgangKategorie
gegenstelle: string | null
aktenzeichen: string | null
naechsterSchritt: string | null
letzterKontaktAm: Date | null
/** Tage seit dem letzten Kontakt - ersatzweise seit dem Anlegen. */
tageOhneRueckmeldung: number
/** Ab WARTEZEIT_DRINGEND_TAGE Tagen: sichtbar hervorheben. */
dringend: boolean
}
export type WartetAufAndereDaten = {
eintraege: WartendEintrag[]
gesamt: number
dringende: number
}
// -----------------------------------------------------------------------------
// 3. Nachforderungen des Bezirks
// -----------------------------------------------------------------------------
export type NachforderungEintrag = {
id: string
bezeichnung: string
status: NachforderungStatus
fristBis: Date | null
/** Tage bis zur gesetzten Frist, negativ = ueberschritten. */
tageBisFrist: number | null
}
export type NachforderungenDaten = {
antragId: string | null
traeger: string | null
aktenzeichen: string | null
gesamt: number
/** Uebersandt, anerkannt oder mit Begruendung als nicht vorhanden gemeldet. */
erledigt: number
offen: NachforderungEintrag[]
/** Wie viele davon haben eine bereits abgelaufene Frist? */
ueberfaellig: number
}
// -----------------------------------------------------------------------------
// 4. Ungeprüfter Posteingang
// -----------------------------------------------------------------------------
export type PosteingangEintrag = {
id: string
titel: string
art: DokumentArt
absender: string | null
eingangAm: Date | null
}
export type PosteingangDaten = {
anzahl: number
neueste: PosteingangEintrag[]
}
// -----------------------------------------------------------------------------
// 5. Unverbuchte Belege
// -----------------------------------------------------------------------------
export type BelegOhneBuchung = {
id: string
titel: string
art: DokumentArt
eingangAm: Date | null
}
export type BuchungOhneBeleg = {
id: string
datum: Date
betragCent: number
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
}
export type BelegeDaten = {
belegeOhneBuchung: BelegOhneBuchung[]
belegeOhneBuchungGesamt: number
buchungenOhneBeleg: BuchungOhneBeleg[]
buchungenOhneBelegGesamt: number
}
// -----------------------------------------------------------------------------
// 6. Kennzahlen
// -----------------------------------------------------------------------------
export type Vermoegenslage = {
/** Juengster Stichtag, aus dem sich die Summe speist. */
stichtag: Date | null
aktivaCent: number
verbindlichkeitenCent: number
nettoCent: number
postenGesamt: number
/** Posten ohne einen einzigen Wertstand - die fehlen in der Summe. */
postenOhneWert: number
}
export type Heimkostenlage = {
jahr: number
monat: number
gesamtkostenCent: number
deckungCent: number
/** Positiv = ungedeckt, negativ = Ueberdeckung. */
deckungsluecke: number
}
export type Berichtslage = {
id: string
titel: string
datum: Date
tage: number
}
export type KennzahlenDaten = {
vermoegen: Vermoegenslage | null
heimkosten: Heimkostenlage | null
jahresbericht: Berichtslage | null
}
// -----------------------------------------------------------------------------
// Das Ganze
// -----------------------------------------------------------------------------
/**
* Ein Feld ist null, wenn der angemeldete Benutzer das zugehoerige Modul
* nicht lesen darf. Die Kachel entfaellt dann ganz - sie zeigt nicht "0".
*/
export type DashboardDaten = {
betreuteId: string
name: string
heute: Date
fristen: FristenAmpelDaten | null
wartet: WartetAufAndereDaten | null
nachforderungen: NachforderungenDaten | null
posteingang: PosteingangDaten | null
belege: BelegeDaten | null
kennzahlen: KennzahlenDaten
}
+826
View File
@@ -0,0 +1,826 @@
import { addDays, addMonths, addWeeks, addYears } from 'date-fns'
import type { DokumentArt, DokumentRichtung } from '@prisma/client'
/**
* Vorschlaege aus dem erkannten Text ziehen.
*
* Der Grundgedanke: Die Anwendung liest den Brief mit und schlaegt vor,
* was drinsteht - Absender, Datum, Aktenzeichen, Art des Schreibens und
* eine moegliche Frist. **Bestaetigt wird immer von Hand.** Nichts davon
* wird automatisch angelegt.
*
* Warum so streng? Eine automatisch angelegte, falsch berechnete Frist ist
* schlimmer als gar keine: Man verlaesst sich darauf. Eine
* Widerspruchsfrist zu verpassen kostet den Anspruch, und die betreute
* Person kann sich dagegen nicht wehren. Deshalb steht neben jedem
* Vorschlag die Textstelle, aus der er stammt - der Nutzer sieht, worauf
* sich die Maschine beruft, und entscheidet selbst.
*
* Alle Funktionen hier sind rein: gleicher Text, gleiche Uhrzeitangabe,
* gleiches Ergebnis. Kein Datenbankzugriff, kein Netz.
*/
export type Sicherheit = 'hoch' | 'mittel' | 'niedrig'
export type Vorschlag<T> = {
wert: T
/** Der Ausschnitt aus dem Text, auf den sich der Vorschlag stuetzt. */
textstelle: string
sicherheit: Sicherheit
}
export type Fristvorschlag = {
/** Berechnetes Datum. null, wenn die Bezugsgroesse fehlt. */
datum: Date | null
/** Wovon aus gerechnet wurde. */
basis: Date | null
/** Wie die Frist im Text formuliert ist. */
formulierung: string
/** Ein Satz, den der Nutzer als Fristtitel uebernehmen kann. */
titel: string
/** Erklaerung inklusive Rechenweg - der Nutzer soll nachrechnen koennen. */
begruendung: string
textstelle: string
sicherheit: Sicherheit
/** Ist es eine Rechtsbehelfsfrist (Widerspruch, Klage)? */
rechtsbehelf: boolean
}
export type Erkennungsergebnis = {
absender: Vorschlag<string> | null
schreibenVom: Vorschlag<Date> | null
aktenzeichen: Vorschlag<string> | null
art: Vorschlag<DokumentArt> | null
richtung: Vorschlag<DokumentRichtung> | null
titel: string | null
fristen: Fristvorschlag[]
}
export type Erkennungsoptionen = {
/** Namen aus Kontakten und frueheren Dokumenten - schlagen alles andere. */
bekannteAbsender?: string[]
/** Tag des Zugangs; massgeblich fuer Rechtsbehelfsfristen. */
zugangAm?: Date | null
/** Nur fuer Tests: heutiges Datum. */
heute?: Date
}
// -----------------------------------------------------------------------------
// Hilfen
// -----------------------------------------------------------------------------
/** Datum ohne Uhrzeit, in UTC - so wie @db.Date es speichert. */
function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat, 0, 0, 0, 0))
}
function aufTag(datum: Date): Date {
return tag(datum.getUTCFullYear(), datum.getUTCMonth() + 1, datum.getUTCDate())
}
/** Schneidet einen lesbaren Ausschnitt um eine Fundstelle herum aus. */
export function textstelleSchneiden(text: string, von: number, bis: number, rand = 60): string {
const anfang = Math.max(0, von - rand)
const ende = Math.min(text.length, bis + rand)
const roh = text.slice(anfang, ende).replace(/\s+/g, ' ').trim()
return `${anfang > 0 ? '… ' : ''}${roh}${ende < text.length ? ' …' : ''}`
}
const MONATE: Record<string, number> = {
januar: 1,
jaenner: 1,
februar: 2,
maerz: 3,
april: 4,
mai: 5,
juni: 6,
juli: 7,
august: 8,
september: 9,
oktober: 10,
november: 11,
dezember: 12,
jan: 1,
feb: 2,
mrz: 3,
apr: 4,
jun: 6,
jul: 7,
aug: 8,
sep: 9,
sept: 9,
okt: 10,
nov: 11,
dez: 12,
}
/** Umlaute und Versalien vereinheitlichen - OCR liefert beides gemischt. */
export function normalisieren(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
}
function jahrVervollstaendigen(roh: string): number {
const zahl = Number.parseInt(roh, 10)
if (roh.length === 4) return zahl
// Zweistellig: 00-79 -> 2000er, 80-99 -> 1900er. Betreuungsakten reichen
// selten weiter zurueck, und Fristen liegen nie in den 1980ern.
return zahl <= 79 ? 2000 + zahl : 1900 + zahl
}
function plausibel(datum: Date | null): boolean {
if (!datum) return false
const jahr = datum.getUTCFullYear()
return jahr >= 1900 && jahr <= 2100
}
// -----------------------------------------------------------------------------
// Datum
// -----------------------------------------------------------------------------
export type Datumsfund = {
datum: Date
text: string
von: number
bis: number
}
const DATUM_ZIFFERN = /\b(\d{1,2})\s*\.\s*(\d{1,2})\s*\.\s*(\d{2,4})\b/g
const DATUM_ISO = /\b(\d{4})-(\d{2})-(\d{2})\b/g
const DATUM_MONATSNAME =
/\b(\d{1,2})\s*\.?\s*(Januar|Jänner|Februar|März|Maerz|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember|Jan|Feb|Mrz|Apr|Jun|Jul|Aug|Sep|Sept|Okt|Nov|Dez)\.?\s*(\d{2,4})\b/gi
/**
* Findet alle Datumsangaben im Text, in der Reihenfolge ihres Auftretens.
* Erkannt werden 12.03.2026, 12.3.26, "12. März 2026" und 2026-03-12.
*/
export function datenFinden(text: string): Datumsfund[] {
const funde: Datumsfund[] = []
const aufnehmen = (datum: Date | null, treffer: RegExpExecArray) => {
if (!plausibel(datum)) return
funde.push({
datum: datum!,
text: treffer[0]!.trim(),
von: treffer.index,
bis: treffer.index + treffer[0]!.length,
})
}
for (const muster of [DATUM_ZIFFERN, DATUM_ISO, DATUM_MONATSNAME]) {
muster.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
if (muster === DATUM_ZIFFERN) {
aufnehmen(
sicheresDatum(
jahrVervollstaendigen(treffer[3]!),
Number.parseInt(treffer[2]!, 10),
Number.parseInt(treffer[1]!, 10),
),
treffer,
)
} else if (muster === DATUM_ISO) {
aufnehmen(
sicheresDatum(
Number.parseInt(treffer[1]!, 10),
Number.parseInt(treffer[2]!, 10),
Number.parseInt(treffer[3]!, 10),
),
treffer,
)
} else {
const monat = MONATE[normalisieren(treffer[2]!.replace(/\./g, ''))]
if (monat === undefined) continue
aufnehmen(
sicheresDatum(
jahrVervollstaendigen(treffer[3]!),
monat,
Number.parseInt(treffer[1]!, 10),
),
treffer,
)
}
}
}
// Ueberlappungen (ISO-Treffer innerhalb eines Monatsnamen-Treffers o.ae.)
// aussortieren und nach Position sortieren.
funde.sort((a, b) => a.von - b.von)
return funde.filter((fund, i) => i === 0 || fund.von >= funde[i - 1]!.bis)
}
/** Legt nie einen 31. Februar an - Rollover wird abgewiesen. */
function sicheresDatum(jahr: number, monat: number, tagImMonat: number): Date | null {
if (monat < 1 || monat > 12 || tagImMonat < 1 || tagImMonat > 31) return null
const datum = tag(jahr, monat, tagImMonat)
if (datum.getUTCMonth() + 1 !== monat || datum.getUTCDate() !== tagImMonat) return null
return datum
}
/** Einzelnes Datum parsen - fuer Formularfelder und Tests. */
export function datumLesen(text: string): Date | null {
const funde = datenFinden(text)
return funde[0]?.datum ?? null
}
/**
* Das Datum des Schreibens: das erste plausible Datum im Kopf des Briefes,
* das nicht in der Zukunft liegt. Behoerdenbriefe tragen es rechts oben,
* meist innerhalb der ersten 1200 Zeichen.
*/
export function schreibdatumErkennen(
text: string,
heute: Date = new Date(),
): Vorschlag<Date> | null {
const kopfbereich = text.slice(0, 1500)
const grenze = aufTag(heute)
// Ein ausdruecklich benanntes Datum schlaegt jedes gefundene.
const benannt =
/(?:vom|Datum|Schreiben vom|Bescheid vom|Ihr Schreiben vom|Rechnungsdatum|Ausstellungsdatum)\s*:?\s*/gi
let treffer: RegExpExecArray | null
while ((treffer = benannt.exec(kopfbereich)) !== null) {
const rest = kopfbereich.slice(treffer.index + treffer[0]!.length, treffer.index + treffer[0]!.length + 40)
const fund = datenFinden(rest)[0]
if (fund && fund.datum <= grenze) {
return {
wert: fund.datum,
textstelle: textstelleSchneiden(kopfbereich, treffer.index, treffer.index + treffer[0]!.length + fund.bis),
sicherheit: 'hoch',
}
}
}
const fund = datenFinden(kopfbereich).find((f) => f.datum <= grenze)
if (!fund) return null
return {
wert: fund.datum,
textstelle: textstelleSchneiden(kopfbereich, fund.von, fund.bis),
sicherheit: 'mittel',
}
}
// -----------------------------------------------------------------------------
// Aktenzeichen
// -----------------------------------------------------------------------------
type AzMuster = {
regex: RegExp
sicherheit: Sicherheit
/** Welche Gruppe traegt den Wert? */
gruppe: number
}
/**
* Aktenzeichen, Kunden- und Vertragsnummern.
*
* Reihenfolge ist Rangfolge: ausdruecklich beschriftete Nummern
* ("Az.: …", "Kd.-Nr. …") vor dem blossen Muster eines Gerichtsaktenzeichens.
*/
const AZ_MUSTER: AzMuster[] = [
{
// "Az.: XVII 123/25", "Aktenzeichen: 4711/26", "Unser Zeichen: AB/12345"
regex:
/(?:Aktenzeichen|Az\.?|Gesch\.?[-\s]?Z\.?|Geschäftszeichen|Geschaeftszeichen|Unser Zeichen|Ihr Zeichen)\s*:?\s*([A-Za-zÄÖÜäöü0-9][A-Za-zÄÖÜäöü0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$|\s*(?:Ihr|Unser|Datum|Seite|Betreff)\b)/g,
sicherheit: 'hoch',
gruppe: 1,
},
{
// "Kd.-Nr. 123456", "Kundennummer: 987", "Versicherungsschein-Nr. 4711"
regex:
/(?:Kd\.?[-\s]?Nr\.?|Kunden[-\s]?Nr\.?|Kundennummer|Versicherungsschein[-\s]?Nr\.?|Versicherungsscheinnummer|Vertrags[-\s]?Nr\.?|Vertragsnummer|Rechnungs[-\s]?Nr\.?|Rechnungsnummer|Renten[-\s]?Nr\.?|Mitglieds[-\s]?Nr\.?|Beleg[-\s]?Nr\.?)\s*:?\s*([A-Za-z0-9][A-Za-z0-9 .\-/]{2,39}?)(?=\s{2,}|[\n\r]|$)/g,
sicherheit: 'hoch',
gruppe: 1,
},
{
// Betreuungsgericht: "XVII 123/25", auch "XVII 0123/2025"
regex: /\b((?:X{0,3}(?:IX|IV|V?I{0,3}))\s?\d{1,5}\s*\/\s*\d{2,4})\b/g,
sicherheit: 'mittel',
gruppe: 1,
},
]
/** Endet das Aktenzeichen sauber? Schleppende Wortreste abschneiden. */
function azAufraeumen(roh: string): string {
return roh
.replace(/\s+/g, ' ')
.replace(/[.,;:\-]+$/, '')
.trim()
}
export function aktenzeichenErkennen(text: string): Vorschlag<string> | null {
for (const muster of AZ_MUSTER) {
muster.regex.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.regex.exec(text)) !== null) {
const wert = azAufraeumen(treffer[muster.gruppe] ?? '')
// Zu kurz oder rein alphabetisch: das ist kein Aktenzeichen.
if (wert.length < 3 || !/\d/.test(wert)) continue
if (wert.length > 40) continue
return {
wert,
textstelle: textstelleSchneiden(text, treffer.index, treffer.index + treffer[0]!.length),
sicherheit: muster.sicherheit,
}
}
}
return null
}
/** Alle gefundenen Nummern - fuer die Anzeige "weitere erkannte Nummern". */
export function alleAktenzeichen(text: string): string[] {
const gefunden = new Set<string>()
for (const muster of AZ_MUSTER) {
muster.regex.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.regex.exec(text)) !== null) {
const wert = azAufraeumen(treffer[muster.gruppe] ?? '')
if (wert.length >= 3 && wert.length <= 40 && /\d/.test(wert)) gefunden.add(wert)
}
}
return [...gefunden]
}
// -----------------------------------------------------------------------------
// Dokumentart
// -----------------------------------------------------------------------------
type ArtRegel = { art: DokumentArt; woerter: string[]; gewicht: number }
/**
* Schluesselwoerter je Dokumentart. Laengere, spezifischere Begriffe wiegen
* schwerer: "Widerspruchsbescheid" ist ein Bescheid, auch wenn im selben
* Text zehnmal "Antrag" steht.
*/
const ART_REGELN: ArtRegel[] = [
{ art: 'BESCHEID', woerter: ['widerspruchsbescheid'], gewicht: 12 },
{ art: 'BESCHEID', woerter: ['aenderungsbescheid', 'bewilligungsbescheid', 'ablehnungsbescheid'], gewicht: 10 },
{ art: 'BESCHEID', woerter: ['bescheid', 'bescheiden wir', 'leistungsbescheid'], gewicht: 6 },
{ art: 'BESCHLUSS', woerter: ['beschluss', 'beschlossen', 'im namen des volkes'], gewicht: 8 },
{ art: 'MAHNUNG', woerter: ['mahnung', 'zahlungserinnerung', 'letzte mahnung', 'mahnbescheid'], gewicht: 10 },
{ art: 'RECHNUNG', woerter: ['rechnung', 'rechnungsnummer', 'zahlbetrag', 'rechnungsbetrag'], gewicht: 7 },
{ art: 'QUITTUNG', woerter: ['quittung', 'zahlungsbeleg', 'barquittung'], gewicht: 8 },
{ art: 'KONTOAUSZUG', woerter: ['kontoauszug', 'umsatzuebersicht', 'auszug nr'], gewicht: 10 },
{ art: 'GRUNDBUCHAUSZUG', woerter: ['grundbuchauszug', 'grundbuch von', 'grundbuchamt', 'abteilung iii'], gewicht: 10 },
{ art: 'VERTRAG', woerter: ['kaufvertrag', 'mietvertrag', 'heimvertrag', 'darlehensvertrag', 'vertrag ueber'], gewicht: 9 },
{ art: 'VERTRAG', woerter: ['versicherungsschein', 'police', 'nachtrag zum vertrag'], gewicht: 8 },
{ art: 'ANTRAG', woerter: ['antrag auf', 'antragsformular', 'hiermit beantrage', 'wir beantragen'], gewicht: 8 },
{ art: 'GUTACHTEN', woerter: ['gutachten', 'sachverstaendiger', 'pflegegutachten', 'medizinischer dienst'], gewicht: 9 },
{ art: 'ATTEST', woerter: ['attest', 'aerztliche bescheinigung', 'arztbrief', 'befundbericht'], gewicht: 9 },
{ art: 'VOLLMACHT', woerter: ['vollmacht', 'bevollmaechtige hiermit', 'betreuerausweis'], gewicht: 9 },
{ art: 'URKUNDE', woerter: ['urkunde', 'notarielle', 'beglaubigung', 'sterbeurkunde', 'geburtsurkunde'], gewicht: 8 },
{ art: 'PROTOKOLL', woerter: ['protokoll', 'niederschrift', 'aktenvermerk', 'gespraechsnotiz'], gewicht: 8 },
{ art: 'AUSWEIS', woerter: ['personalausweis', 'schwerbehindertenausweis', 'ausweis nr'], gewicht: 8 },
{ art: 'SCHREIBEN', woerter: ['sehr geehrte', 'mit freundlichen gruessen', 'betreff'], gewicht: 2 },
]
export function dokumentartErkennen(text: string): Vorschlag<DokumentArt> | null {
// Der Betreff und die ersten Zeilen wiegen doppelt - dort steht, worum es geht.
const ganz = normalisieren(text)
const kopf = normalisieren(text.slice(0, 900))
const punkte = new Map<DokumentArt, { punkte: number; wort: string }>()
for (const regel of ART_REGELN) {
for (const wort of regel.woerter) {
const imKopf = kopf.includes(wort)
const imText = ganz.includes(wort)
if (!imText) continue
const wert = regel.gewicht * (imKopf ? 2 : 1)
const bisher = punkte.get(regel.art)
if (!bisher || wert > bisher.punkte) punkte.set(regel.art, { punkte: wert, wort })
}
}
if (punkte.size === 0) return null
const [art, treffer] = [...punkte.entries()].sort((a, b) => b[1].punkte - a[1].punkte)[0]!
const stelle = normalisieren(text).indexOf(treffer.wort)
return {
wert: art,
textstelle:
stelle >= 0
? textstelleSchneiden(text, stelle, stelle + treffer.wort.length, 45)
: treffer.wort,
sicherheit: treffer.punkte >= 12 ? 'hoch' : treffer.punkte >= 6 ? 'mittel' : 'niedrig',
}
}
// -----------------------------------------------------------------------------
// Absender
// -----------------------------------------------------------------------------
const ORGANISATIONS_WOERTER = [
'amtsgericht',
'landgericht',
'sozialgericht',
'finanzamt',
'landratsamt',
'bezirk',
'stadt ',
'gemeinde',
'jobcenter',
'agentur fuer arbeit',
'rentenversicherung',
'krankenkasse',
'pflegekasse',
'aok',
'barmer',
'techniker krankenkasse',
'versicherung',
'sparkasse',
'volksbank',
'raiffeisen',
'bank',
'gmbh',
'ag ',
'e.v.',
'e. v.',
'kg ',
'mbh',
'seniorenheim',
'pflegeheim',
'seniorenzentrum',
'klinik',
'krankenhaus',
'praxis',
'notar',
'rechtsanwalt',
'rechtsanwaelte',
'kanzlei',
'stadtwerke',
'sozialamt',
'betreuungsstelle',
'versorgungsamt',
]
const KEIN_ABSENDER =
/^(sehr geehrte|betreff|betr\.|datum|seite|az|aktenzeichen|unser zeichen|ihr zeichen|ihre nachricht|anlage|anlagen|mit freundlichen|hochachtungsvoll)/i
/**
* Der Absender steht in deutschen Geschaeftsbriefen ganz oben - entweder im
* Briefkopf oder in der kleinen Ruecksendezeile ueber der Anschrift.
*
* Bekannte Namen (aus den Kontakten und aus frueheren Dokumenten) schlagen
* jede Heuristik: wer schon einmal geschrieben hat, schreibt wieder.
*/
export function absenderErkennen(
text: string,
bekannte: string[] = [],
): Vorschlag<string> | null {
const kopfbereich = text.slice(0, 1200)
const normKopf = normalisieren(kopfbereich)
// 1. Abgleich mit bekannten Absendern
let bester: { name: string; stelle: number } | null = null
for (const name of bekannte) {
const gekuerzt = name.trim()
if (gekuerzt.length < 4) continue
const stelle = normKopf.indexOf(normalisieren(gekuerzt))
if (stelle >= 0 && (!bester || gekuerzt.length > bester.name.length)) {
bester = { name: gekuerzt, stelle }
}
}
if (bester) {
return {
wert: bester.name,
textstelle: textstelleSchneiden(kopfbereich, bester.stelle, bester.stelle + bester.name.length, 40),
sicherheit: 'hoch',
}
}
const zeilen = kopfbereich
.split('\n')
.map((z) => z.trim())
.filter((z) => z.length >= 3 && z.length <= 90)
.slice(0, 12)
// 2. Zeile mit einem typischen Organisationswort
for (const zeile of zeilen) {
if (KEIN_ABSENDER.test(zeile)) continue
const norm = normalisieren(zeile)
if (ORGANISATIONS_WOERTER.some((wort) => norm.includes(wort))) {
const stelle = kopfbereich.indexOf(zeile)
return {
wert: zeile.replace(/\s+/g, ' '),
textstelle: textstelleSchneiden(kopfbereich, stelle, stelle + zeile.length, 30),
sicherheit: 'mittel',
}
}
}
// 3. Notfalls die erste Zeile, die nach einem Namen aussieht
for (const zeile of zeilen) {
if (KEIN_ABSENDER.test(zeile)) continue
if (/^\d/.test(zeile)) continue // Datum, Postleitzahl, Seitenzahl
if (!/[A-ZÄÖÜ]/.test(zeile)) continue
const stelle = kopfbereich.indexOf(zeile)
return {
wert: zeile.replace(/\s+/g, ' '),
textstelle: textstelleSchneiden(kopfbereich, stelle, stelle + zeile.length, 30),
sicherheit: 'niedrig',
}
}
return null
}
// -----------------------------------------------------------------------------
// Fristen
// -----------------------------------------------------------------------------
const ZAHLWOERTER: Record<string, number> = {
ein: 1,
eine: 1,
einer: 1,
eines: 1,
einem: 1,
einen: 1,
zwei: 2,
drei: 3,
vier: 4,
fuenf: 5,
sechs: 6,
sieben: 7,
acht: 8,
neun: 9,
zehn: 10,
elf: 11,
zwoelf: 12,
vierzehn: 14,
}
type Einheit = 'tage' | 'wochen' | 'monate' | 'jahre'
function einheitLesen(roh: string): Einheit | null {
const norm = normalisieren(roh)
if (norm.startsWith('tag') || norm.startsWith('werktag') || norm.startsWith('kalendertag')) return 'tage'
if (norm.startsWith('woch')) return 'wochen'
if (norm.startsWith('monat')) return 'monate'
if (norm.startsWith('jahr')) return 'jahre'
return null
}
function anzahlLesen(roh: string): number | null {
const gekuerzt = roh.trim()
if (/^\d{1,3}$/.test(gekuerzt)) return Number.parseInt(gekuerzt, 10)
const wort = ZAHLWOERTER[normalisieren(gekuerzt)]
return wort ?? null
}
function rechnen(basis: Date, anzahl: number, einheit: Einheit): Date {
switch (einheit) {
case 'tage':
return aufTag(addDays(basis, anzahl))
case 'wochen':
return aufTag(addWeeks(basis, anzahl))
case 'monate':
return aufTag(addMonths(basis, anzahl))
case 'jahre':
return aufTag(addYears(basis, anzahl))
}
}
function einheitText(anzahl: number, einheit: Einheit): string {
const namen: Record<Einheit, [string, string]> = {
tage: ['Tag', 'Tagen'],
wochen: ['Woche', 'Wochen'],
monate: ['Monat', 'Monaten'],
jahre: ['Jahr', 'Jahren'],
}
return `${anzahl} ${anzahl === 1 ? namen[einheit][0] : namen[einheit][1]}`
}
const RECHTSBEHELF = /widerspruch|klage|rechtsbehelf|rechtsmittel|beschwerde|einspruch/i
/**
* Relative Fristen: "innerhalb von vier Wochen", "binnen eines Monats",
* "einen Monat nach Bekanntgabe", "Frist von 14 Tagen".
*/
const RELATIV_MUSTER: RegExp[] = [
/\b(?:innerhalb|binnen)\s+(?:von\s+|einer\s+Frist\s+von\s+)?([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tagen?|Werktagen?|Kalendertagen?|Wochen?|Monaten?|Monats|Jahren?|Jahres)\b/gi,
/\bFrist\s+von\s+([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tagen?|Werktagen?|Wochen?|Monaten?|Monats|Jahren?)\b/gi,
/\b([A-Za-zÄÖÜäöü]+|\d{1,3})\s+(Tage|Tagen|Wochen|Monat|Monate|Monaten|Jahr|Jahre|Jahren)\s+nach\s+(?:Bekanntgabe|Zugang|Zustellung|Erhalt|Empfang|Bekanntwerden)\b/gi,
]
/** Absolute Fristen: "bis zum 30.04.2026", "spätestens am 30.04.2026". */
const ABSOLUT_MUSTER =
/\b(?:bis\s+(?:zum|spätestens|spaetestens)?|bis|spätestens\s+(?:am|zum)?|spaetestens\s+(?:am|zum)?|fällig\s+am|faellig\s+am|Zahlungsziel|zahlbar\s+bis|Termin\s+am)\s*:?\s*/gi
/**
* Sucht Fristen im Text und rechnet sie aus.
*
* `basis` ist der Tag, ab dem gerechnet wird - in aller Regel der Zugang
* des Schreibens, nicht das Briefdatum. Bei Bescheiden ist das der Tag der
* Bekanntgabe; die Drei-Tages-Fiktion des § 37 Abs. 2 SGB X wird hier
* bewusst NICHT angewandt, weil sie vom Versandweg abhaengt und die
* Anwendung den nicht kennt. Der Hinweis darauf steht in der Begruendung.
*/
export function fristenErkennen(text: string, basis: Date | null): Fristvorschlag[] {
const vorschlaege: Fristvorschlag[] = []
const basisTag = basis ? aufTag(basis) : null
for (const muster of RELATIV_MUSTER) {
muster.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(text)) !== null) {
const anzahl = anzahlLesen(treffer[1]!)
const einheit = einheitLesen(treffer[2]!)
if (anzahl === null || einheit === null || anzahl < 1 || anzahl > 120) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), treffer.index + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const spanne = einheitText(anzahl, einheit)
vorschlaege.push({
datum: basisTag ? rechnen(basisTag, anzahl, einheit) : null,
basis: basisTag,
formulierung: treffer[0]!.replace(/\s+/g, ' ').trim(),
titel: rechtsbehelf
? `Widerspruch/Rechtsbehelf einlegen (${spanne})`
: `Frist aus dem Schreiben: ${spanne}`,
begruendung: basisTag
? `${spanne} ab dem ${formatKurz(basisTag)} (angenommener Zugang). ` +
`Prüfe das Zugangsdatum – bei Bescheiden zählt die Bekanntgabe, nicht das Briefdatum.`
: `${spanne} ab Zugang. Trage zuerst das Eingangsdatum ein, dann wird die Frist berechnet.`,
textstelle: textstelleSchneiden(text, treffer.index, treffer.index + treffer[0]!.length),
sicherheit: rechtsbehelf ? 'hoch' : 'mittel',
rechtsbehelf,
})
}
}
ABSOLUT_MUSTER.lastIndex = 0
let treffer: RegExpExecArray | null
while ((treffer = ABSOLUT_MUSTER.exec(text)) !== null) {
const ab = treffer.index + treffer[0]!.length
const rest = text.slice(ab, ab + 40)
const fund = datenFinden(rest)[0]
if (!fund || fund.von > 3) continue
const umgebung = text.slice(Math.max(0, treffer.index - 200), ab + 200)
const rechtsbehelf = RECHTSBEHELF.test(umgebung)
const zahlung = /zahl|betrag|überweis|ueberweis|konto|rechnung/i.test(umgebung)
vorschlaege.push({
datum: fund.datum,
basis: null,
formulierung: `${treffer[0]!.trim()} ${fund.text}`.replace(/\s+/g, ' '),
titel: rechtsbehelf
? 'Widerspruch/Rechtsbehelf einlegen'
: zahlung
? 'Zahlung bis zum genannten Termin'
: 'Im Schreiben genannter Termin',
begruendung: `Im Text ausdrücklich genanntes Datum (${formatKurz(fund.datum)}).`,
textstelle: textstelleSchneiden(text, treffer.index, ab + fund.bis),
sicherheit: 'hoch',
rechtsbehelf,
})
}
return entdoppeln(vorschlaege)
}
function formatKurz(datum: Date): string {
const tagZahl = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tagZahl}.${monat}.${datum.getUTCFullYear()}`
}
function entdoppeln(liste: Fristvorschlag[]): Fristvorschlag[] {
const gesehen = new Set<string>()
const rang: Record<Sicherheit, number> = { hoch: 0, mittel: 1, niedrig: 2 }
return liste
.slice()
.sort((a, b) => rang[a.sicherheit] - rang[b.sicherheit])
.filter((vorschlag) => {
const schluessel = `${vorschlag.datum?.toISOString() ?? 'offen'}|${normalisieren(vorschlag.formulierung)}`
if (gesehen.has(schluessel)) return false
gesehen.add(schluessel)
return true
})
.slice(0, 6)
}
// -----------------------------------------------------------------------------
// Richtung und Titel
// -----------------------------------------------------------------------------
/**
* Eingang oder Ausgang? Beim Handy-Foto eines Briefes ist es fast immer
* ein Eingang; ein selbst geschriebener Antrag ist ein Ausgang.
*/
export function richtungErkennen(text: string): Vorschlag<DokumentRichtung> | null {
const kopf = normalisieren(text.slice(0, 900))
if (/hiermit beantrage ich|hiermit lege ich widerspruch|als betreuer der|in meiner eigenschaft als betreuer/.test(kopf)) {
return { wert: 'AUSGANG', textstelle: 'Formulierung in der Ich-Form', sicherheit: 'mittel' }
}
if (/sehr geehrte|wir teilen ihnen mit|wir haben ihren antrag|hiermit erhalten sie/.test(kopf)) {
return { wert: 'EINGANG', textstelle: 'Anrede eines fremden Absenders', sicherheit: 'mittel' }
}
return null
}
/** Ein brauchbarer Titel: Betreffzeile, sonst Art plus Absender. */
export function titelVorschlagen(
text: string,
art: DokumentArt | null,
absender: string | null,
): string | null {
const betreff = /(?:Betreff|Betr\.|Betrifft)\s*:?\s*(.{4,90})/i.exec(text.slice(0, 1500))
if (betreff) {
const zeile = betreff[1]!.split('\n')[0]!.replace(/\s+/g, ' ').trim()
if (zeile.length >= 4) return zeile.slice(0, 90)
}
if (art && absender) return `${ART_BESCHRIFTUNG[art]} – ${absender}`.slice(0, 90)
if (absender) return `Schreiben – ${absender}`.slice(0, 90)
if (art) return ART_BESCHRIFTUNG[art]
return null
}
/** Beschriftungen der Dokumentarten - auch von der Oberflaeche benutzt. */
export const ART_BESCHRIFTUNG: Record<DokumentArt, string> = {
BESCHEID: 'Bescheid',
ANTRAG: 'Antrag',
SCHREIBEN: 'Schreiben',
RECHNUNG: 'Rechnung',
KONTOAUSZUG: 'Kontoauszug',
VERTRAG: 'Vertrag',
BESCHLUSS: 'Beschluss',
URKUNDE: 'Urkunde',
GUTACHTEN: 'Gutachten',
AUSWEIS: 'Ausweis',
PROTOKOLL: 'Protokoll',
QUITTUNG: 'Quittung',
MAHNUNG: 'Mahnung',
VOLLMACHT: 'Vollmacht',
ATTEST: 'Attest',
GRUNDBUCHAUSZUG: 'Grundbuchauszug',
FOTO: 'Foto',
SONSTIGES: 'Sonstiges',
}
export const RICHTUNG_BESCHRIFTUNG: Record<DokumentRichtung, string> = {
EINGANG: 'Eingang',
AUSGANG: 'Ausgang',
INTERN: 'Intern',
}
// -----------------------------------------------------------------------------
// Alles zusammen
// -----------------------------------------------------------------------------
/**
* Wertet einen OCR-Text aus und liefert die Vorschlaege, die der Nutzer
* anschliessend bestaetigt. Es wird nichts angelegt und nichts gespeichert.
*/
export function erkennungsvorschlaege(
text: string,
optionen: Erkennungsoptionen = {},
): Erkennungsergebnis {
const heute = optionen.heute ?? new Date()
if (!text || text.trim().length < 10) {
return {
absender: null,
schreibenVom: null,
aktenzeichen: null,
art: null,
richtung: null,
titel: null,
fristen: [],
}
}
const absender = absenderErkennen(text, optionen.bekannteAbsender ?? [])
const schreibenVom = schreibdatumErkennen(text, heute)
const aktenzeichen = aktenzeichenErkennen(text)
const art = dokumentartErkennen(text)
const richtung = richtungErkennen(text)
// Fuer Fristen zaehlt der Zugang. Fehlt er, ist das Briefdatum die beste
// verfuegbare Naeherung - der Nutzer sieht in der Begruendung, worauf
// gerechnet wurde, und kann es korrigieren.
const basis = optionen.zugangAm ?? schreibenVom?.wert ?? null
return {
absender,
schreibenVom,
aktenzeichen,
art,
richtung,
titel: titelVorschlagen(text, art?.wert ?? null, absender?.wert ?? null),
fristen: fristenErkennen(text, basis),
}
}
+375
View File
@@ -0,0 +1,375 @@
import 'server-only'
import { Prisma, type DokumentArt, type DokumentRichtung, type PosteingangStatus } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Volltextsuche ueber die Dokumente einer betreuten Person.
*
* Gesucht wird in der von Postgres gepflegten Spalte `suchvektor`
* (prisma/sql/volltextsuche.sql, deutsche Konfiguration, Trigger auf
* Titel/Absender/Aktenzeichen/Dateiname/OCR-Text).
*
* Zwei Dinge sind hier nicht verhandelbar:
*
* 1. Jede Abfrage ist auf `betreuteId` eingegrenzt und blendet den
* Papierkorb aus. Es gibt keinen Weg durch diese Datei, der Dokumente
* einer anderen Person zurueckgeben koennte.
* 2. Der Suchbegriff wird als **Parameter** uebergeben, nie in den
* SQL-Text geklebt. `websearch_to_tsquery` nimmt beliebigen Nutzertext
* entgegen, ohne bei Anfuehrungszeichen oder Klammern zu stolpern.
*/
export type Suchtreffer = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
/** Fundstelle mit hervorgehobenem Treffer (<mark>…</mark>). */
ausschnitt: string
/** Rangwert von ts_rank_cd - nur zum Sortieren und Anzeigen. */
rang: number
}
/**
* Bereitet die Eingabe des Nutzers auf.
*
* `websearch_to_tsquery` versteht die Schreibweise, die man aus
* Suchmaschinen kennt: Woerter, "Wortgruppen in Anfuehrungszeichen",
* `oder`, ein vorangestelltes Minus zum Ausschliessen. Deshalb wird hier
* bewusst wenig veraendert - nur Steuerzeichen und uebermaessige Laenge
* werden abgeraeumt.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
if (sauber.length < 2) return null
// Ein einzelnes offenes Anfuehrungszeichen wuerde die halbe Anfrage zur
// Wortgruppe machen - lieber entfernen als den Nutzer raten lassen.
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '')
}
export type Suchoptionen = {
grenze?: number
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
}
/**
* Volltextsuche. Rangfolge ueber ts_rank_cd, Fundstelle ueber ts_headline.
* Leere oder unbrauchbare Anfrage -> leeres Ergebnis, kein Fehler.
*/
export async function dokumenteSuchen(
betreuteId: string,
anfrage: string,
optionen: Suchoptionen = {},
): Promise<Suchtreffer[]> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return []
const grenze = Math.min(Math.max(optionen.grenze ?? 50, 1), 200)
const bedingungen: Prisma.Sql[] = [
Prisma.sql`d."betreuteId" = ${betreuteId}`,
Prisma.sql`d."geloeschtAm" IS NULL`,
Prisma.sql`d.suchvektor @@ q.abfrage`,
]
if (optionen.art) bedingungen.push(Prisma.sql`d.art = ${optionen.art}::"DokumentArt"`)
if (optionen.richtung) {
bedingungen.push(Prisma.sql`d.richtung = ${optionen.richtung}::"DokumentRichtung"`)
}
if (optionen.posteingangStatus) {
bedingungen.push(
Prisma.sql`d."posteingangStatus" = ${optionen.posteingangStatus}::"PosteingangStatus"`,
)
}
if (optionen.von) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") >= ${optionen.von}`)
}
if (optionen.bis) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") <= ${optionen.bis}`)
}
if (optionen.tagId) {
bedingungen.push(
Prisma.sql`EXISTS (
SELECT 1 FROM "_DokumentTags" v
WHERE v."A" = d.id AND v."B" = ${optionen.tagId}
)`,
)
}
const zeilen = await db.$queryRaw<
(Omit<Suchtreffer, 'rang'> & { rang: number })[]
>(Prisma.sql`
SELECT
d.id,
d.titel,
d.art,
d.richtung,
d.absender,
d.aktenzeichen,
d.dateiname,
d."eingangAm",
d."schreibenVom",
d."posteingangStatus",
ts_headline(
'german',
coalesce(nullif(d."ocrText", ''), concat_ws(' ', d.titel, d.absender, d.aktenzeichen)),
q.abfrage,
'StartSel=<mark>, StopSel=</mark>, MaxWords=32, MinWords=12, ShortWord=3, MaxFragments=2, FragmentDelimiter=" … "'
) AS ausschnitt,
ts_rank_cd(d.suchvektor, q.abfrage) AS rang
FROM dokument d,
websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE ${Prisma.join(bedingungen, ' AND ')}
ORDER BY rang DESC, coalesce(d."eingangAm", d."schreibenVom", d."erstelltAm"::date) DESC
LIMIT ${grenze}
`)
return zeilen.map((zeile) => ({ ...zeile, rang: Number(zeile.rang) }))
}
/** Zaehlt die Treffer, ohne sie zu laden - fuer "127 Treffer". */
export async function trefferZaehlen(betreuteId: string, anfrage: string): Promise<number> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return 0
const ergebnis = await db.$queryRaw<{ anzahl: bigint }[]>(Prisma.sql`
SELECT count(*) AS anzahl
FROM dokument d, websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.suchvektor @@ q.abfrage
`)
return Number(ergebnis[0]?.anzahl ?? 0)
}
/**
* Vorschlaege fuer das Absenderfeld: unscharf ueber den Trigram-Index, so
* dass "Bezirk Oberfr" auch "Bezirk Oberfranken" findet.
*/
export async function absenderVorschlagen(
betreuteId: string,
teil: string,
grenze = 8,
): Promise<string[]> {
const sauber = (teil ?? '').trim().slice(0, 80)
if (sauber.length < 2) return []
const zeilen = await db.$queryRaw<{ absender: string }[]>(Prisma.sql`
SELECT DISTINCT d.absender
FROM dokument d
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.absender IS NOT NULL
AND d.absender % ${sauber}
ORDER BY d.absender
LIMIT ${Math.min(Math.max(grenze, 1), 25)}
`)
return zeilen.map((z) => z.absender)
}
/**
* Alle Absender, die in dieser Akte schon einmal vorkamen - Futter fuer
* die Erkennung beim naechsten Upload.
*/
export async function bekannteAbsender(betreuteId: string, grenze = 250): Promise<string[]> {
const [ausDokumenten, ausKontakten] = await Promise.all([
db.dokument.findMany({
where: { betreuteId, absender: { not: null }, ...nichtGeloescht },
select: { absender: true },
distinct: ['absender'],
take: grenze,
}),
db.kontakt.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { name: true, organisation: true },
take: grenze,
}),
])
const namen = new Set<string>()
for (const eintrag of ausDokumenten) {
if (eintrag.absender) namen.add(eintrag.absender.trim())
}
for (const kontakt of ausKontakten) {
if (kontakt.organisation) namen.add(kontakt.organisation.trim())
if (kontakt.name) namen.add(kontakt.name.trim())
}
// Lange Namen zuerst: "Bezirk Oberfranken, Sozialverwaltung" ist ein
// besserer Treffer als das blosse "Bezirk Oberfranken".
return [...namen].filter((n) => n.length >= 3).sort((a, b) => b.length - a.length)
}
// -----------------------------------------------------------------------------
// Liste ohne Suchbegriff
// -----------------------------------------------------------------------------
export type Listenfilter = {
suche?: string | null
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
seite?: number
proSeite?: number
}
export function filterWhere(betreuteId: string, filter: Listenfilter): Prisma.DokumentWhereInput {
const where: Prisma.DokumentWhereInput = { betreuteId, ...nichtGeloescht }
if (filter.art) where.art = filter.art
if (filter.richtung) where.richtung = filter.richtung
if (filter.posteingangStatus) where.posteingangStatus = filter.posteingangStatus
if (filter.tagId) where.tags = { some: { id: filter.tagId } }
if (filter.von || filter.bis) {
const spanne: Prisma.DateTimeFilter = {}
if (filter.von) spanne.gte = filter.von
if (filter.bis) spanne.lte = filter.bis
where.OR = [{ eingangAm: spanne }, { eingangAm: null, schreibenVom: spanne }]
}
return where
}
export type Listeneintrag = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
mimeTyp: string | null
groesseBytes: number | null
seitenzahl: number | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
ablageortPapier: string | null
ocrStatus: string | null
erstelltAm: Date
tags: { id: string; name: string; farbe: string }[]
anzahlVerknuepfungen: number
ausschnitt?: string
}
/**
* Die Dokumentenliste mit Filtern - und, wenn ein Suchbegriff gesetzt ist,
* in der Rangfolge der Volltextsuche.
*/
export async function dokumenteListe(
betreuteId: string,
filter: Listenfilter = {},
): Promise<{ eintraege: Listeneintrag[]; gesamt: number }> {
const proSeite = Math.min(Math.max(filter.proSeite ?? 25, 5), 100)
const seite = Math.max(filter.seite ?? 1, 1)
const begriff = suchanfrageAufbereiten(filter.suche)
if (begriff) {
const treffer = await dokumenteSuchen(betreuteId, begriff, {
grenze: 200,
art: filter.art,
richtung: filter.richtung,
posteingangStatus: filter.posteingangStatus,
von: filter.von,
bis: filter.bis,
tagId: filter.tagId,
})
const seitenTreffer = treffer.slice((seite - 1) * proSeite, seite * proSeite)
if (seitenTreffer.length === 0) return { eintraege: [], gesamt: treffer.length }
const datensaetze = await db.dokument.findMany({
where: { id: { in: seitenTreffer.map((t) => t.id) } },
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
})
const nachId = new Map(datensaetze.map((d) => [d.id, d]))
const eintraege = seitenTreffer
.map((t) => {
const datensatz = nachId.get(t.id)
if (!datensatz) return null
return { ...zuEintrag(datensatz), ausschnitt: t.ausschnitt }
})
.filter((e): e is Listeneintrag => e !== null)
return { eintraege, gesamt: treffer.length }
}
const where = filterWhere(betreuteId, filter)
const [datensaetze, gesamt] = await Promise.all([
db.dokument.findMany({
where,
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
skip: (seite - 1) * proSeite,
take: proSeite,
}),
db.dokument.count({ where }),
])
return { eintraege: datensaetze.map(zuEintrag), gesamt }
}
type RohDatensatz = Prisma.DokumentGetPayload<{
include: {
tags: { select: { id: true; name: true; farbe: true } }
_count: { select: { verknuepfungen: true } }
}
}>
function zuEintrag(d: RohDatensatz): Listeneintrag {
return {
id: d.id,
titel: d.titel,
art: d.art,
richtung: d.richtung,
absender: d.absender,
aktenzeichen: d.aktenzeichen,
dateiname: d.dateiname,
mimeTyp: d.mimeTyp,
groesseBytes: d.groesseBytes,
seitenzahl: d.seitenzahl,
eingangAm: d.eingangAm,
schreibenVom: d.schreibenVom,
posteingangStatus: d.posteingangStatus,
ablageortPapier: d.ablageortPapier,
ocrStatus: d.ocrStatus,
erstelltAm: d.erstelltAm,
tags: d.tags,
anzahlVerknuepfungen: d._count.verknuepfungen,
}
}
+189
View File
@@ -0,0 +1,189 @@
/**
* Geldbetraege.
*
* In dieser Anwendung gibt es nur eine Darstellung von Geld: BigInt in
* Cent. Kein Float, kein Decimal, keine Zwischenrechnung in Euro. Der
* Grund steht im Schema: die Rechnungslegung nach § 1865 BGB darf keine
* Rundungsfehler erben, und beim Gericht wird auf den Cent geprueft.
*
* Hier stehen deshalb nur zwei Sorten von Funktionen: solche, die Text
* in Cent verwandeln (Formular, CSV-Import), und solche, die auf Cent
* rechnen, ohne dass ein Cent verloren geht.
*/
export class BetragFehler extends Error {
constructor(nachricht: string) {
super(nachricht)
this.name = 'BetragFehler'
}
}
const NICHT_ZIFFER_ODER_TRENNER = /[^0-9.,+-]/g
/**
* Liest einen Betrag aus Text und liefert Cent.
*
* Erkannt werden die Schreibweisen, die in deutschen Kontoauszuegen und
* CSV-Ausleitungen tatsaechlich vorkommen:
*
* "1.234,56" "1234,56" "-1.234,56" "1.234,56-" "1234,5"
* "1234" "€ 1.234,56" "1 234,56" "(1.234,56)"
* "1234.56" (englische Schreibweise, z.B. aus CAMT oder Excel)
*
* Regeln bei mehrdeutigen Trennzeichen:
*
* - Kommen Punkt UND Komma vor, ist das zuletzt stehende Zeichen das
* Dezimaltrennzeichen, das andere das Tausendertrennzeichen.
* - Ein alleinstehendes Komma ist immer Dezimaltrennzeichen (deutsch).
* - Ein alleinstehender Punkt ist Tausendertrennzeichen, wenn genau drei
* Ziffern folgen und Ziffern davorstehen ("1.234"), sonst
* Dezimaltrennzeichen ("1234.56"). Diese Anwendung liest deutsche
* Dateien; "1.234" heisst hier eintausendzweihundertvierunddreissig.
*
* Mehr als zwei Nachkommastellen sind ein Fehler und keine Rundung -
* wer aus 1,005 EUR eigenmaechtig 1,01 EUR macht, verschiebt Geld.
*/
export function betragParsen(eingabe: string | null | undefined): bigint {
if (eingabe === null || eingabe === undefined) {
throw new BetragFehler('Bitte einen Betrag angeben.')
}
let text = eingabe.trim()
if (text === '') throw new BetragFehler('Bitte einen Betrag angeben.')
// Buchhalterische Klammer: (12,34) bedeutet minus 12,34.
let negativ = false
if (/^\(.*\)$/.test(text)) {
negativ = true
text = text.slice(1, -1)
}
text = text.replace(NICHT_ZIFFER_ODER_TRENNER, '')
// Vorzeichen darf vorne oder hinten stehen ("1.234,56-" ist in
// Bankausleitungen ueblich).
if (text.startsWith('-')) {
negativ = !negativ
text = text.slice(1)
} else if (text.startsWith('+')) {
text = text.slice(1)
}
if (text.endsWith('-')) {
negativ = !negativ
text = text.slice(0, -1)
} else if (text.endsWith('+')) {
text = text.slice(0, -1)
}
if (text === '' || /[+-]/.test(text)) {
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
}
const letztesKomma = text.lastIndexOf(',')
const letzterPunkt = text.lastIndexOf('.')
let dezimaltrenner: '.' | ',' | null = null
if (letztesKomma >= 0 && letzterPunkt >= 0) {
dezimaltrenner = letztesKomma > letzterPunkt ? ',' : '.'
} else if (letztesKomma >= 0) {
dezimaltrenner = ','
} else if (letzterPunkt >= 0) {
const nachkomma = text.length - letzterPunkt - 1
const punkteAnzahl = text.split('.').length - 1
const tausender = punkteAnzahl > 1 || (nachkomma === 3 && letzterPunkt > 0)
dezimaltrenner = tausender ? null : '.'
}
let ganz: string
let bruch: string
if (dezimaltrenner === null) {
ganz = text.replace(/[.,]/g, '')
bruch = ''
} else {
const teiler = dezimaltrenner === ',' ? letztesKomma : letzterPunkt
ganz = text.slice(0, teiler).replace(/[.,]/g, '')
bruch = text.slice(teiler + 1).replace(/[.,]/g, '')
}
if (!/^\d*$/.test(ganz) || !/^\d*$/.test(bruch) || ganz + bruch === '') {
throw new BetragFehler(`"${eingabe}" ist kein Betrag. Beispiel: 1.234,56`)
}
if (bruch.length > 2) {
throw new BetragFehler(
`"${eingabe}" hat mehr als zwei Nachkommastellen. Beträge werden auf den Cent geführt.`,
)
}
const cent = BigInt(ganz === '' ? '0' : ganz) * 100n + BigInt(bruch.padEnd(2, '0') || '0')
return negativ ? -cent : cent
}
/** Wie betragParsen, gibt aber null statt eines Fehlers zurueck. */
export function betragParsenWennMoeglich(eingabe: string | null | undefined): bigint | null {
try {
return betragParsen(eingabe)
} catch {
return null
}
}
/**
* Cent als deutsche Zahl ohne Waehrungszeichen ("1.234,56").
* Fuer Eingabefelder gedacht - zum Anzeigen gibt es formatCent().
*/
export function betragFormatieren(cent: bigint): string {
const negativ = cent < 0n
const betrag = negativ ? -cent : cent
const ganz = betrag / 100n
const rest = betrag % 100n
const ganzText = ganz
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, '.')
return `${negativ ? '-' : ''}${ganzText},${rest.toString().padStart(2, '0')}`
}
/** Summe, ohne dass jemals ein Zwischenwert in Number landet. */
export function summe(werte: Iterable<bigint>): bigint {
let ergebnis = 0n
for (const wert of werte) ergebnis += wert
return ergebnis
}
/**
* Ganzzahlige Division mit kaufmaennischer Rundung (halbe Cent vom
* Nullpunkt weg). Wird fuer Durchschnitte gebraucht - etwa "laufende
* Einnahmen je Monat" im Vermoegensverzeichnis.
*/
export function teileGerundet(zaehler: bigint, nenner: bigint): bigint {
if (nenner === 0n) throw new BetragFehler('Division durch null.')
const negativ = zaehler < 0n !== nenner < 0n
const a = zaehler < 0n ? -zaehler : zaehler
const b = nenner < 0n ? -nenner : nenner
const ganz = a / b
const rest = a % b
const ergebnis = rest * 2n >= b ? ganz + 1n : ganz
return negativ ? -ergebnis : ergebnis
}
/**
* Verteilt einen Betrag auf n Teile, ohne dass ein Cent verschwindet:
* die Restcents werden auf die vorderen Teile verteilt. Die Summe der
* Rueckgabe ist immer exakt der Eingabebetrag.
*/
export function centAufteilen(gesamtCent: bigint, teile: number): bigint[] {
if (!Number.isInteger(teile) || teile <= 0) {
throw new BetragFehler('Ein Betrag lässt sich nur auf mindestens einen Teil aufteilen.')
}
const n = BigInt(teile)
const negativ = gesamtCent < 0n
const betrag = negativ ? -gesamtCent : gesamtCent
const grundanteil = betrag / n
const rest = betrag % n
const ergebnis: bigint[] = []
for (let i = 0; i < teile; i++) {
const anteil = grundanteil + (BigInt(i) < rest ? 1n : 0n)
ergebnis.push(negativ ? -anteil : anteil)
}
return ergebnis
}
+130
View File
@@ -0,0 +1,130 @@
import { alsTag, heute, jahrePlus, monatePlus, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
/**
* Das Betreuungsjahr.
*
* Es laeuft ab dem Tag der Bestellung, nicht ab dem 1. Januar. Wer am
* 15.03.2025 bestellt wurde, legt fuer den Zeitraum 15.03.2025 bis
* 14.03.2026 Rechnung (§ 1865 Abs. 1 BGB), und die Aufwandspauschale
* nach § 1878 BGB verfaellt sechs Monate nach dessen Ende.
*
* Das ist der haeufigste Rechenfehler in selbstgebauten Tabellen: man
* rechnet das Kalenderjahr ab und wundert sich, warum das Gericht
* nachfragt. Deshalb steht die Rechnung hier an genau einer Stelle.
*
* Schaltjahrfall: Ist die Bestellung am 29.02. erfolgt, gibt es in
* Nicht-Schaltjahren keinen 29.02. Das Jahr endet dann am 27.02. und das
* folgende beginnt am 28.02. - so entsteht weder eine Luecke noch eine
* Ueberschneidung, und jeder Tag gehoert zu genau einem Betreuungsjahr.
*/
export type Betreuungsjahr = {
/** 1 = das erste Jahr ab Bestellung. */
nummer: number
/** Erster Tag, einschliesslich. */
von: Date
/** Letzter Tag, einschliesslich. */
bis: Date
/** "1. Betreuungsjahr (15.03.2025 – 14.03.2026)" - fuer Auswahllisten. */
bezeichnung: string
}
function formatKurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
function bauen(nummer: number, von: Date, bis: Date): Betreuungsjahr {
return {
nummer,
von,
bis,
bezeichnung: `${nummer}. Betreuungsjahr (${formatKurz(von)} – ${formatKurz(bis)})`,
}
}
/**
* Das n-te Betreuungsjahr. nummer ist 1-basiert; 1 ist das Jahr, das am
* Tag der Bestellung beginnt.
*/
export function betreuungsjahr(bestellungAm: Date, nummer: number): Betreuungsjahr {
if (!Number.isInteger(nummer) || nummer < 1) {
throw new Error('Das Betreuungsjahr wird ab 1 gezählt.')
}
const start = alsTag(bestellungAm)
const von = jahrePlus(start, nummer - 1)
const bis = tagePlus(jahrePlus(start, nummer), -1)
return bauen(nummer, von, bis)
}
/**
* In welches Betreuungsjahr faellt ein Datum? Null, wenn es vor der
* Bestellung liegt - dafuer gibt es keine Rechnungslegung.
*/
export function betreuungsjahrZuDatum(bestellungAm: Date, datum: Date): Betreuungsjahr | null {
const start = alsTag(bestellungAm)
const ziel = alsTag(datum)
if (ziel.getTime() < start.getTime()) return null
// Grobe Schaetzung ueber die Jahresdifferenz, danach hoechstens zwei
// Korrekturschritte - das haelt auch den Schaltjahrfall aus.
let nummer = ziel.getUTCFullYear() - start.getUTCFullYear() + 1
if (nummer < 1) nummer = 1
for (let versuch = 0; versuch < 4; versuch++) {
const jahr = betreuungsjahr(start, nummer)
if (ziel.getTime() < jahr.von.getTime()) {
nummer -= 1
if (nummer < 1) return null
continue
}
if (ziel.getTime() > jahr.bis.getTime()) {
nummer += 1
continue
}
return jahr
}
return null
}
/** Das Betreuungsjahr, in dem wir heute stehen. */
export function laufendesBetreuungsjahr(
bestellungAm: Date,
stichtag: Date = heute(),
): Betreuungsjahr | null {
return betreuungsjahrZuDatum(bestellungAm, stichtag)
}
/**
* Alle Betreuungsjahre von der Bestellung bis einschliesslich des Jahres,
* in dem "bis" liegt. Fuer die Auswahlliste im Berichtsformular - das
* laufende Jahr steht mit dabei, denn eine Zwischenaufstellung ist
* jederzeit erlaubt.
*/
export function betreuungsjahre(bestellungAm: Date, bis: Date = heute()): Betreuungsjahr[] {
const letztes = betreuungsjahrZuDatum(bestellungAm, bis)
if (!letztes) return []
const liste: Betreuungsjahr[] = []
for (let nummer = 1; nummer <= letztes.nummer; nummer++) {
liste.push(betreuungsjahr(bestellungAm, nummer))
}
return liste
}
/**
* Ist das Betreuungsjahr abgeschlossen? Nur dann ist die Rechnungslegung
* faellig; vorher ist der Bericht eine Zwischenaufstellung.
*/
export function jahrAbgeschlossen(jahr: Betreuungsjahr, stichtag: Date = heute()): boolean {
return vorOderGleich(jahr.bis, stichtag)
}
/**
* Ende der Ausschlussfrist fuer die Aufwandspauschale nach § 1878 BGB:
* sechs Monate nach dem Ende des Betreuungsjahres. Danach ist der
* Anspruch weg - kein Ermessen, keine Nachsicht.
*/
export function aufwandspauschaleBis(jahr: Betreuungsjahr): Date {
return monatePlus(jahr.bis, 6)
}
+357
View File
@@ -0,0 +1,357 @@
import type { BuchungRichtung } from '@prisma/client'
import { ImportFehler, importReferenzBilden, type ImportZeile } from '@/lib/finanzen/import'
/**
* CAMT.053 (Kontoauszug im ISO-20022-Format).
*
* Fast jede Bank bietet den elektronischen Kontoauszug neben CSV auch
* als CAMT-XML an. Der Vorteil: Betrag, Waehrung, Richtung und Datum
* sind dort eindeutig ausgezeichnet - es gibt kein "ist der Punkt jetzt
* ein Tausendertrennzeichen?" mehr. Deshalb ist dieser Weg der genauere.
*
* Gelesen wird bewusst nur ein kleiner, klar umrissener Ausschnitt:
* die <Ntry>-Elemente eines <Stmt>. Alles andere - Zwischensalden,
* Gebuehrendetails, strukturierte Referenzen - wird uebergangen.
*
* Der XML-Leser unten ist absichtlich klein gehalten und kennt keine
* Entity-Deklarationen und keine externen Referenzen. Das ist keine
* Bequemlichkeit, sondern Absicht: ein XML-Parser mit Entity-Aufloesung
* ist die klassische Stelle fuer XXE-Angriffe, und eine Kontoauszugs-
* datei kommt hier aus einem Download, den niemand geprueft hat.
*/
export type XmlKnoten = {
name: string
attribute: Record<string, string>
kinder: XmlKnoten[]
text: string
}
const ENTITAETEN: Record<string, string> = {
amp: '&',
lt: '<',
gt: '>',
quot: '"',
apos: "'",
}
function entitaetenAufloesen(text: string): string {
return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (treffer, name: string) => {
if (name.startsWith('#x') || name.startsWith('#X')) {
const code = Number.parseInt(name.slice(2), 16)
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
}
if (name.startsWith('#')) {
const code = Number.parseInt(name.slice(1), 10)
return Number.isFinite(code) ? String.fromCodePoint(code) : treffer
}
return ENTITAETEN[name] ?? treffer
})
}
/** Namensraumpraefix abschneiden: "ns:Ntry" wird zu "Ntry". */
function ortsname(name: string): string {
const doppelpunkt = name.indexOf(':')
return doppelpunkt >= 0 ? name.slice(doppelpunkt + 1) : name
}
function attributeLesen(roh: string): Record<string, string> {
const attribute: Record<string, string> = {}
const muster = /([A-Za-z_][\w.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(roh)) !== null) {
attribute[ortsname(treffer[1]!)] = entitaetenAufloesen(treffer[3] ?? treffer[4] ?? '')
}
return attribute
}
/**
* Minimaler XML-Leser. Kennt Elemente, Attribute, Text und CDATA.
* Prolog, Kommentare und Verarbeitungsanweisungen werden uebersprungen,
* Doctype-Deklarationen ausdruecklich abgelehnt.
*/
export function xmlLesen(quelle: string): XmlKnoten {
const text = quelle.charCodeAt(0) === 0xfeff ? quelle.slice(1) : quelle
if (/<!DOCTYPE/i.test(text)) {
throw new ImportFehler(
'Die Datei enthält eine DOCTYPE-Deklaration. Solche Dateien werden aus Sicherheitsgründen ' +
'nicht gelesen. Bitte den Kontoauszug erneut ohne Zusätze herunterladen.',
)
}
const wurzel: XmlKnoten = { name: '#dokument', attribute: {}, kinder: [], text: '' }
const stapel: XmlKnoten[] = [wurzel]
let i = 0
while (i < text.length) {
const start = text.indexOf('<', i)
if (start < 0) break
if (start > i) {
const inhalt = entitaetenAufloesen(text.slice(i, start))
const aktuell = stapel[stapel.length - 1]!
if (inhalt.trim() !== '') aktuell.text += inhalt
}
if (text.startsWith('<!--', start)) {
const ende = text.indexOf('-->', start)
if (ende < 0) break
i = ende + 3
continue
}
if (text.startsWith('<![CDATA[', start)) {
const ende = text.indexOf(']]>', start)
if (ende < 0) break
stapel[stapel.length - 1]!.text += text.slice(start + 9, ende)
i = ende + 3
continue
}
if (text.startsWith('<?', start)) {
const ende = text.indexOf('?>', start)
if (ende < 0) break
i = ende + 2
continue
}
const ende = text.indexOf('>', start)
if (ende < 0) break
const roh = text.slice(start + 1, ende)
i = ende + 1
if (roh.startsWith('/')) {
if (stapel.length > 1) stapel.pop()
continue
}
const selbstschliessend = roh.endsWith('/')
const koerper = selbstschliessend ? roh.slice(0, -1) : roh
const leerzeichen = koerper.search(/\s/)
const name = ortsname(leerzeichen < 0 ? koerper : koerper.slice(0, leerzeichen))
const attribute = leerzeichen < 0 ? {} : attributeLesen(koerper.slice(leerzeichen))
const knoten: XmlKnoten = { name, attribute, kinder: [], text: '' }
stapel[stapel.length - 1]!.kinder.push(knoten)
if (!selbstschliessend) stapel.push(knoten)
}
return wurzel
}
/** Erstes Kind mit diesem Namen, beliebig tief. */
export function knotenSuchen(knoten: XmlKnoten, name: string): XmlKnoten | null {
for (const kind of knoten.kinder) {
if (kind.name === name) return kind
const tiefer = knotenSuchen(kind, name)
if (tiefer) return tiefer
}
return null
}
/** Alle Knoten mit diesem Namen, beliebig tief. */
export function alleKnoten(knoten: XmlKnoten, name: string): XmlKnoten[] {
const gefunden: XmlKnoten[] = []
for (const kind of knoten.kinder) {
if (kind.name === name) gefunden.push(kind)
gefunden.push(...alleKnoten(kind, name))
}
return gefunden
}
function textVon(knoten: XmlKnoten | null): string | null {
if (!knoten) return null
const wert = knoten.text.replace(/\s+/g, ' ').trim()
return wert === '' ? null : wert
}
/**
* Betraege in ISO-20022 sind eindeutig: Punkt als Dezimaltrennzeichen,
* kein Tausendertrennzeichen, hoechstens fuenf Nachkommastellen. Fuer
* Euro erwarten wir zwei - mehr waere ein Bruchteil eines Cents und
* gehoert nicht in ein Kassenbuch.
*/
export function dezimalZuCent(text: string): bigint {
const bereinigt = text.trim()
const treffer = /^(-?)(\d+)(?:\.(\d+))?$/.exec(bereinigt)
if (!treffer) {
throw new ImportFehler(`"${text}" ist kein gültiger Betrag im CAMT-Format.`)
}
const bruch = treffer[3] ?? ''
if (bruch.replace(/0+$/, '').length > 2) {
throw new ImportFehler(
`"${text}" hat mehr als zwei Nachkommastellen. Bruchteile eines Cents sind nicht buchbar.`,
)
}
const cent = BigInt(treffer[2]!) * 100n + BigInt(bruch.padEnd(2, '0').slice(0, 2) || '0')
return treffer[1] === '-' ? -cent : cent
}
export type CamtErgebnis = {
/** IBAN des Kontos aus dem Auszug - hilft beim Zuordnen. */
iban: string | null
/** Kontowaehrung, sofern angegeben. */
waehrung: string | null
zeilen: ImportZeile[]
fehler: { zeilennummer: number; meldung: string; rohzeile: string[] }[]
fremdwaehrung: number
}
/**
* Liest die Umsaetze aus einer CAMT.053-Datei.
*
* Die Zeilennummer ist hier die laufende Nummer des <Ntry>-Elements -
* eine Zeilennummer im Sinne einer Textdatei gibt es bei XML nicht.
*/
export function camtZuBuchungen(inhalt: Buffer | Uint8Array | string): CamtErgebnis {
const quelle =
typeof inhalt === 'string'
? inhalt
: Buffer.isBuffer(inhalt)
? inhalt.toString('utf8')
: Buffer.from(inhalt).toString('utf8')
const dokument = xmlLesen(quelle)
const auszug = knotenSuchen(dokument, 'Stmt')
if (!auszug) {
throw new ImportFehler(
'In der Datei ist kein Kontoauszug (<Stmt>) zu finden. Erwartet wird CAMT.053.',
)
}
const konto = knotenSuchen(auszug, 'Acct')
const iban = konto ? textVon(knotenSuchen(konto, 'IBAN')) : null
const waehrung = konto ? textVon(knotenSuchen(konto, 'Ccy')) : null
const zeilen: ImportZeile[] = []
const fehler: CamtErgebnis['fehler'] = []
let fremdwaehrung = 0
const schluesselZaehler = new Map<string, number>()
const eintraege = alleKnoten(auszug, 'Ntry')
eintraege.forEach((eintrag, i) => {
const nummer = i + 1
const betragKnoten = eintrag.kinder.find((k) => k.name === 'Amt') ?? knotenSuchen(eintrag, 'Amt')
const betragText = textVon(betragKnoten)
if (!betragText) {
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt der Betrag.', rohzeile: [] })
return
}
const ccy = betragKnoten?.attribute['Ccy'] ?? waehrung
if (ccy && ccy.toUpperCase() !== 'EUR') {
fremdwaehrung++
fehler.push({
zeilennummer: nummer,
meldung: `Währung "${ccy}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
rohzeile: [],
})
return
}
let betragCent: bigint
try {
betragCent = dezimalZuCent(betragText)
} catch (ausnahme) {
fehler.push({
zeilennummer: nummer,
meldung: ausnahme instanceof Error ? ausnahme.message : 'Der Betrag ist nicht lesbar.',
rohzeile: [],
})
return
}
if (betragCent < 0n) betragCent = -betragCent
if (betragCent === 0n) {
fehler.push({
zeilennummer: nummer,
meldung: 'Der Betrag ist 0,00 € – der Umsatz wurde übersprungen.',
rohzeile: [],
})
return
}
const kennzeichen = textVon(
eintrag.kinder.find((k) => k.name === 'CdtDbtInd') ?? knotenSuchen(eintrag, 'CdtDbtInd'),
)
if (kennzeichen !== 'CRDT' && kennzeichen !== 'DBIT') {
fehler.push({
zeilennummer: nummer,
meldung: 'Im Umsatz fehlt die Angabe, ob es eine Gutschrift oder eine Belastung ist.',
rohzeile: [],
})
return
}
const richtung: BuchungRichtung = kennzeichen === 'CRDT' ? 'EINNAHME' : 'AUSGABE'
const buchungsdatum = datumAusKnoten(eintrag, 'BookgDt')
const valuta = datumAusKnoten(eintrag, 'ValDt')
const datum = buchungsdatum ?? valuta
if (!datum) {
fehler.push({ zeilennummer: nummer, meldung: 'Im Umsatz fehlt das Buchungsdatum.', rohzeile: [] })
return
}
const gegenstelle = gegenstelleLesen(eintrag, richtung)
const zweckTeile = alleKnoten(eintrag, 'Ustrd')
.map((k) => textVon(k))
.filter((t): t is string => t !== null)
const zusatz = textVon(knotenSuchen(eintrag, 'AddtlNtryInf'))
const verwendungszweck =
zweckTeile.length > 0 ? zweckTeile.join(' ') : zusatz
const grundschluessel = importReferenzBilden({
datum,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
}).replace(/^csv:/, 'camt:')
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
schluesselZaehler.set(grundschluessel, bisher + 1)
zeilen.push({
zeilennummer: nummer,
datum,
wertstellung: valuta,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
importReferenz: bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`,
dublette: false,
})
})
return { iban, waehrung, zeilen, fehler, fremdwaehrung }
}
function datumAusKnoten(eintrag: XmlKnoten, name: string): Date | null {
const knoten = knotenSuchen(eintrag, name)
if (!knoten) return null
const text = textVon(knotenSuchen(knoten, 'Dt')) ?? textVon(knotenSuchen(knoten, 'DtTm'))
if (!text) return null
const treffer = /^(\d{4})-(\d{2})-(\d{2})/.exec(text)
if (!treffer) return null
return new Date(
Date.UTC(
Number.parseInt(treffer[1]!, 10),
Number.parseInt(treffer[2]!, 10) - 1,
Number.parseInt(treffer[3]!, 10),
),
)
}
/**
* Bei einer Gutschrift ist die Gegenstelle der Auftraggeber (Dbtr),
* bei einer Belastung der Empfaenger (Cdtr). Wer das vertauscht, hat im
* Kassenbuch ueberall den eigenen Namen stehen.
*/
function gegenstelleLesen(eintrag: XmlKnoten, richtung: BuchungRichtung): string | null {
const parteien = knotenSuchen(eintrag, 'RltdPties')
if (!parteien) return null
const gesucht = richtung === 'EINNAHME' ? 'Dbtr' : 'Cdtr'
const partei = knotenSuchen(parteien, gesucht)
return partei ? textVon(knotenSuchen(partei, 'Nm')) : null
}
+100
View File
@@ -0,0 +1,100 @@
import { addDays, addMonths, addYears, differenceInCalendarDays } from 'date-fns'
/**
* Tagesdatum-Rechnung fuer die @db.Date-Felder des Finanzmoduls.
*
* Stichtage, Buchungsdaten und Bestellungsdatum sind reine Kalendertage
* ohne Uhrzeit. Prisma liefert sie als Date auf UTC-Mitternacht. Rechnet
* man darauf mit den lokalen date-fns-Funktionen, verschiebt sich in
* jeder Zeitzone westlich von Greenwich der Tag - und eine Frist, ein
* Betreuungsjahr oder ein Stichtag waere um einen Tag falsch.
*
* Deshalb laeuft jede Rechnung ueber einen "Spiegel": das Datum wird auf
* die lokale Mittagszeit desselben Kalendertages gelegt, dort mit
* date-fns gerechnet (so bleibt die Konvention "Datumsrechnung immer ueber
* date-fns" gewahrt) und danach wieder auf UTC-Mitternacht zurueckgelegt.
* Mittag statt Mitternacht, weil es Zeitzonen gibt, in denen die
* Sommerzeit um 00:00 umgestellt wird und die lokale Mitternacht dann
* gar nicht existiert.
*/
function inSpiegel(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 12, 0, 0, 0)
}
function ausSpiegel(datum: Date): Date {
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate()))
}
/** Schneidet eine eventuelle Uhrzeit ab: UTC-Mitternacht desselben Tages. */
export function alsTag(datum: Date): Date {
return new Date(Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate()))
}
/** Der heutige Kalendertag - gelesen aus der lokalen Uhr des Servers. */
export function heute(): Date {
const jetzt = new Date()
return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate()))
}
/** "2026-03-15" aus einem Datumsfeld des Formulars. Wirft bei Unsinn nicht. */
export function tagAusText(text: string | null | undefined): Date | null {
if (!text) return null
const treffer = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
if (!treffer) return null
const jahr = Number.parseInt(treffer[1]!, 10)
const monat = Number.parseInt(treffer[2]!, 10)
const tag = Number.parseInt(treffer[3]!, 10)
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
// 31.02. faengt die Rueckrechnung ab.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
return datum
}
/** Gegenstueck fuer den value eines <input type="date">. */
export function tagAlsText(datum: Date | null | undefined): string {
if (!datum) return ''
const jahr = String(datum.getUTCFullYear()).padStart(4, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
const tag = String(datum.getUTCDate()).padStart(2, '0')
return `${jahr}-${monat}-${tag}`
}
export function tagePlus(datum: Date, tage: number): Date {
return ausSpiegel(addDays(inSpiegel(datum), tage))
}
export function monatePlus(datum: Date, monate: number): Date {
return ausSpiegel(addMonths(inSpiegel(datum), monate))
}
export function jahrePlus(datum: Date, jahre: number): Date {
return ausSpiegel(addYears(inSpiegel(datum), jahre))
}
/** Ganze Kalendertage zwischen zwei Tagesdaten. Negativ, wenn b vor a liegt. */
export function tageZwischen(a: Date, b: Date): number {
return differenceInCalendarDays(inSpiegel(b), inSpiegel(a))
}
export function vorOderGleich(a: Date, b: Date): boolean {
return alsTag(a).getTime() <= alsTag(b).getTime()
}
export function nachOderGleich(a: Date, b: Date): boolean {
return alsTag(a).getTime() >= alsTag(b).getTime()
}
/** Beide Grenzen einschliesslich. */
export function imZeitraum(datum: Date, von: Date, bis: Date): boolean {
const t = alsTag(datum).getTime()
return t >= alsTag(von).getTime() && t <= alsTag(bis).getTime()
}
/** Aeltestes zuerst. */
export function nachTagSortieren<T>(werte: T[], schluessel: (wert: T) => Date): T[] {
return [...werte].sort(
(a, b) => alsTag(schluessel(a)).getTime() - alsTag(schluessel(b)).getTime(),
)
}
+149
View File
@@ -0,0 +1,149 @@
/**
* IBAN-Pruefung nach ISO 13616 - selbst gerechnet, ohne Paket.
*
* Warum ueberhaupt pruefen? Weil eine falsch abgetippte IBAN im
* Vermoegensverzeichnis beim Gericht landet und weil der Blindindex
* (siehe blindindex() in src/lib/crypto/hash.ts) nur dann als "kenne ich
* dieses Konto schon?" taugt, wenn die IBAN vorher normalisiert wurde.
* Ein Leerzeichen zu viel wuerde sonst ein zweites, scheinbar neues
* Konto erzeugen.
*
* Das Verfahren: die ersten vier Zeichen (Laendercode + Pruefziffer) ans
* Ende stellen, jeden Buchstaben durch seine Position im Alphabet + 9
* ersetzen (A=10 ... Z=35) und den entstandenen Riesen-Zahlstring modulo
* 97 rechnen. Ergebnis muss 1 sein.
*/
/**
* Laenge der IBAN je Land. Ohne diese Tabelle wuerde eine zu kurze oder
* zu lange IBAN durchrutschen, wenn die Pruefziffer zufaellig passt.
* Quelle: IBAN-Registry des SWIFT (Stand 2024).
*/
export const IBAN_LAENGEN: Record<string, number> = {
AD: 24, AE: 23, AL: 28, AT: 20, AZ: 28, BA: 20, BE: 16, BG: 22, BH: 22, BI: 27,
BR: 29, BY: 28, CH: 21, CR: 22, CY: 28, CZ: 24, DE: 22, DJ: 27, DK: 18, DO: 28,
EE: 20, EG: 29, ES: 24, FI: 18, FO: 18, FR: 27, GB: 22, GE: 22, GI: 23, GL: 18,
GR: 27, GT: 28, HR: 21, HU: 28, IE: 22, IL: 23, IQ: 23, IS: 26, IT: 27, JO: 30,
KW: 30, KZ: 20, LB: 28, LC: 32, LI: 21, LT: 20, LU: 20, LV: 21, LY: 25, MC: 27,
MD: 24, ME: 22, MK: 19, MR: 27, MT: 31, MU: 30, NL: 18, NO: 15, PK: 24, PL: 28,
PS: 29, PT: 25, QA: 29, RO: 24, RS: 22, RU: 33, SA: 24, SC: 31, SD: 18, SE: 24,
SI: 19, SK: 24, SM: 27, SO: 23, ST: 25, SV: 28, TL: 23, TN: 24, TR: 26, UA: 29,
VA: 22, VG: 24, XK: 20,
}
/** Grossbuchstaben, keine Leerzeichen - so und nicht anders wird gespeichert. */
export function ibanNormalisieren(iban: string | null | undefined): string {
if (!iban) return ''
return iban.replace(/[\s  -]/g, '').toUpperCase()
}
/** Vierergruppen, wie sie auf jedem Kontoauszug stehen. */
export function ibanFormatieren(iban: string | null | undefined): string {
const roh = ibanNormalisieren(iban)
if (roh === '') return ''
return roh.replace(/(.{4})/g, '$1 ').trim()
}
/**
* Fuer Listen und Berichte: DE12 **** **** 3456. Die vollstaendige IBAN
* steht nur dort, wo sie gebraucht wird - im Vermoegensverzeichnis und
* im Kontodetail.
*/
export function ibanMaskieren(iban: string | null | undefined): string {
const roh = ibanNormalisieren(iban)
if (roh.length < 8) return roh
const anfang = roh.slice(0, 4)
const ende = roh.slice(-4)
const versteckt = '*'.repeat(Math.max(0, roh.length - 8))
return ibanFormatieren(`${anfang}${versteckt}${ende}`)
}
/** Modulo 97 ueber den Zahlstring, ziffernweise - kein BigInt noetig. */
function modulo97(zahlstring: string): number {
let rest = 0
for (const zeichen of zahlstring) {
rest = (rest * 10 + (zeichen.charCodeAt(0) - 48)) % 97
}
return rest
}
export type IbanPruefung =
| { gueltig: true; iban: string; land: string; formatiert: string }
| { gueltig: false; meldung: string }
/**
* Prueft eine IBAN vollstaendig: Zeichenvorrat, Laenderlaenge und
* Pruefziffer. Die Meldungen sagen, was zu tun ist, nicht was falsch war.
*/
export function ibanPruefen(eingabe: string | null | undefined): IbanPruefung {
const iban = ibanNormalisieren(eingabe)
if (iban === '') {
return { gueltig: false, meldung: 'Bitte eine IBAN angeben.' }
}
if (!/^[A-Z0-9]+$/.test(iban)) {
return {
gueltig: false,
meldung: 'Die IBAN darf nur Buchstaben und Ziffern enthalten.',
}
}
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(iban)) {
return {
gueltig: false,
meldung: 'Eine IBAN beginnt mit zwei Buchstaben für das Land und zwei Prüfziffern, z.B. DE89.',
}
}
const land = iban.slice(0, 2)
const erwartet = IBAN_LAENGEN[land]
if (erwartet === undefined) {
return {
gueltig: false,
meldung: `Das Länderkürzel "${land}" kenne ich nicht. Bitte die IBAN prüfen.`,
}
}
if (iban.length !== erwartet) {
return {
gueltig: false,
meldung: `Eine ${land}-IBAN hat ${erwartet} Zeichen, diese hat ${iban.length}.`,
}
}
const umgestellt = iban.slice(4) + iban.slice(0, 4)
let zahlstring = ''
for (const zeichen of umgestellt) {
const code = zeichen.charCodeAt(0)
zahlstring += code >= 65 ? String(code - 55) : zeichen
}
if (modulo97(zahlstring) !== 1) {
return {
gueltig: false,
meldung: 'Die Prüfziffer stimmt nicht. Vermutlich ein Zahlendreher – bitte noch einmal vergleichen.',
}
}
return { gueltig: true, iban, land, formatiert: ibanFormatieren(iban) }
}
/** Kurzform fuer Bedingungen. */
export function ibanGueltig(eingabe: string | null | undefined): boolean {
return ibanPruefen(eingabe).gueltig
}
/**
* BIC nach ISO 9362: 8 oder 11 Stellen. Wird nur geprueft, nicht
* erzwungen - fuer eine Barkasse oder ein Verwahrgeldkonto im Heim gibt
* es keinen BIC.
*/
export function bicGueltig(eingabe: string | null | undefined): boolean {
if (!eingabe || eingabe.trim() === '') return true
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
return /^[A-Z]{6}[A-Z0-9]{2}([A-Z0-9]{3})?$/.test(bic)
}
export function bicNormalisieren(eingabe: string | null | undefined): string | null {
if (!eingabe) return null
const bic = eingabe.replace(/\s+/g, '').toUpperCase()
return bic === '' ? null : bic
}
+572
View File
@@ -0,0 +1,572 @@
import { createHash } from 'node:crypto'
import type { BuchungRichtung } from '@prisma/client'
import { betragParsen, BetragFehler } from '@/lib/finanzen/betrag'
import { tagAlsText } from '@/lib/finanzen/datum'
/**
* Import von Kontoumsaetzen aus CSV.
*
* Kontoauszuege abzutippen ist die Stelle, an der in der Rechnungslegung
* die Fehler entstehen - ein Zahlendreher pro hundert Zeilen reicht, und
* die Rechenprobe geht nicht mehr auf. Deshalb dieser Import.
*
* Er macht bewusst keine Annahmen ueber "das Format der Sparkasse" oder
* "das Format der Volksbank". Stattdessen wird erkannt:
*
* - das Trennzeichen (Semikolon, Komma, Tabulator, senkrechter Strich),
* - die Kodierung (UTF-8 oder Windows-1252/ISO-8859-1),
* - die Spalten anhand der Ueberschriften,
* - deutsche Zahlen ("1.234,56", auch mit nachgestelltem Minus),
* - die ueblichen Datumsformate.
*
* Nichts wird stillschweigend geraten: was nicht erkannt wird, kommt als
* Fehlerzeile zurueck und wird dem Nutzer gezeigt.
*
* Dublettenschutz laeuft ueber importReferenz. Der Schluessel ist ein
* Hash ueber Datum, Betrag, Gegenstelle und Verwendungszweck; kommt
* dieselbe Kombination in einer Datei mehrfach vor - zwei gleiche
* Barabhebungen am selben Tag sind voellig normal -, bekommt jede
* Wiederholung eine laufende Nummer angehaengt. So werden echte
* Doppelbuchungen erkannt, ohne echte Wiederholungen zu verschlucken.
*/
export type Trennzeichen = ';' | ',' | '\t' | '|'
const TRENNZEICHEN_KANDIDATEN: Trennzeichen[] = [';', ',', '\t', '|']
export type Spaltenzuordnung = {
datum: number
wertstellung: number | null
betrag: number | null
/** Getrennte Soll-/Haben-Spalten, wie sie manche Ausleitungen haben. */
soll: number | null
haben: number | null
/** Spalte mit "S"/"H" bzw. "Soll"/"Haben". */
sollHaben: number | null
gegenstelle: number | null
verwendungszweck: number | null
buchungstext: number | null
waehrung: number | null
}
export type CsvErkennung = {
trennzeichen: Trennzeichen
kopfzeile: string[]
zuordnung: Spaltenzuordnung
datenzeilen: string[][]
/** Zeilennummer der Kopfzeile in der Datei, ab 1 gezaehlt. */
kopfzeileNummer: number
}
export class ImportFehler extends Error {
constructor(nachricht: string) {
super(nachricht)
this.name = 'ImportFehler'
}
}
// -----------------------------------------------------------------------------
// Kodierung und Zerlegung
// -----------------------------------------------------------------------------
/**
* Liest den Dateiinhalt als Text. Viele Banken liefern bis heute
* Windows-1252; kommt beim UTF-8-Versuch ein Ersatzzeichen heraus, wird
* auf latin1 zurueckgeschaltet - dann stimmen die Umlaute wieder.
*/
export function csvTextLesen(inhalt: Buffer | Uint8Array | string): string {
if (typeof inhalt === 'string') return bomEntfernen(inhalt)
const puffer = Buffer.isBuffer(inhalt) ? inhalt : Buffer.from(inhalt)
const alsUtf8 = puffer.toString('utf8')
if (alsUtf8.includes('�')) return bomEntfernen(puffer.toString('latin1'))
return bomEntfernen(alsUtf8)
}
function bomEntfernen(text: string): string {
return text.charCodeAt(0) === 0xfeff ? text.slice(1) : text
}
/**
* Zerlegt CSV nach RFC 4180: Anfuehrungszeichen schuetzen Trennzeichen
* und Zeilenumbrueche, ein doppeltes Anfuehrungszeichen im Feld steht
* fuer ein einzelnes.
*/
export function csvZerlegen(text: string, trennzeichen: Trennzeichen): string[][] {
const zeilen: string[][] = []
let zeile: string[] = []
let feld = ''
let inAnfuehrung = false
for (let i = 0; i < text.length; i++) {
const zeichen = text[i]!
if (inAnfuehrung) {
if (zeichen === '"') {
if (text[i + 1] === '"') {
feld += '"'
i++
} else {
inAnfuehrung = false
}
} else {
feld += zeichen
}
continue
}
if (zeichen === '"' && feld === '') {
inAnfuehrung = true
} else if (zeichen === trennzeichen) {
zeile.push(feld)
feld = ''
} else if (zeichen === '\n' || zeichen === '\r') {
if (zeichen === '\r' && text[i + 1] === '\n') i++
zeile.push(feld)
feld = ''
zeilen.push(zeile)
zeile = []
} else {
feld += zeichen
}
}
if (feld !== '' || zeile.length > 0) {
zeile.push(feld)
zeilen.push(zeile)
}
return zeilen.filter((z) => z.some((f) => f.trim() !== ''))
}
/**
* Welches Trennzeichen? Gewaehlt wird das, bei dem die meisten Zeilen
* dieselbe Spaltenzahl haben - das ist verlaesslicher als blosses
* Zaehlen, weil in Verwendungszwecken oft Kommas stehen.
*/
export function trennzeichenErkennen(text: string): Trennzeichen {
let bestes: Trennzeichen = ';'
let bewertung = -1
for (const kandidat of TRENNZEICHEN_KANDIDATEN) {
const zeilen = csvZerlegen(text, kandidat).slice(0, 20)
if (zeilen.length === 0) continue
const spalten = zeilen.map((z) => z.length)
const haeufigste = spalten
.sort((a, b) => a - b)
[Math.floor(spalten.length / 2)]!
if (haeufigste < 2) continue
const passend = zeilen.filter((z) => z.length === haeufigste).length
const punkte = haeufigste * 100 + passend
if (punkte > bewertung) {
bewertung = punkte
bestes = kandidat
}
}
return bestes
}
// -----------------------------------------------------------------------------
// Spaltenerkennung
// -----------------------------------------------------------------------------
function normalisieren(text: string): string {
return text
.toLowerCase()
.replace(/ä/g, 'ae')
.replace(/ö/g, 'oe')
.replace(/ü/g, 'ue')
.replace(/ß/g, 'ss')
.replace(/[^a-z0-9]/g, '')
}
/** Reihenfolge zaehlt: der erste Treffer gewinnt. */
const SPALTEN_MUSTER: { feld: keyof Spaltenzuordnung; begriffe: string[] }[] = [
{ feld: 'wertstellung', begriffe: ['wertstellung', 'valutadatum', 'valuta', 'wertstellungsdatum'] },
{ feld: 'datum', begriffe: ['buchungstag', 'buchungsdatum', 'belegdatum', 'datum', 'bookingdate'] },
{ feld: 'soll', begriffe: ['soll', 'debit', 'ausgang', 'ausgabe', 'lastschrift'] },
{ feld: 'haben', begriffe: ['haben', 'credit', 'eingang', 'einnahme', 'gutschrift'] },
{ feld: 'sollHaben', begriffe: ['sollhaben', 'sh', 'sollhabenkennzeichen', 'debitcredit'] },
{
feld: 'betrag',
begriffe: ['betrag', 'umsatz', 'amount', 'betrageur', 'betragineur', 'umsatzineur'],
},
{
feld: 'gegenstelle',
begriffe: [
'beguenstigterzahlungspflichtiger',
'beguenstigter',
'zahlungspflichtiger',
'auftraggeber',
'empfaenger',
'zahlungsempfaenger',
'name',
'kontoinhaber',
'partnername',
],
},
{
feld: 'verwendungszweck',
begriffe: ['verwendungszweck', 'vorgangverwendungszweck', 'zweck', 'referenz', 'remittanceinformation'],
},
{ feld: 'buchungstext', begriffe: ['buchungstext', 'vorgang', 'umsatzart', 'transaktionsart'] },
{ feld: 'waehrung', begriffe: ['waehrung', 'currency', 'waehrungskennzeichen'] },
]
function spaltenZuordnen(kopf: string[]): Spaltenzuordnung {
const normalisiert = kopf.map((k) => normalisieren(k))
const belegt = new Set<number>()
const treffer: Partial<Record<keyof Spaltenzuordnung, number>> = {}
/**
* Zwei Durchlaeufe: erst exakte Ueberschriften, dann Teiltreffer. Ohne
* diese Reihenfolge wuerde "Soll/Haben" als Betragsspalte "Soll"
* gelesen - und jede Gutschrift waere eine Ausgabe.
*/
const suchen = (passt: (ueberschrift: string, begriff: string) => boolean) => {
for (const muster of SPALTEN_MUSTER) {
if (treffer[muster.feld] !== undefined) continue
for (const begriff of muster.begriffe) {
const index = normalisiert.findIndex(
(k, i) => !belegt.has(i) && k !== '' && passt(k, begriff),
)
if (index >= 0) {
treffer[muster.feld] = index
belegt.add(index)
break
}
}
}
}
suchen((k, begriff) => k === begriff)
// Kurze Begriffe wie "sh" oder "name" nur exakt - sonst treffen sie
// in halben Ueberschriften mit.
suchen((k, begriff) => begriff.length >= 5 && k.includes(begriff))
// Gibt es nur eine Valuta-Spalte und kein Buchungsdatum, ist die
// Valuta das Datum. Besser eine belegte Spalte als ein Abbruch.
if (treffer.datum === undefined && treffer.wertstellung !== undefined) {
treffer.datum = treffer.wertstellung
treffer.wertstellung = undefined
}
return {
datum: treffer.datum ?? -1,
wertstellung: treffer.wertstellung ?? null,
betrag: treffer.betrag ?? null,
soll: treffer.soll ?? null,
haben: treffer.haben ?? null,
sollHaben: treffer.sollHaben ?? null,
gegenstelle: treffer.gegenstelle ?? null,
verwendungszweck: treffer.verwendungszweck ?? null,
buchungstext: treffer.buchungstext ?? null,
waehrung: treffer.waehrung ?? null,
}
}
/**
* Findet die Kopfzeile. Manche Ausleitungen stellen der Tabelle noch
* zwei, drei Zeilen mit Kontonummer und Zeitraum voran - deshalb wird
* nicht blind die erste Zeile genommen, sondern die erste, in der sich
* eine Datums- und eine Betragsspalte erkennen laesst.
*/
export function csvErkennen(inhalt: Buffer | Uint8Array | string): CsvErkennung {
const text = csvTextLesen(inhalt)
if (text.trim() === '') throw new ImportFehler('Die Datei ist leer.')
const trennzeichen = trennzeichenErkennen(text)
const zeilen = csvZerlegen(text, trennzeichen)
if (zeilen.length === 0) throw new ImportFehler('Die Datei enthält keine lesbaren Zeilen.')
for (let i = 0; i < Math.min(zeilen.length, 10); i++) {
const kopf = zeilen[i]!
const zuordnung = spaltenZuordnen(kopf)
const hatBetrag =
zuordnung.betrag !== null || zuordnung.soll !== null || zuordnung.haben !== null
if (zuordnung.datum >= 0 && hatBetrag) {
return {
trennzeichen,
kopfzeile: kopf,
zuordnung,
datenzeilen: zeilen.slice(i + 1),
kopfzeileNummer: i + 1,
}
}
}
throw new ImportFehler(
'In der Datei ist keine Kopfzeile mit einer Datums- und einer Betragsspalte zu finden. ' +
'Bitte die Datei aus dem Online-Banking noch einmal als CSV mit Spaltenüberschriften ausgeben.',
)
}
// -----------------------------------------------------------------------------
// Datum
// -----------------------------------------------------------------------------
/**
* Liest die Datumsformate, die in Bankausleitungen vorkommen.
* Zweistellige Jahre: 00-79 heisst 20xx, 80-99 heisst 19xx - vor 1980
* gab es keine CSV-Ausleitung, und ein Kontoauszug von 2079 kommt fuer
* diese Anwendung nicht mehr in Betracht.
*/
export function datumParsen(eingabe: string | null | undefined): Date | null {
if (!eingabe) return null
const text = eingabe.trim()
if (text === '') return null
let jahr: number
let monat: number
let tag: number
const deutsch = /^(\d{1,2})[.\/-](\d{1,2})[.\/-](\d{2}|\d{4})$/.exec(text)
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T ].*)?$/.exec(text)
if (deutsch) {
tag = Number.parseInt(deutsch[1]!, 10)
monat = Number.parseInt(deutsch[2]!, 10)
const jahrText = deutsch[3]!
jahr =
jahrText.length === 4
? Number.parseInt(jahrText, 10)
: Number.parseInt(jahrText, 10) < 80
? 2000 + Number.parseInt(jahrText, 10)
: 1900 + Number.parseInt(jahrText, 10)
} else if (iso) {
jahr = Number.parseInt(iso[1]!, 10)
monat = Number.parseInt(iso[2]!, 10)
tag = Number.parseInt(iso[3]!, 10)
} else {
return null
}
if (monat < 1 || monat > 12 || tag < 1 || tag > 31) return null
const datum = new Date(Date.UTC(jahr, monat - 1, tag))
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tag) return null
return datum
}
// -----------------------------------------------------------------------------
// Umwandlung in Buchungen
// -----------------------------------------------------------------------------
export type ImportZeile = {
/** Zeilennummer in der Datei, ab 1 - damit der Nutzer sie wiederfindet. */
zeilennummer: number
datum: Date
wertstellung: Date | null
/** Immer positiv; die Richtung steht daneben. */
betragCent: bigint
richtung: BuchungRichtung
gegenstelle: string | null
verwendungszweck: string | null
importReferenz: string
/** Wird von dublettenMarkieren gesetzt. */
dublette: boolean
}
export type ImportZeilenfehler = {
zeilennummer: number
meldung: string
rohzeile: string[]
}
export type ImportErgebnis = {
erkennung: CsvErkennung
zeilen: ImportZeile[]
fehler: ImportZeilenfehler[]
/** Zeilen, deren Waehrung nicht EUR ist - werden nicht uebernommen. */
fremdwaehrung: number
}
function textOderNull(werte: string[], index: number | null): string | null {
if (index === null || index < 0) return null
const wert = (werte[index] ?? '').replace(/\s+/g, ' ').trim()
return wert === '' ? null : wert
}
/**
* Stabiler Schluessel je Umsatz. Absichtlich NICHT die Referenznummer der
* Bank: die ist nicht in jedem Format enthalten und aendert sich beim
* erneuten Ausleiten. Datum, Betrag, Gegenstelle und Zweck dagegen
* bleiben gleich.
*/
export function importReferenzBilden(teile: {
datum: Date
betragCent: bigint
richtung: BuchungRichtung
gegenstelle: string | null
verwendungszweck: string | null
}): string {
const roh = [
tagAlsText(teile.datum),
teile.richtung,
teile.betragCent.toString(),
(teile.gegenstelle ?? '').toLowerCase(),
(teile.verwendungszweck ?? '').toLowerCase(),
].join('|')
return `csv:${createHash('sha256').update(roh, 'utf8').digest('hex').slice(0, 32)}`
}
export function csvZuBuchungen(inhalt: Buffer | Uint8Array | string): ImportErgebnis {
const erkennung = csvErkennen(inhalt)
const { zuordnung } = erkennung
const zeilen: ImportZeile[] = []
const fehler: ImportZeilenfehler[] = []
let fremdwaehrung = 0
// Zaehlt, wie oft ein Schluessel innerhalb dieser Datei vorkommt -
// zwei gleiche Abhebungen am selben Tag sind kein Fehler.
const schluesselZaehler = new Map<string, number>()
erkennung.datenzeilen.forEach((werte, i) => {
const zeilennummer = erkennung.kopfzeileNummer + 1 + i
const waehrung = textOderNull(werte, zuordnung.waehrung)
if (waehrung && !/^(eur|€)$/i.test(waehrung)) {
fremdwaehrung++
fehler.push({
zeilennummer,
meldung: `Währung "${waehrung}" wird nicht übernommen. Diese Anwendung rechnet in Euro.`,
rohzeile: werte,
})
return
}
const datum = datumParsen(textOderNull(werte, zuordnung.datum))
if (!datum) {
fehler.push({
zeilennummer,
meldung: `Das Datum "${werte[zuordnung.datum] ?? ''}" ist nicht lesbar. Erwartet wird TT.MM.JJJJ.`,
rohzeile: werte,
})
return
}
let betragCent: bigint
let richtung: BuchungRichtung
try {
const ergebnis = betragUndRichtung(werte, zuordnung)
betragCent = ergebnis.betragCent
richtung = ergebnis.richtung
} catch (ausnahme) {
fehler.push({
zeilennummer,
meldung:
ausnahme instanceof BetragFehler || ausnahme instanceof ImportFehler
? ausnahme.message
: 'Der Betrag ist nicht lesbar.',
rohzeile: werte,
})
return
}
if (betragCent === 0n) {
fehler.push({
zeilennummer,
meldung: 'Der Betrag ist 0,00 € – die Zeile wurde übersprungen.',
rohzeile: werte,
})
return
}
const gegenstelle = textOderNull(werte, zuordnung.gegenstelle)
const zweckTeile = [
textOderNull(werte, zuordnung.buchungstext),
textOderNull(werte, zuordnung.verwendungszweck),
].filter((t): t is string => t !== null)
const verwendungszweck = zweckTeile.length > 0 ? zweckTeile.join(' · ') : null
const grundschluessel = importReferenzBilden({
datum,
betragCent,
richtung,
gegenstelle,
verwendungszweck,
})
const bisher = schluesselZaehler.get(grundschluessel) ?? 0
schluesselZaehler.set(grundschluessel, bisher + 1)
const importReferenz = bisher === 0 ? grundschluessel : `${grundschluessel}#${bisher + 1}`
zeilen.push({
zeilennummer,
datum,
wertstellung: datumParsen(textOderNull(werte, zuordnung.wertstellung)),
betragCent,
richtung,
gegenstelle,
verwendungszweck,
importReferenz,
dublette: false,
})
})
return { erkennung, zeilen, fehler, fremdwaehrung }
}
function betragUndRichtung(
werte: string[],
zuordnung: Spaltenzuordnung,
): { betragCent: bigint; richtung: BuchungRichtung } {
// Getrennte Soll-/Haben-Spalten
if (zuordnung.soll !== null || zuordnung.haben !== null) {
const sollText = textOderNull(werte, zuordnung.soll)
const habenText = textOderNull(werte, zuordnung.haben)
const soll = sollText ? betragParsen(sollText) : 0n
const haben = habenText ? betragParsen(habenText) : 0n
if (soll !== 0n && haben !== 0n) {
throw new ImportFehler('In dieser Zeile stehen Soll und Haben gleichzeitig.')
}
if (haben !== 0n) {
return { betragCent: haben < 0n ? -haben : haben, richtung: 'EINNAHME' }
}
return { betragCent: soll < 0n ? -soll : soll, richtung: 'AUSGABE' }
}
if (zuordnung.betrag === null) {
throw new ImportFehler('In dieser Zeile ist keine Betragsspalte zu finden.')
}
const roh = textOderNull(werte, zuordnung.betrag)
if (roh === null) throw new ImportFehler('Der Betrag fehlt.')
let betrag = betragParsen(roh)
// Eigene Soll/Haben-Spalte hat Vorrang vor dem Vorzeichen.
const kennzeichen = textOderNull(werte, zuordnung.sollHaben)
if (kennzeichen) {
const normal = normalisieren(kennzeichen)
if (normal === 's' || normal === 'soll' || normal === 'd' || normal === 'debit') {
betrag = betrag < 0n ? betrag : -betrag
} else if (normal === 'h' || normal === 'haben' || normal === 'c' || normal === 'credit') {
betrag = betrag < 0n ? -betrag : betrag
}
}
return {
betragCent: betrag < 0n ? -betrag : betrag,
richtung: betrag < 0n ? 'AUSGABE' : 'EINNAHME',
}
}
/**
* Markiert die Zeilen, deren importReferenz schon in der Datenbank
* steht. Sie werden angezeigt, aber nicht uebernommen - so sieht der
* Nutzer, dass die Datei sich mit einer frueheren ueberschneidet, statt
* sich zu wundern, warum weniger Zeilen ankommen.
*/
export function dublettenMarkieren(
zeilen: ImportZeile[],
vorhandeneReferenzen: Iterable<string>,
): { zeilen: ImportZeile[]; dubletten: number } {
const vorhanden = new Set(vorhandeneReferenzen)
let dubletten = 0
const markiert = zeilen.map((zeile) => {
const dublette = vorhanden.has(zeile.importReferenz)
if (dublette) dubletten++
return { ...zeile, dublette }
})
return { zeilen: markiert, dubletten }
}
+282
View File
@@ -0,0 +1,282 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import { summe, teileGerundet } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren } from '@/lib/finanzen/datum'
import {
AUSGABE_KATEGORIEN,
EINNAHME_KATEGORIEN,
KATEGORIE_BESCHRIFTUNG,
} from '@/lib/finanzen/kategorien'
/**
* Auswertung des Kassenbuchs - ohne Datenbank, damit sich jede Zahl
* einzeln nachrechnen laesst.
*
* Drei Regeln liegen allem zugrunde:
*
* 1. Umbuchungen zwischen eigenen Konten sind weder Einnahme noch
* Ausgabe. Wer 500 EUR vom Giro aufs Sparbuch legt, hat nichts
* eingenommen. Sie tauchen in keiner Summe auf.
* 2. Buchungen mit betrifftBetreute = false gehoeren nicht zum Vermoegen
* der betreuten Person. Das sind eigene Auslagen des Betreuers - sie
* werden getrennt gefuehrt und getrennt abgerechnet.
* 3. Vermoegenswirksame Buchungen (Verkaufserloes, Rueckkaufswert) sind
* keine laufenden Einnahmen. Sie veraendern aber sehr wohl den
* Kontostand und muessen deshalb in der Rechenprobe mitlaufen -
* sonst geht die Probe nicht auf. Im Bericht stehen sie eigens
* ausgewiesen.
*/
export type BuchungEingabe = {
id: string
datum: Date
betragCent: bigint
richtung: BuchungRichtung
kategorie: BuchungKategorie
gegenstelle: string | null
verwendungszweck: string | null
kontoId: string | null
kontoBezeichnung?: string | null
belegId: string | null
belegTitel?: string | null
belegFehltBegruendung: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
/** Laeuft die Buchung ueber ein Konto des Betreuers? */
aufBetreuerkonto?: boolean
}
/** Zaehlt nur, was das Vermoegen der betreuten Person beruehrt. */
export function istVermoegensbuchung(buchung: BuchungEingabe): boolean {
return buchung.betrifftBetreute && buchung.richtung !== 'UMBUCHUNG'
}
// -----------------------------------------------------------------------------
// Belegzwang
// -----------------------------------------------------------------------------
/**
* Fehlt der Beleg? Eine Begruendung heilt das Fehlen fuer die Statistik,
* nicht fuer den Aktenordner - aber sie beantwortet die Frage des
* Rechtspflegers "und wo ist der Beleg?" schon vorab.
*/
export function belegFehlt(buchung: BuchungEingabe): boolean {
return !buchung.belegId && !(buchung.belegFehltBegruendung ?? '').trim()
}
export function belegBegruendet(buchung: BuchungEingabe): boolean {
return !buchung.belegId && (buchung.belegFehltBegruendung ?? '').trim() !== ''
}
export type Belegquote = {
gesamt: number
mitBeleg: number
begruendet: number
ohneNachweis: number
/** 0 bis 100, ganzzahlig. Bei null Buchungen: 100 - es fehlt ja nichts. */
quoteProzent: number
}
export function belegquote(buchungen: BuchungEingabe[]): Belegquote {
const gesamt = buchungen.length
const mitBeleg = buchungen.filter((b) => Boolean(b.belegId)).length
const begruendet = buchungen.filter((b) => belegBegruendet(b)).length
const ohneNachweis = buchungen.filter((b) => belegFehlt(b)).length
const quoteProzent =
gesamt === 0 ? 100 : Math.round(((mitBeleg + begruendet) / gesamt) * 100)
return { gesamt, mitBeleg, begruendet, ohneNachweis, quoteProzent }
}
/**
* Der Fehler, der in der Rechnungslegung um die Ohren fliegt: eine
* Buchung, die auf einem Konto des Betreuers laeuft und trotzdem als
* Vermoegen der betreuten Person gefuehrt wird. Das vermischt zwei
* Vermoegensmassen - genau das, was § 1835 BGB verhindern soll.
*/
export function vermischungsWarnung(buchung: {
aufBetreuerkonto?: boolean
betrifftBetreute: boolean
}): string | null {
if (buchung.aufBetreuerkonto && buchung.betrifftBetreute) {
return (
'Diese Buchung läuft über ein Konto, das als Konto des Betreuers gekennzeichnet ist, ' +
'gilt aber als Vermögen der betreuten Person. Beides zusammen vermischt zwei ' +
'Vermögensmassen. Entweder ist das Konto falsch gekennzeichnet, oder die Buchung ist ' +
'eine eigene Auslage – dann bitte „betrifft Vermögen der Betreuten“ abwählen.'
)
}
return null
}
// -----------------------------------------------------------------------------
// Gruppierung nach Kategorien
// -----------------------------------------------------------------------------
export type KategorieGruppe = {
kategorie: BuchungKategorie
beschriftung: string
summeCent: bigint
davonVermoegenswirksamCent: bigint
anzahl: number
buchungen: BuchungEingabe[]
}
function gruppieren(
buchungen: BuchungEingabe[],
reihenfolge: BuchungKategorie[],
): KategorieGruppe[] {
const nachKategorie = new Map<BuchungKategorie, BuchungEingabe[]>()
for (const buchung of buchungen) {
const liste = nachKategorie.get(buchung.kategorie) ?? []
liste.push(buchung)
nachKategorie.set(buchung.kategorie, liste)
}
// Erst die Kategorien in Berichtsreihenfolge, danach alles, was sonst
// noch auftaucht (z.B. eine Rueckerstattung in einer Ausgabekategorie).
const bekannt = reihenfolge.filter((k) => nachKategorie.has(k))
const uebrig = [...nachKategorie.keys()].filter((k) => !reihenfolge.includes(k))
return [...bekannt, ...uebrig].map((kategorie) => {
const liste = nachTagSortieren(nachKategorie.get(kategorie) ?? [], (b) => b.datum)
return {
kategorie,
beschriftung: KATEGORIE_BESCHRIFTUNG[kategorie],
summeCent: summe(liste.map((b) => b.betragCent)),
davonVermoegenswirksamCent: summe(
liste.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent),
),
anzahl: liste.length,
buchungen: liste,
}
})
}
export function einnahmenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] {
return gruppieren(
buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'EINNAHME'),
EINNAHME_KATEGORIEN,
)
}
export function ausgabenGruppiert(buchungen: BuchungEingabe[]): KategorieGruppe[] {
return gruppieren(
buchungen.filter((b) => istVermoegensbuchung(b) && b.richtung === 'AUSGABE'),
AUSGABE_KATEGORIEN,
)
}
// -----------------------------------------------------------------------------
// Laufende Einnahmen und Ausgaben (fuer das Vermoegensverzeichnis)
// -----------------------------------------------------------------------------
export type LaufenderPosten = {
kategorie: BuchungKategorie
beschriftung: string
/** Summe im betrachteten Zeitraum. */
summeCent: bigint
/** Auf den Monat heruntergerechnet, kaufmaennisch auf den Cent gerundet. */
monatlichCent: bigint
anzahl: number
}
export type LaufendeBewegungen = {
von: Date
bis: Date
monate: number
einnahmen: LaufenderPosten[]
ausgaben: LaufenderPosten[]
summeEinnahmenCent: bigint
summeAusgabenCent: bigint
monatlichEinnahmenCent: bigint
monatlichAusgabenCent: bigint
}
/**
* Was laeuft regelmaessig? Das Vermoegensverzeichnis verlangt neben dem
* Bestand auch die laufenden Einnahmen und Ausgaben. Grundlage sind die
* Buchungen des angegebenen Zeitraums (in der Regel die letzten zwoelf
* Monate), heruntergerechnet auf den Monat.
*
* Vermoegenswirksame Buchungen bleiben hier aussen vor: ein einmaliger
* Verkaufserloes ist keine laufende Einnahme und wuerde die Hochrechnung
* verfaelschen.
*/
export function laufendeBewegungen(
buchungen: BuchungEingabe[],
von: Date,
bis: Date,
monate = 12,
): LaufendeBewegungen {
const imFenster = buchungen.filter(
(b) => istVermoegensbuchung(b) && !b.vermoegenswirksam && imZeitraum(b.datum, von, bis),
)
const umrechnen = (gruppen: KategorieGruppe[]): LaufenderPosten[] =>
gruppen.map((g) => ({
kategorie: g.kategorie,
beschriftung: g.beschriftung,
summeCent: g.summeCent,
monatlichCent: teileGerundet(g.summeCent, BigInt(monate)),
anzahl: g.anzahl,
}))
const einnahmen = umrechnen(einnahmenGruppiert(imFenster))
const ausgaben = umrechnen(ausgabenGruppiert(imFenster))
const summeEinnahmenCent = summe(einnahmen.map((p) => p.summeCent))
const summeAusgabenCent = summe(ausgaben.map((p) => p.summeCent))
return {
von: alsTag(von),
bis: alsTag(bis),
monate,
einnahmen,
ausgaben,
summeEinnahmenCent,
summeAusgabenCent,
// Bewusst aus der Gesamtsumme gerechnet und nicht aus den gerundeten
// Einzelwerten - sonst weicht die Summe der Zeilen vom Gesamtwert ab.
monatlichEinnahmenCent: teileGerundet(summeEinnahmenCent, BigInt(monate)),
monatlichAusgabenCent: teileGerundet(summeAusgabenCent, BigInt(monate)),
}
}
// -----------------------------------------------------------------------------
// Kennzahlen fuer die Uebersichtsseite
// -----------------------------------------------------------------------------
export type Kassenkennzahlen = {
anzahl: number
einnahmenCent: bigint
ausgabenCent: bigint
saldoCent: bigint
vermoegenswirksamCent: bigint
eigeneAuslagenCent: bigint
beleg: Belegquote
vermischungen: number
}
export function kennzahlen(buchungen: BuchungEingabe[]): Kassenkennzahlen {
const relevant = buchungen.filter(istVermoegensbuchung)
const einnahmenCent = summe(
relevant.filter((b) => b.richtung === 'EINNAHME').map((b) => b.betragCent),
)
const ausgabenCent = summe(
relevant.filter((b) => b.richtung === 'AUSGABE').map((b) => b.betragCent),
)
return {
anzahl: buchungen.length,
einnahmenCent,
ausgabenCent,
saldoCent: einnahmenCent - ausgabenCent,
vermoegenswirksamCent: summe(
relevant.filter((b) => b.vermoegenswirksam).map((b) => b.betragCent),
),
eigeneAuslagenCent: summe(
buchungen.filter((b) => !b.betrifftBetreute).map((b) => b.betragCent),
),
beleg: belegquote(buchungen),
vermischungen: buchungen.filter((b) => vermischungsWarnung(b) !== null).length,
}
}
+219
View File
@@ -0,0 +1,219 @@
import type {
BuchungKategorie,
BuchungRichtung,
KontoArt,
VermoegensArt,
} from '@prisma/client'
/**
* Beschriftungen und Ordnung der Finanz-Enums.
*
* Die Reihenfolge in den Listen ist nicht alphabetisch, sondern die
* Reihenfolge, in der die Posten im Vermoegensverzeichnis und in der
* Rechnungslegung beim Gericht stehen sollen. Wer sie aendert, aendert
* das Aussehen beider Berichte.
*/
// -----------------------------------------------------------------------------
// Konten
// -----------------------------------------------------------------------------
export const KONTO_ARTEN: KontoArt[] = [
'GIRO',
'SPAR',
'TAGESGELD',
'FESTGELD',
'DEPOT',
'VERWAHRGELD_HEIM',
'BARKASSE',
'SONSTIGES',
]
export const KONTO_ART_BESCHRIFTUNG: Record<KontoArt, string> = {
GIRO: 'Girokonto',
SPAR: 'Sparkonto',
TAGESGELD: 'Tagesgeld',
FESTGELD: 'Festgeld',
DEPOT: 'Wertpapierdepot',
VERWAHRGELD_HEIM: 'Verwahrgeld im Heim',
BARKASSE: 'Barkasse',
SONSTIGES: 'Sonstiges Konto',
}
export const KONTO_ART_ERKLAERUNG: Record<KontoArt, string> = {
GIRO: 'Laufendes Konto, über das Rente, Heimkosten und Alltagszahlungen laufen.',
SPAR: 'Sparbuch oder Sparkonto. Zählt beim Sozialhilfeträger voll zum Vermögen.',
TAGESGELD: 'Täglich verfügbares Guthaben, meist als Unterkonto zum Girokonto.',
FESTGELD: 'Angelegtes Guthaben mit fester Laufzeit. Kündigungsfrist im Blick behalten.',
DEPOT: 'Wertpapiere. Der Wert schwankt – Stichtagswerte deshalb belegen.',
VERWAHRGELD_HEIM:
'Taschengeldkonto, das die Einrichtung führt. Auch dieses Geld gehört zum Vermögen und muss abgerechnet werden.',
BARKASSE: 'Bargeld, das du selbst verwahrst. Jede Entnahme braucht eine Quittung.',
SONSTIGES: 'Alles, was in keine der anderen Arten passt.',
}
/** Konten ohne IBAN - dort ist das Feld optional und wird nicht erzwungen. */
export const KONTO_ARTEN_OHNE_IBAN: KontoArt[] = ['VERWAHRGELD_HEIM', 'BARKASSE', 'SONSTIGES']
// -----------------------------------------------------------------------------
// Buchungen
// -----------------------------------------------------------------------------
export const BUCHUNG_RICHTUNGEN: BuchungRichtung[] = ['EINNAHME', 'AUSGABE', 'UMBUCHUNG']
export const RICHTUNG_BESCHRIFTUNG: Record<BuchungRichtung, string> = {
EINNAHME: 'Einnahme',
AUSGABE: 'Ausgabe',
UMBUCHUNG: 'Umbuchung',
}
/** Einnahmen in der Reihenfolge, in der sie im Bericht stehen. */
export const EINNAHME_KATEGORIEN: BuchungKategorie[] = [
'RENTE',
'PFLEGEKASSE',
'SOZIALHILFE',
'LANDESPFLEGEGELD',
'ZINSEN',
'MIETEINNAHME',
'SONSTIGE_EINNAHME',
]
export const AUSGABE_KATEGORIEN: BuchungKategorie[] = [
'HEIMKOSTEN',
'BARBETRAG',
'BEKLEIDUNG',
'ARZT_UND_MEDIKAMENTE',
'ZUZAHLUNG',
'VERSICHERUNG',
'IMMOBILIE_BETRIEBSKOSTEN',
'IMMOBILIE_INSTANDHALTUNG',
'GRUNDSTEUER',
'STEUERN',
'GEBUEHREN_GERICHT',
'PORTO_UND_KOPIEN',
'FAHRTKOSTEN',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'BESTATTUNGSVORSORGE',
'BANKGEBUEHREN',
'SONSTIGE_AUSGABE',
]
export const ALLE_KATEGORIEN: BuchungKategorie[] = [
...EINNAHME_KATEGORIEN,
...AUSGABE_KATEGORIEN,
'UMBUCHUNG',
]
export const KATEGORIE_BESCHRIFTUNG: Record<BuchungKategorie, string> = {
RENTE: 'Rente',
PFLEGEKASSE: 'Leistungen der Pflegekasse',
SOZIALHILFE: 'Hilfe zur Pflege / Sozialhilfe',
LANDESPFLEGEGELD: 'Landespflegegeld',
ZINSEN: 'Zinsen und Kapitalerträge',
MIETEINNAHME: 'Mieteinnahmen',
SONSTIGE_EINNAHME: 'Sonstige Einnahmen',
HEIMKOSTEN: 'Heimkosten',
BARBETRAG: 'Barbetrag / Taschengeld',
BEKLEIDUNG: 'Bekleidung',
ARZT_UND_MEDIKAMENTE: 'Arzt und Medikamente',
ZUZAHLUNG: 'Zuzahlungen',
VERSICHERUNG: 'Versicherungsbeiträge',
IMMOBILIE_BETRIEBSKOSTEN: 'Immobilie: Betriebskosten',
IMMOBILIE_INSTANDHALTUNG: 'Immobilie: Instandhaltung',
GRUNDSTEUER: 'Grundsteuer',
STEUERN: 'Steuern',
GEBUEHREN_GERICHT: 'Gerichtskosten und Gebühren',
PORTO_UND_KOPIEN: 'Porto und Kopien',
FAHRTKOSTEN: 'Fahrtkosten',
TELEFON_INTERNET: 'Telefon und Internet',
RUNDFUNKBEITRAG: 'Rundfunkbeitrag',
BESTATTUNGSVORSORGE: 'Bestattungsvorsorge',
BANKGEBUEHREN: 'Bankgebühren',
SONSTIGE_AUSGABE: 'Sonstige Ausgaben',
UMBUCHUNG: 'Umbuchung zwischen eigenen Konten',
}
/**
* Passt die Kategorie zur Richtung? Wird nicht erzwungen (eine
* Rueckerstattung von Heimkosten ist eine Einnahme in der Kategorie
* HEIMKOSTEN), aber die Oberflaeche sortiert danach vor.
*/
export function kategorienFuerRichtung(richtung: BuchungRichtung): BuchungKategorie[] {
if (richtung === 'EINNAHME') return EINNAHME_KATEGORIEN
if (richtung === 'AUSGABE') return AUSGABE_KATEGORIEN
return ['UMBUCHUNG']
}
export function standardKategorie(richtung: BuchungRichtung): BuchungKategorie {
if (richtung === 'EINNAHME') return 'SONSTIGE_EINNAHME'
if (richtung === 'AUSGABE') return 'SONSTIGE_AUSGABE'
return 'UMBUCHUNG'
}
/**
* Vorzeichen fuer die Anzeige und fuer den Kontostand: Einnahmen erhoehen,
* Ausgaben mindern. Umbuchungen bekommen kein Vorzeichen, weil sie am
* Gesamtvermoegen nichts aendern - sie schieben nur zwischen Konten.
*/
export function vorzeichen(richtung: BuchungRichtung): 1n | -1n | 0n {
if (richtung === 'EINNAHME') return 1n
if (richtung === 'AUSGABE') return -1n
return 0n
}
// -----------------------------------------------------------------------------
// Vermoegensposten
// -----------------------------------------------------------------------------
/**
* Reihenfolge der Gruppen im Vermoegensverzeichnis nach § 1835 BGB:
* erst das, was sofort verfuegbar ist, dann Sachwerte, zuletzt
* Forderungen. VERBINDLICHKEIT steht nicht in dieser Liste - Passiva
* bekommen einen eigenen Abschnitt.
*/
export const AKTIVA_ARTEN: VermoegensArt[] = [
'KONTO',
'IMMOBILIE',
'VERSICHERUNG',
'FAHRZEUG',
'WERTGEGENSTAND',
'FORDERUNG',
'SONSTIGES',
]
export const VERMOEGENS_ARTEN: VermoegensArt[] = [...AKTIVA_ARTEN, 'VERBINDLICHKEIT']
export const VERMOEGENS_ART_BESCHRIFTUNG: Record<VermoegensArt, string> = {
KONTO: 'Konten und Guthaben',
IMMOBILIE: 'Grundbesitz',
VERSICHERUNG: 'Versicherungen',
FAHRZEUG: 'Fahrzeuge',
WERTGEGENSTAND: 'Wertgegenstände',
FORDERUNG: 'Forderungen',
SONSTIGES: 'Sonstiges Vermögen',
VERBINDLICHKEIT: 'Verbindlichkeiten',
}
/** Einzahl, fuer die Kopfzeile eines einzelnen Postens. */
export const VERMOEGENS_ART_EINZAHL: Record<VermoegensArt, string> = {
KONTO: 'Konto',
IMMOBILIE: 'Grundbesitz',
VERSICHERUNG: 'Versicherung',
FAHRZEUG: 'Fahrzeug',
WERTGEGENSTAND: 'Wertgegenstand',
FORDERUNG: 'Forderung',
SONSTIGES: 'Sonstiges',
VERBINDLICHKEIT: 'Verbindlichkeit',
}
export function istPassiv(art: VermoegensArt): boolean {
return art === 'VERBINDLICHKEIT'
}
export const SOZIALHILFE_BEWERTUNG_BESCHRIFTUNG = {
UNGEPRUEFT: 'noch nicht geprüft',
VERWERTBAR: 'verwertbar',
GESCHUETZT: 'geschützt (Schonvermögen)',
STRITTIG: 'strittig',
} as const
+356
View File
@@ -0,0 +1,356 @@
import { summe } from '@/lib/finanzen/betrag'
import { alsTag, imZeitraum, nachTagSortieren, tagePlus, vorOderGleich } from '@/lib/finanzen/datum'
import type { Betreuungsjahr } from '@/lib/finanzen/betreuungsjahr'
import {
ausgabenGruppiert,
belegFehlt,
einnahmenGruppiert,
istVermoegensbuchung,
belegquote,
type Belegquote,
type BuchungEingabe,
type KategorieGruppe,
} from '@/lib/finanzen/kassenbuch'
import { KONTO_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import type { KontoArt } from '@prisma/client'
/**
* Rechnungslegung nach §§ 1859, 1865 BGB.
*
* Aufbau, den das Gericht erwartet:
*
* Anfangsbestand
* + Einnahmen (nach Kategorien geordnet)
* - Ausgaben (nach Kategorien geordnet)
* = Endbestand
*
* Die letzte Zeile ist eine Behauptung, und sie wird geprueft. Der
* Endbestand aus den Saldostaenden muss dem herausgerechneten Wert
* entsprechen. Tut er das nicht, fehlen Buchungen oder Saldostaende.
*
* Diese Abweichung wird ausgewiesen, nicht versteckt. Ein Bericht, der
* eine Differenz stillschweigend glattzieht, ist schlimmer als einer,
* der sie benennt: die Zahl waere dann falsch, ohne dass es jemand
* merkt - und unterschrieben wird sie trotzdem.
*
* Zwei Varianten:
*
* - § 1859 BGB - befreiter Betreuer (Angehoerige). Es genuegt eine
* Vermoegensuebersicht: Bestaende und Summen je Kategorie, keine
* Einzelbelegliste.
* - § 1865 BGB - vollstaendige Rechnungslegung. Jede Buchung einzeln,
* mit laufender Belegnummer.
*/
export type Variante = 'UEBERSICHT_1859' | 'VOLL_1865'
export const VARIANTE_BESCHRIFTUNG: Record<Variante, string> = {
UEBERSICHT_1859: 'Vermögensübersicht (§ 1859 BGB, befreiter Betreuer)',
VOLL_1865: 'Vollständige Rechnungslegung (§ 1865 BGB)',
}
export const VARIANTE_RECHTSGRUNDLAGE: Record<Variante, string> = {
UEBERSICHT_1859: '§ 1859 BGB',
VOLL_1865: '§ 1865 BGB',
}
export function standardVariante(befreiterBetreuer: boolean): Variante {
return befreiterBetreuer ? 'UEBERSICHT_1859' : 'VOLL_1865'
}
// -----------------------------------------------------------------------------
// Bestaende
// -----------------------------------------------------------------------------
export type SaldoEingabe = { stichtag: Date; betragCent: bigint; quelle: string | null }
export type KontoEingabe = {
id: string
bezeichnung: string
bank: string | null
art: KontoArt
istBetreuerkonto: boolean
ibanMaskiert?: string | null
saldostaende: SaldoEingabe[]
}
export type Bestandszeile = {
kontoId: string
bezeichnung: string
bank: string | null
art: KontoArt
artBeschriftung: string
ibanMaskiert: string | null
anfangCent: bigint
/** Stichtag des tatsaechlich benutzten Saldostands - null, wenn keiner da war. */
anfangStichtag: Date | null
endeCent: bigint
endeStichtag: Date | null
}
/** Der juengste Saldostand, der nicht nach dem Stichtag liegt. */
export function saldoZumStichtag(
saldostaende: SaldoEingabe[],
stichtag: Date,
): SaldoEingabe | null {
let treffer: SaldoEingabe | null = null
for (const saldo of saldostaende) {
if (!vorOderGleich(saldo.stichtag, stichtag)) continue
if (treffer === null || alsTag(saldo.stichtag).getTime() > alsTag(treffer.stichtag).getTime()) {
treffer = saldo
}
}
return treffer
}
/**
* Bestaende zu Beginn und zum Ende des Zeitraums.
*
* Ein Saldostand gilt als Bestand am Ende seines Stichtages. Der
* Anfangsbestand eines Zeitraums ist deshalb der Bestand am Tag VOR dem
* ersten Tag - sonst waeren die Buchungen des ersten Tages doppelt
* enthalten.
*
* Konten des Betreuers bleiben aussen vor: sie gehoeren nicht zum
* Vermoegen der betreuten Person.
*/
export function bestaendeErmitteln(
konten: KontoEingabe[],
von: Date,
bis: Date,
): Bestandszeile[] {
const vortag = tagePlus(von, -1)
return konten
.filter((konto) => !konto.istBetreuerkonto)
.map((konto) => {
const anfang = saldoZumStichtag(konto.saldostaende, vortag)
const ende = saldoZumStichtag(konto.saldostaende, bis)
return {
kontoId: konto.id,
bezeichnung: konto.bezeichnung,
bank: konto.bank,
art: konto.art,
artBeschriftung: KONTO_ART_BESCHRIFTUNG[konto.art],
ibanMaskiert: konto.ibanMaskiert ?? null,
anfangCent: anfang?.betragCent ?? 0n,
anfangStichtag: anfang ? alsTag(anfang.stichtag) : null,
endeCent: ende?.betragCent ?? 0n,
endeStichtag: ende ? alsTag(ende.stichtag) : null,
}
})
}
// -----------------------------------------------------------------------------
// Belegliste
// -----------------------------------------------------------------------------
export type Belegzeile = {
/** Laufende Nummer im Bericht, ab 1. Danach wird im Ordner sortiert. */
nummer: number
buchungId: string
datum: Date
betragCent: bigint
richtung: BuchungEingabe['richtung']
beschriftung: string
gegenstelle: string | null
verwendungszweck: string | null
belegId: string | null
belegTitel: string | null
belegFehltBegruendung: string | null
fehlt: boolean
}
/**
* Laufende Belegnummern. Sortiert nach Datum - so laesst sich die
* Belegmappe in derselben Reihenfolge abheften, in der die Zahlen im
* Bericht stehen.
*/
export function beleglisteErstellen(buchungen: BuchungEingabe[]): Belegzeile[] {
return nachTagSortieren(buchungen, (b) => b.datum).map((buchung, i) => ({
nummer: i + 1,
buchungId: buchung.id,
datum: alsTag(buchung.datum),
betragCent: buchung.betragCent,
richtung: buchung.richtung,
beschriftung: buchung.gegenstelle ?? buchung.verwendungszweck ?? 'ohne Bezeichnung',
gegenstelle: buchung.gegenstelle,
verwendungszweck: buchung.verwendungszweck,
belegId: buchung.belegId,
belegTitel: buchung.belegTitel ?? null,
belegFehltBegruendung: buchung.belegFehltBegruendung,
fehlt: belegFehlt(buchung),
}))
}
// -----------------------------------------------------------------------------
// Die Rechnung selbst
// -----------------------------------------------------------------------------
export type Rechenprobe = {
anfangsbestandCent: bigint
einnahmenCent: bigint
ausgabenCent: bigint
/** Anfangsbestand + Einnahmen - Ausgaben. */
rechnerischerEndbestandCent: bigint
/** Summe der Saldostaende zum Ende des Zeitraums. */
gebuchterEndbestandCent: bigint
/** gebucht - rechnerisch. Positiv: es ist mehr da, als die Buchungen erklären. */
differenzCent: bigint
stimmt: boolean
}
export type Rechnungsdaten = {
von: Date
bis: Date
variante: Variante
/** Gesetzt, wenn der Zeitraum einem Betreuungsjahr entspricht. */
betreuungsjahr: Betreuungsjahr | null
/** Ein noch laufendes Jahr ergibt eine Zwischenaufstellung, keine Schlussrechnung. */
zwischenaufstellung: boolean
bestaende: Bestandszeile[]
anfangsbestandCent: bigint
endbestandCent: bigint
einnahmen: KategorieGruppe[]
ausgaben: KategorieGruppe[]
summeEinnahmenCent: bigint
summeAusgabenCent: bigint
vermoegenswirksamEinnahmenCent: bigint
vermoegenswirksamAusgabenCent: bigint
umbuchungen: BuchungEingabe[]
eigeneAuslagen: BuchungEingabe[]
eigeneAuslagenCent: bigint
probe: Rechenprobe
belege: Belegzeile[]
beleg: Belegquote
/** Klartexthinweise fuer den Bericht - jede Luecke wird benannt. */
hinweise: string[]
}
export function rechnungslegungBerechnen(eingabe: {
von: Date
bis: Date
variante: Variante
konten: KontoEingabe[]
buchungen: BuchungEingabe[]
betreuungsjahr?: Betreuungsjahr | null
/** Fuer die Frage, ob das Jahr schon abgeschlossen ist. */
heute?: Date
}): Rechnungsdaten {
const von = alsTag(eingabe.von)
const bis = alsTag(eingabe.bis)
const vortag = tagePlus(von, -1)
const imZeitraumListe = eingabe.buchungen.filter((b) => imZeitraum(b.datum, von, bis))
const bestaende = bestaendeErmitteln(eingabe.konten, von, bis)
const anfangsbestandCent = summe(bestaende.map((b) => b.anfangCent))
const endbestandCent = summe(bestaende.map((b) => b.endeCent))
const einnahmen = einnahmenGruppiert(imZeitraumListe)
const ausgaben = ausgabenGruppiert(imZeitraumListe)
const summeEinnahmenCent = summe(einnahmen.map((g) => g.summeCent))
const summeAusgabenCent = summe(ausgaben.map((g) => g.summeCent))
const rechnerischerEndbestandCent = anfangsbestandCent + summeEinnahmenCent - summeAusgabenCent
const differenzCent = endbestandCent - rechnerischerEndbestandCent
const relevante = imZeitraumListe.filter(istVermoegensbuchung)
const eigeneAuslagen = imZeitraumListe.filter((b) => !b.betrifftBetreute)
const hinweise: string[] = []
for (const zeile of bestaende) {
if (zeile.anfangStichtag === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(vortag)} oder davor. ` +
`Der Anfangsbestand wurde mit 0,00 € angesetzt.`,
)
} else if (zeile.anfangStichtag.getTime() !== vortag.getTime()) {
hinweise.push(
`Der Anfangsbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.anfangStichtag)} ` +
`statt vom ${formatKurz(vortag)}. Buchungen dazwischen sind in der Rechenprobe nicht gedeckt.`,
)
}
if (zeile.endeStichtag === null) {
hinweise.push(
`Für „${zeile.bezeichnung}“ gibt es keinen Saldostand zum ${formatKurz(bis)} oder davor. ` +
`Der Endbestand wurde mit 0,00 € angesetzt.`,
)
} else if (zeile.endeStichtag.getTime() !== bis.getTime()) {
hinweise.push(
`Der Endbestand von „${zeile.bezeichnung}“ stammt vom ${formatKurz(zeile.endeStichtag)} ` +
`statt vom ${formatKurz(bis)}.`,
)
}
}
if (differenzCent !== 0n) {
hinweise.push(
'Die Rechenprobe geht nicht auf: Anfangsbestand zuzüglich Einnahmen abzüglich Ausgaben ' +
'ergibt nicht den Endbestand. Entweder fehlen Buchungen, oder ein Saldostand ist nicht ' +
'erfasst. Die Differenz ist im Bericht ausgewiesen und sollte vor der Abgabe geklärt werden.',
)
}
const fehlendeBelege = relevante.filter(belegFehlt).length
if (fehlendeBelege > 0) {
hinweise.push(
`${fehlendeBelege} Buchung(en) haben weder einen Beleg noch eine Begründung, warum er fehlt.`,
)
}
const vermischt = imZeitraumListe.filter((b) => b.aufBetreuerkonto && b.betrifftBetreute).length
if (vermischt > 0) {
hinweise.push(
`${vermischt} Buchung(en) laufen über ein Konto des Betreuers und sind trotzdem als ` +
'Vermögen der betreuten Person gekennzeichnet. Das ist zu klären, bevor der Bericht abgeht.',
)
}
const jahr = eingabe.betreuungsjahr ?? null
const stichtagHeute = eingabe.heute ? alsTag(eingabe.heute) : null
const zwischenaufstellung =
stichtagHeute !== null && stichtagHeute.getTime() < bis.getTime()
return {
von,
bis,
variante: eingabe.variante,
betreuungsjahr: jahr,
zwischenaufstellung,
bestaende,
anfangsbestandCent,
endbestandCent,
einnahmen,
ausgaben,
summeEinnahmenCent,
summeAusgabenCent,
vermoegenswirksamEinnahmenCent: summe(einnahmen.map((g) => g.davonVermoegenswirksamCent)),
vermoegenswirksamAusgabenCent: summe(ausgaben.map((g) => g.davonVermoegenswirksamCent)),
umbuchungen: imZeitraumListe.filter((b) => b.richtung === 'UMBUCHUNG' && b.betrifftBetreute),
eigeneAuslagen,
eigeneAuslagenCent: summe(eigeneAuslagen.map((b) => b.betragCent)),
probe: {
anfangsbestandCent,
einnahmenCent: summeEinnahmenCent,
ausgabenCent: summeAusgabenCent,
rechnerischerEndbestandCent,
gebuchterEndbestandCent: endbestandCent,
differenzCent,
stimmt: differenzCent === 0n,
},
belege: beleglisteErstellen(relevante),
beleg: belegquote(relevante),
hinweise,
}
}
function formatKurz(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
+269
View File
@@ -0,0 +1,269 @@
import type { VermoegensArt } from '@prisma/client'
import { summe } from '@/lib/finanzen/betrag'
import { alsTag, nachTagSortieren, tageZwischen, vorOderGleich } from '@/lib/finanzen/datum'
import { AKTIVA_ARTEN, istPassiv } from '@/lib/finanzen/kategorien'
/**
* Vermoegen zu einem Stichtag.
*
* Diese Datei ist das Herz beider Berichte. Das Vermoegensverzeichnis
* nach § 1835 BGB und die Vermoegensuebersicht nach § 1859 BGB fragen
* dasselbe: "Was war an diesem Tag da?" Beantwortet wird das nie aus
* einem aktuellen Wertfeld, sondern immer aus der Wertstand-Historie -
* je Posten der juengste Wertstand, der nicht nach dem Stichtag liegt.
*
* Der Grund ist praktisch: Wenn das Gericht zwei Jahre spaeter nachfragt,
* warum im Verzeichnis 42.000 EUR standen, muss genau dieselbe Zahl
* wieder herauskommen. Wuerden Werte ueberschrieben, waere das nicht
* mehr moeglich - dann stuende Aussage gegen Aussage.
*
* Ein Wertstand, der nach dem Stichtag liegt, wird nicht benutzt. Auch
* dann nicht, wenn er "besser" waere. Rueckwaerts wissen gilt nicht.
*/
export type WertstandEingabe = {
stichtag: Date
wertCent: bigint
wertquelle: string
nachweisDokumentId?: string | null
bemerkung?: string | null
}
export type PostenEingabe = {
id: string
bezeichnung: string
art: VermoegensArt
wertstaende: WertstandEingabe[]
/** Optionale Verweise, damit der Bericht auf das Objekt zeigen kann. */
kontoId?: string | null
immobilieId?: string | null
vertragId?: string | null
bewertungSozialhilfe?: string | null
}
export type PostenZumStichtag = {
id: string
bezeichnung: string
art: VermoegensArt
wertCent: bigint
/** Stichtag des tatsaechlich verwendeten Wertstands. */
bewertetAm: Date
wertquelle: string
nachweisDokumentId: string | null
/** Wie alt ist der verwendete Wert am Stichtag? Null Tage = taggenau. */
alterTage: number
kontoId: string | null
immobilieId: string | null
vertragId: string | null
bewertungSozialhilfe: string | null
}
export type PostenOhneWert = {
id: string
bezeichnung: string
art: VermoegensArt
/** Gibt es ueberhaupt Wertstaende, nur eben spaetere? */
spaetereWertstaende: number
}
export type Vermoegensgruppe = {
art: VermoegensArt
posten: PostenZumStichtag[]
summeCent: bigint
}
export type Vermoegensbild = {
stichtag: Date
/** Alles ausser VERBINDLICHKEIT, nach Gruppen in Berichtsreihenfolge. */
aktivaGruppen: Vermoegensgruppe[]
aktiva: PostenZumStichtag[]
passiva: PostenZumStichtag[]
/** Posten, fuer die es am Stichtag keinen Wert gibt - stehen im Bericht als Luecke. */
ohneWert: PostenOhneWert[]
summeAktivaCent: bigint
summePassivaCent: bigint
/** Aktiva minus Passiva. Das ist die Zahl, nach der das Gericht fragt. */
reinvermoegenCent: bigint
/** Aeltester verwendeter Wert - Hinweis darauf, dass nachbewertet werden sollte. */
aeltesterWertTage: number | null
}
/**
* Der juengste Wertstand, der nicht nach dem Stichtag liegt.
* Null, wenn es zu diesem Tag noch keine Bewertung gab.
*/
export function wertZumStichtag(
wertstaende: WertstandEingabe[],
stichtag: Date,
): WertstandEingabe | null {
let treffer: WertstandEingabe | null = null
for (const wert of wertstaende) {
if (!vorOderGleich(wert.stichtag, stichtag)) continue
if (treffer === null || alsTag(wert.stichtag) > alsTag(treffer.stichtag)) {
treffer = wert
}
}
return treffer
}
/**
* Reine Rechnung ohne Datenbank - hier laesst sich der Fall
* "mehrere Wertstaende, Stichtag mittendrin" pruefen, ohne Postgres zu
* starten.
*/
export function vermoegenBerechnen(posten: PostenEingabe[], stichtag: Date): Vermoegensbild {
const tag = alsTag(stichtag)
const bewertet: PostenZumStichtag[] = []
const ohneWert: PostenOhneWert[] = []
for (const p of posten) {
const wert = wertZumStichtag(p.wertstaende, tag)
if (!wert) {
ohneWert.push({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
spaetereWertstaende: p.wertstaende.length,
})
continue
}
bewertet.push({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
wertCent: wert.wertCent,
bewertetAm: alsTag(wert.stichtag),
wertquelle: wert.wertquelle,
nachweisDokumentId: wert.nachweisDokumentId ?? null,
alterTage: tageZwischen(wert.stichtag, tag),
kontoId: p.kontoId ?? null,
immobilieId: p.immobilieId ?? null,
vertragId: p.vertragId ?? null,
bewertungSozialhilfe: p.bewertungSozialhilfe ?? null,
})
}
const aktiva = bewertet.filter((p) => !istPassiv(p.art))
const passiva = bewertet.filter((p) => istPassiv(p.art))
const aktivaGruppen: Vermoegensgruppe[] = AKTIVA_ARTEN.map((art) => {
const gruppe = aktiva
.filter((p) => p.art === art)
.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de'))
return { art, posten: gruppe, summeCent: summe(gruppe.map((p) => p.wertCent)) }
}).filter((gruppe) => gruppe.posten.length > 0)
const summeAktivaCent = summe(aktiva.map((p) => p.wertCent))
const summePassivaCent = summe(passiva.map((p) => p.wertCent))
const alter = bewertet.map((p) => p.alterTage)
return {
stichtag: tag,
aktivaGruppen,
aktiva,
passiva: passiva.sort((a, b) => a.bezeichnung.localeCompare(b.bezeichnung, 'de')),
ohneWert,
summeAktivaCent,
summePassivaCent,
reinvermoegenCent: summeAktivaCent - summePassivaCent,
aeltesterWertTage: alter.length > 0 ? Math.max(...alter) : null,
}
}
/**
* Dieselbe Rechnung, aber mit den Daten aus der Datenbank.
*
* Der Zugriff auf db geschieht bewusst erst im Funktionsrumpf: so bleibt
* dieses Modul fuer die Tests ohne Datenbank ladbar, und die reine
* Rechnung oben ist unabhaengig pruefbar.
*/
export async function vermoegenZumStichtag(
betreuteId: string,
stichtag: Date,
): Promise<Vermoegensbild> {
const { db, nichtGeloescht } = await import('@/lib/db')
const posten = await db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'asc' } } },
orderBy: [{ art: 'asc' }, { bezeichnung: 'asc' }],
})
return vermoegenBerechnen(
posten.map((p) => ({
id: p.id,
bezeichnung: p.bezeichnung,
art: p.art,
kontoId: p.kontoId,
immobilieId: p.immobilieId,
vertragId: p.vertragId,
bewertungSozialhilfe: p.bewertungSozialhilfe,
wertstaende: p.wertstaende.map((w) => ({
stichtag: w.stichtag,
wertCent: w.wertCent,
wertquelle: w.wertquelle,
nachweisDokumentId: w.nachweisDokumentId,
bemerkung: w.bemerkung,
})),
})),
stichtag,
)
}
// -----------------------------------------------------------------------------
// Wertverlauf eines einzelnen Postens
// -----------------------------------------------------------------------------
export type Verlaufspunkt = {
stichtag: Date
wertCent: bigint
wertquelle: string
/** Veraenderung gegenueber dem vorherigen Wertstand. */
veraenderungCent: bigint | null
}
/** Aeltester Wert zuerst, mit der Veraenderung zum Vorgaenger. */
export function wertverlauf(wertstaende: WertstandEingabe[]): Verlaufspunkt[] {
const sortiert = nachTagSortieren(wertstaende, (w) => w.stichtag)
return sortiert.map((wert, i) => {
const vorher = i > 0 ? sortiert[i - 1]! : null
return {
stichtag: alsTag(wert.stichtag),
wertCent: wert.wertCent,
wertquelle: wert.wertquelle,
veraenderungCent: vorher ? wert.wertCent - vorher.wertCent : null,
}
})
}
/**
* Punkte fuer eine kleine Inline-SVG-Kurve, normiert auf ein Rechteck.
* Bewusst kein Diagrammpaket: eine Handvoll Stuetzstellen als Polyline
* reicht, laedt nichts nach und druckt sauber.
*/
export function verlaufsPunkte(
verlauf: Verlaufspunkt[],
breite: number,
hoehe: number,
): { x: number; y: number }[] {
if (verlauf.length === 0) return []
if (verlauf.length === 1) return [{ x: breite / 2, y: hoehe / 2 }]
const zeiten = verlauf.map((p) => p.stichtag.getTime())
const werte = verlauf.map((p) => Number(p.wertCent))
const tMin = Math.min(...zeiten)
const tMax = Math.max(...zeiten)
const wMin = Math.min(...werte)
const wMax = Math.max(...werte)
const tSpanne = tMax - tMin || 1
const wSpanne = wMax - wMin || 1
return verlauf.map((punkt, i) => ({
x: ((zeiten[i]! - tMin) / tSpanne) * breite,
// y ist in SVG nach unten gerichtet - deshalb gespiegelt.
y: hoehe - ((werte[i]! - wMin) / wSpanne) * hoehe,
}))
}
+115
View File
@@ -0,0 +1,115 @@
import { heuteAlsTag, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Die Fristenampel.
*
* Farbe ist hier nie die einzige Information: Jede Stufe bringt ihren
* eigenen Text mit ("Überfällig seit 3 Tagen", "Heute fällig"). Wer die
* Farben nicht unterscheiden kann - und rot/grün ist die häufigste
* Farbfehlsichtigkeit -, liest denselben Sachverhalt im Klartext.
*/
export type Ampelstufe = 'ueberfaellig' | 'diese_woche' | 'dieser_monat' | 'spaeter' | 'erledigt'
export type Ampelvariante = 'rot' | 'orange' | 'gelb' | 'gruen' | 'secondary'
export type Ampel = {
stufe: Ampelstufe
variante: Ampelvariante
/** Klartext, der neben der Farbe steht. Nie weglassen. */
text: string
/** Tage bis zum Stichtag; negativ, wenn er vorbei ist. */
tage: number
}
/** Ab hier gilt eine Frist als "diese Woche" - eine volle Woche Vorlauf. */
const GRENZE_WOCHE = 7
/** Ab hier gilt sie als "dieser Monat". 31 Tage decken jeden Monat ab. */
const GRENZE_MONAT = 31
export const AMPEL_REIHENFOLGE: Ampelstufe[] = [
'ueberfaellig',
'diese_woche',
'dieser_monat',
'spaeter',
'erledigt',
]
export const AMPEL_UEBERSCHRIFT: Record<Ampelstufe, string> = {
ueberfaellig: 'Überfällig',
diese_woche: 'In den nächsten 7 Tagen',
dieser_monat: 'Im nächsten Monat',
spaeter: 'Später',
erledigt: 'Abgeschlossen',
}
export const AMPEL_ERKLAERUNG: Record<Ampelstufe, string> = {
ueberfaellig: 'Der Stichtag ist vorbei. Das gehört zuerst erledigt.',
diese_woche: 'Fällig innerhalb einer Woche.',
dieser_monat: 'Fällig innerhalb eines Monats.',
spaeter: 'Liegt im Plan, nichts zu tun.',
erledigt: 'Erledigt, verschoben oder entfallen.',
}
type Status = 'OFFEN' | 'ERLEDIGT' | 'VERSCHOBEN' | 'ENTFALLEN'
/**
* Stufe, Farbvariante und Text zu einem Stichtag.
*
* `heute` ist nur zum Testen gedacht - im Betrieb bleibt es beim aktuellen
* Tag.
*/
export function fristAmpel(datum: Date, status: Status = 'OFFEN', heute?: Date): Ampel {
const stichtag = tagesdatum(datum)
const bezug = heute ? tagesdatum(heute) : heuteAlsTag()
const tage = tageZwischen(bezug, stichtag)
// Abgeschlossene Fristen bekommen bewusst KEINE Ampelfarbe, sondern das
// neutrale Grau: Sie sollen in der Liste zurücktreten. Grün ist hier für
// "liegt im Plan" reserviert - also für etwas, das noch zu tun ist.
if (status === 'ERLEDIGT') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Erledigt', tage }
}
if (status === 'ENTFALLEN') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Entfallen', tage }
}
if (status === 'VERSCHOBEN') {
return { stufe: 'erledigt', variante: 'secondary', text: 'Verschoben', tage }
}
if (tage < 0) {
const dauer = tage === -1 ? 'seit gestern' : `seit ${Math.abs(tage)} Tagen`
return { stufe: 'ueberfaellig', variante: 'rot', text: `Überfällig ${dauer}`, tage }
}
if (tage === 0) {
return { stufe: 'diese_woche', variante: 'orange', text: 'Heute fällig', tage }
}
if (tage === 1) {
return { stufe: 'diese_woche', variante: 'orange', text: 'Morgen fällig', tage }
}
if (tage <= GRENZE_WOCHE) {
return { stufe: 'diese_woche', variante: 'orange', text: `Fällig in ${tage} Tagen`, tage }
}
if (tage <= GRENZE_MONAT) {
return { stufe: 'dieser_monat', variante: 'gelb', text: `Fällig in ${tage} Tagen`, tage }
}
const wochen = Math.round(tage / 7)
const text =
tage <= 70 ? `Fällig in ${wochen} Wochen` : `Fällig in ${Math.round(tage / 30)} Monaten`
return { stufe: 'spaeter', variante: 'gruen', text, tage }
}
/**
* Ausschlussfristen bekommen einen eigenen, deutlicheren Text: Hier geht
* es nicht um eine Mahnung, sondern um einen Anspruch, der ersatzlos weg
* ist. § 1878 Abs. 2 BGB kennt keine Wiedereinsetzung.
*/
export function ampelText(ampel: Ampel, ausschlussfrist: boolean): string {
if (!ausschlussfrist) return ampel.text
if (ampel.stufe === 'erledigt') return ampel.text
if (ampel.stufe === 'ueberfaellig') return `Anspruch verfallen — ${ampel.text.toLowerCase()}`
return `${ampel.text} — danach verfällt der Anspruch`
}
+218
View File
@@ -0,0 +1,218 @@
import { addDays, addMonths, addYears, differenceInCalendarDays } from 'date-fns'
/**
* Tagesdatum-Arithmetik fuer Fristen.
*
* Fristen liegen als @db.Date in der Datenbank, Prisma liefert sie als
* Date um UTC-Mitternacht. Rechnet man darauf direkt mit date-fns, rechnet
* date-fns in der Ortszeit: Aus dem 15.04. um 00:00 UTC wird in Berlin der
* 15.04. um 02:00 Ortszeit - und "+3 Monate" landet dann je nach Sommer-
* zeitwechsel auf dem 14. statt auf dem 15. Eine Frist, die um einen Tag
* verrutscht, ist im Betreuungsrecht keine Kleinigkeit.
*
* Deshalb der Umweg hier: Zum Rechnen wird das Datum auf die Ortszeit-
* Mittagsstunde gehoben (12 Uhr liegt in keiner Zeitzone in einer
* Umstellungsluecke), dort mit date-fns kalendarisch gerechnet, und das
* Ergebnis wieder auf UTC-Mitternacht zurueckgesetzt.
*/
/** Ein Datum auf UTC-Mitternacht normalisieren - so liegt es in der Datenbank. */
export function tagesdatum(datum: Date): Date {
return new Date(
Date.UTC(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 0, 0, 0, 0),
)
}
/** Tagesdatum aus Jahr/Monat(1-12)/Tag. */
export function tag(jahr: number, monat: number, tagImMonat: number): Date {
return new Date(Date.UTC(jahr, monat - 1, tagImMonat, 0, 0, 0, 0))
}
/** Heute als Tagesdatum, aus Sicht der Ortszeit des Servers. */
export function heuteAlsTag(jetzt = new Date()): Date {
return new Date(Date.UTC(jetzt.getFullYear(), jetzt.getMonth(), jetzt.getDate(), 0, 0, 0, 0))
}
/** In die Ortszeit-Mittagsstunde heben, damit date-fns kalendarisch rechnet. */
function zumRechnen(datum: Date): Date {
return new Date(datum.getUTCFullYear(), datum.getUTCMonth(), datum.getUTCDate(), 12, 0, 0, 0)
}
/** Zurueck auf UTC-Mitternacht. */
function vomRechnen(datum: Date): Date {
return new Date(Date.UTC(datum.getFullYear(), datum.getMonth(), datum.getDate(), 0, 0, 0, 0))
}
export function plusTage(datum: Date, anzahl: number): Date {
return vomRechnen(addDays(zumRechnen(datum), anzahl))
}
/**
* Kalendarisch, nicht in Tagen: 31.01. + 1 Monat ist der 28./29.02.
* date-fns kappt auf den letzten Tag des Zielmonats - genau so rechnet
* auch § 188 Abs. 3 BGB.
*/
export function plusMonate(datum: Date, anzahl: number): Date {
return vomRechnen(addMonths(zumRechnen(datum), anzahl))
}
/** Kalendarisch: 29.02. + 1 Jahr ist der 28.02. */
export function plusJahre(datum: Date, anzahl: number): Date {
return vomRechnen(addYears(zumRechnen(datum), anzahl))
}
/** Ganze Tage zwischen zwei Tagesdaten. Negativ, wenn "bis" schon vorbei ist. */
export function tageZwischen(von: Date, bis: Date): number {
return differenceInCalendarDays(zumRechnen(bis), zumRechnen(von))
}
export function istVor(a: Date, b: Date): boolean {
return tagesdatum(a).getTime() < tagesdatum(b).getTime()
}
export function istGleicherTag(a: Date, b: Date): boolean {
return tagesdatum(a).getTime() === tagesdatum(b).getTime()
}
/** "2026-09-14" - fuer Formularfelder vom Typ date und fuer ICS-Schluessel. */
export function alsIsoTag(datum: Date): string {
const d = tagesdatum(datum)
const monat = String(d.getUTCMonth() + 1).padStart(2, '0')
const tagImMonat = String(d.getUTCDate()).padStart(2, '0')
return `${d.getUTCFullYear()}-${monat}-${tagImMonat}`
}
/** Liest "2026-09-14" aus einem HTML-Datumsfeld als Tagesdatum. */
export function ausIsoTag(text: string): Date | null {
const treffer = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text.trim())
if (!treffer) return null
const jahr = Number(treffer[1])
const monat = Number(treffer[2])
const tagImMonat = Number(treffer[3])
const datum = tag(jahr, monat, tagImMonat)
// Fängt "2026-02-31" ab, das sonst still zum 03.03. würde.
if (datum.getUTCMonth() !== monat - 1 || datum.getUTCDate() !== tagImMonat) return null
return datum
}
// -----------------------------------------------------------------------------
// Wochenenden und Feiertage
// -----------------------------------------------------------------------------
const WOCHENTAGE = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag',
] as const
export function wochentagName(datum: Date): string {
return WOCHENTAGE[tagesdatum(datum).getUTCDay()]!
}
export function istWochenende(datum: Date): boolean {
const wochentag = tagesdatum(datum).getUTCDay()
return wochentag === 0 || wochentag === 6
}
/**
* Ostersonntag nach der Gaussschen Osterformel in der Fassung von Lichtenberg.
* Ohne ihn liessen sich Karfreitag, Ostermontag, Christi Himmelfahrt,
* Pfingstmontag und Fronleichnam nicht bestimmen.
*/
function ostersonntag(jahr: number): Date {
const a = jahr % 19
const b = Math.floor(jahr / 100)
const c = jahr % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const monat = Math.floor((h + l - 7 * m + 114) / 31)
const tagImMonat = ((h + l - 7 * m + 114) % 31) + 1
return tag(jahr, monat, tagImMonat)
}
/**
* Gesetzliche Feiertage in Bayern. Die betreute Person lebt in Bayern,
* das zustaendige Betreuungsgericht und der Bezirk sitzen dort - fuer die
* Frage "ist an diesem Tag jemand zu erreichen?" zaehlt dieses Bundesland.
*
* Mariae Himmelfahrt gilt nur in ueberwiegend katholischen Gemeinden. Da
* der Hinweis nur warnt und nichts verschiebt, ist er hier bewusst
* grosszuegig mitgenommen: lieber einmal zu viel gewarnt.
*/
export function feiertagName(datum: Date): string | null {
const d = tagesdatum(datum)
const jahr = d.getUTCFullYear()
const schluessel = alsIsoTag(d)
const ostern = ostersonntag(jahr)
const beweglich: Record<string, string> = {
[alsIsoTag(plusTage(ostern, -2))]: 'Karfreitag',
[alsIsoTag(ostern)]: 'Ostersonntag',
[alsIsoTag(plusTage(ostern, 1))]: 'Ostermontag',
[alsIsoTag(plusTage(ostern, 39))]: 'Christi Himmelfahrt',
[alsIsoTag(plusTage(ostern, 49))]: 'Pfingstsonntag',
[alsIsoTag(plusTage(ostern, 50))]: 'Pfingstmontag',
[alsIsoTag(plusTage(ostern, 60))]: 'Fronleichnam',
}
if (beweglich[schluessel]) return beweglich[schluessel]!
const fest: Record<string, string> = {
'01-01': 'Neujahr',
'01-06': 'Heilige Drei Könige',
'05-01': 'Tag der Arbeit',
'08-15': 'Mariä Himmelfahrt',
'10-03': 'Tag der Deutschen Einheit',
'11-01': 'Allerheiligen',
'12-25': '1. Weihnachtsfeiertag',
'12-26': '2. Weihnachtsfeiertag',
}
return fest[schluessel.slice(5)] ?? null
}
export function istFeiertag(datum: Date): boolean {
return feiertagName(datum) !== null
}
/**
* Liefert einen Hinweistext, wenn die Frist auf einen Tag faellt, an dem
* bei Gericht, Bezirk und Kasse niemand sitzt - sonst null.
*
* Verschoben wird bewusst NICHT. § 193 BGB verlaengert zwar das Fristende
* auf den naechsten Werktag, wenn der letzte Tag ein Samstag, Sonntag oder
* gesetzlicher Feiertag ist. Fuer die Arbeitsplanung waere es aber genau
* der falsche Schluss: Wer die Unterlagen erst am Montag fertigstellt, hat
* sie am Freitag nicht abgeschickt. Die Anwendung soll frueher fertig
* machen lassen, nicht spaeter. Ausserdem gilt § 193 BGB nicht fuer jede
* Frist gleichermassen (bei materiell-rechtlichen Ausschlussfristen ist die
* Anwendung umstritten) - sich darauf zu verlassen, kostet im Zweifel den
* Anspruch. Also: Datum stehen lassen, Nutzer warnen.
*/
export function nichtWerktagHinweis(datum: Date): string | null {
const feiertag = feiertagName(datum)
if (feiertag) {
return (
`Der Stichtag ist ein Feiertag (${feiertag}). Bitte vorher erledigen — ` +
`§ 193 BGB würde den Ablauf zwar auf den nächsten Werktag schieben, ` +
`verlass dich aber nicht darauf.`
)
}
if (istWochenende(datum)) {
return (
`Der Stichtag fällt auf einen ${wochentagName(datum)}. Bitte vorher erledigen — ` +
`§ 193 BGB würde den Ablauf zwar auf den nächsten Werktag schieben, ` +
`verlass dich aber nicht darauf.`
)
}
return null
}
+242
View File
@@ -0,0 +1,242 @@
import { createHmac, timingSafeEqual } from 'node:crypto'
import { env } from '@/lib/env'
import { tagesdatum } from '@/lib/fristen/datum'
/**
* Abonnierbarer Kalender (iCalendar, RFC 5545).
*
* Warum von Hand statt mit einer Bibliothek? Der Feed ist der einzige Weg,
* auf dem die Fristen ohne Anmeldung im Handykalender landen - er muss
* daher genau das enthalten, was noetig ist, und nichts weiter. Namen und
* Diagnosen haben in einem Kalender, den ein Mobilfunkanbieter mitliest,
* nichts verloren.
*
* Zugangsschutz ohne zusaetzliches Schemafeld: Die Adresse traegt eine
* Signatur aus betreuteId, userId und einem HMAC-SHA256 darueber mit dem
* AUTH_SECRET. Wer den Schluessel nicht hat, kann keine gueltige Adresse
* bauen. Der Nachteil ist bekannt: Die Adresse laesst sich nur widerrufen,
* indem AUTH_SECRET gewechselt wird - dafuer gibt es keinen Datensatz, der
* heimlich mitgelesen werden koennte.
*/
// -----------------------------------------------------------------------------
// Signatur
// -----------------------------------------------------------------------------
function hmacBerechnen(betreuteId: string, userId: string): string {
const geheim = process.env.AUTH_SECRET ?? ''
return createHmac('sha256', geheim).update(`${betreuteId}.${userId}`).digest('base64url')
}
/** `<betreuteId>.<userId>.<hmac>`, insgesamt base64url - ein Pfadsegment. */
export function signaturErzeugen(betreuteId: string, userId: string): string {
const roh = `${betreuteId}.${userId}.${hmacBerechnen(betreuteId, userId)}`
return Buffer.from(roh, 'utf8').toString('base64url')
}
/**
* Prueft die Signatur und gibt zurueck, wer da eigentlich fragt.
* Null heisst: nicht anfassen, nichts ausliefern.
*/
export function signaturPruefen(signatur: string): { betreuteId: string; userId: string } | null {
let roh: string
try {
roh = Buffer.from(signatur.replace(/\.ics$/i, ''), 'base64url').toString('utf8')
} catch {
return null
}
const teile = roh.split('.')
if (teile.length !== 3) return null
const [betreuteId, userId, mitgeliefert] = teile as [string, string, string]
if (!betreuteId || !userId || !mitgeliefert) return null
const erwartet = Buffer.from(hmacBerechnen(betreuteId, userId), 'utf8')
const vorhanden = Buffer.from(mitgeliefert, 'utf8')
// Laengenvergleich vorab, weil timingSafeEqual sonst wirft.
if (erwartet.length !== vorhanden.length) return null
if (!timingSafeEqual(erwartet, vorhanden)) return null
return { betreuteId, userId }
}
/** Die Adresse, die der Nutzer in seinen Kalender eintraegt. */
export function feedUrlErzeugen(betreuteId: string, userId: string): string {
return `${env.basisUrl}/api/kalender/${signaturErzeugen(betreuteId, userId)}.ics`
}
// -----------------------------------------------------------------------------
// Formatierung nach RFC 5545
// -----------------------------------------------------------------------------
/**
* Zeilenfaltung. RFC 5545 Abschnitt 3.1: Eine Inhaltszeile darf hoechstens
* 75 Oktette lang sein; laengere werden umgebrochen, und jede Folgezeile
* beginnt mit einem einzelnen Leerzeichen (das beim Lesen wieder entfaellt).
*
* Gezaehlt werden Oktette, nicht Zeichen - "Vermoegensuebersicht" mit
* Umlauten braucht in UTF-8 mehr Platz, als die Zeichenzahl vermuten
* laesst. Und ein Mehrbytezeichen darf dabei nicht mittendrin zerteilt
* werden, sonst zeigt der Kalender Fragezeichen an.
*/
export function zeileFalten(zeile: string): string {
const bytes = Buffer.from(zeile, 'utf8')
if (bytes.length <= 75) return zeile
const stuecke: string[] = []
let pos = 0
// Erste Zeile 75 Oktette, Folgezeilen 74 - das fuehrende Leerzeichen
// zaehlt mit.
let grenze = 75
while (pos < bytes.length) {
let ende = Math.min(pos + grenze, bytes.length)
// Nicht in ein UTF-8-Folgebyte (10xxxxxx) hineinschneiden.
while (ende > pos + 1 && ende < bytes.length && (bytes[ende]! & 0xc0) === 0x80) ende--
stuecke.push(bytes.subarray(pos, ende).toString('utf8'))
pos = ende
grenze = 74
}
return stuecke.join('\r\n ')
}
/** RFC 5545 Abschnitt 3.3.11: Backslash, Semikolon, Komma und Zeilenumbruch. */
export function textEscapen(wert: string): string {
return wert
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\r|\n/g, '\\n')
}
/** "20260914" - Datumswert ohne Uhrzeit. */
export function alsIcsDatum(datum: Date): string {
const d = tagesdatum(datum)
return (
`${d.getUTCFullYear()}` +
`${String(d.getUTCMonth() + 1).padStart(2, '0')}` +
`${String(d.getUTCDate()).padStart(2, '0')}`
)
}
/** "20260822T101500Z" - Zeitstempel in UTC. */
export function alsIcsZeitstempel(zeitpunkt: Date): string {
return `${zeitpunkt.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')}`
}
// -----------------------------------------------------------------------------
// Kalender bauen
// -----------------------------------------------------------------------------
export type IcsFrist = {
id: string
titel: string
beschreibung?: string | null
datum: Date
rechtsgrundlage?: string | null
typ: string
ausschlussfrist: boolean
vorwarnungTage: number[]
aktualisiertAm?: Date | null
}
export type IcsOptionen = {
/** Erscheint als Kalendername im Handy. Bewusst ohne vollen Namen. */
kalendername: string
fristen: IcsFrist[]
/** Nur zum Testen; sonst der Zeitpunkt der Anfrage. */
jetzt?: Date
/** Basisadresse fuer die Rueckverweise in die Anwendung. */
basisUrl?: string
}
/**
* Baut den vollstaendigen Kalender. Rein - kein Datenbankzugriff, damit
* sich die Ausgabe testen laesst.
*/
export function icsErzeugen({
kalendername,
fristen,
jetzt = new Date(),
basisUrl,
}: IcsOptionen): string {
const zeitstempel = alsIcsZeitstempel(jetzt)
const zeilen: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Betreuungs-Cockpit//Fristen//DE',
'CALSCALE:GREGORIAN',
'METHOD:PUBLISH',
`X-WR-CALNAME:${textEscapen(kalendername)}`,
'X-WR-TIMEZONE:Europe/Berlin',
// Beide Angaben: die eine kennt Apple, die andere der Rest der Welt.
'REFRESH-INTERVAL;VALUE=DURATION:PT6H',
'X-PUBLISHED-TTL:PT6H',
]
for (const frist of fristen) {
zeilen.push(...ereignisZeilen(frist, zeitstempel, basisUrl))
}
zeilen.push('END:VCALENDAR')
// RFC 5545 verlangt CRLF - und zwar auch am Ende der letzten Zeile.
return zeilen.map(zeileFalten).join('\r\n') + '\r\n'
}
function ereignisZeilen(frist: IcsFrist, zeitstempel: string, basisUrl?: string): string[] {
const beginn = tagesdatum(frist.datum)
// Ganztaegige Ereignisse enden nach RFC 5545 am Folgetag ("nicht
// einschliessend"). Ohne das +1 zeigen manche Kalender gar nichts an.
const ende = new Date(beginn.getTime() + 86_400_000)
const titel = frist.ausschlussfrist ? `${frist.titel} — Ausschlussfrist!` : frist.titel
const beschreibungsteile: string[] = []
if (frist.beschreibung) beschreibungsteile.push(frist.beschreibung)
if (frist.rechtsgrundlage) beschreibungsteile.push(`Rechtsgrundlage: ${frist.rechtsgrundlage}`)
if (frist.ausschlussfrist) {
beschreibungsteile.push(
'Ausschlussfrist: Läuft sie ab, ist der Anspruch ersatzlos verloren.',
)
}
beschreibungsteile.push('Keine Rechtsberatung — im Zweifel bei Gericht oder Bezirk nachfragen.')
if (basisUrl) beschreibungsteile.push(`${basisUrl}/fristen/${frist.id}`)
const zeilen = [
'BEGIN:VEVENT',
`UID:frist-${frist.id}@betreuungs-cockpit`,
`DTSTAMP:${zeitstempel}`,
`DTSTART;VALUE=DATE:${alsIcsDatum(beginn)}`,
`DTEND;VALUE=DATE:${alsIcsDatum(ende)}`,
`SUMMARY:${textEscapen(titel)}`,
`DESCRIPTION:${textEscapen(beschreibungsteile.join('\n\n'))}`,
`CATEGORIES:${textEscapen(frist.typ)}`,
// Der Termin blockt keine Zeit - er ist eine Erinnerung, kein Termin
// beim Amt. Sonst erscheint der Kalender den ganzen Tag als belegt.
'TRANSP:TRANSPARENT',
'CLASS:PRIVATE',
'STATUS:CONFIRMED',
`PRIORITY:${frist.ausschlussfrist ? 1 : 5}`,
]
if (basisUrl) zeilen.push(`URL:${basisUrl}/fristen/${frist.id}`)
if (frist.aktualisiertAm) {
zeilen.push(`LAST-MODIFIED:${alsIcsZeitstempel(frist.aktualisiertAm)}`)
}
// Je Vorwarnstufe ein eigener Wecker. "-P0D" waere keine gueltige Dauer,
// deshalb faellt die Stufe 0 heraus.
for (const tage of [...new Set(frist.vorwarnungTage)].filter((t) => t > 0).sort((a, b) => b - a)) {
zeilen.push(
'BEGIN:VALARM',
'ACTION:DISPLAY',
`TRIGGER;RELATED=START:-P${tage}D`,
`DESCRIPTION:${textEscapen(`In ${tage} Tagen fällig: ${titel}`)}`,
'END:VALARM',
)
}
zeilen.push('END:VEVENT')
return zeilen
}
+36
View File
@@ -0,0 +1,36 @@
import 'server-only'
import type { Betreute, User } from '@prisma/client'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import type { Zugriffsart } from '@/lib/auth/rollen'
/**
* Der immer gleiche Vorspann jeder Fristenseite: angemeldeter Benutzer,
* aktive betreute Person, geprueftes Zugriffsrecht auf das Modul FRISTEN.
*
* Die Pruefung laeuft ueber erfordereZugang() und damit gegen die
* betreuteId - nicht gegen das, was im Cookie steht. Ein manipuliertes
* Cookie kaeme hoechstens bis hierher.
*/
export type Fristenkontext = {
benutzer: User
betreute: Betreute
darfSchreiben: boolean
}
export async function fristenKontext(art: Zugriffsart = 'lesen'): Promise<Fristenkontext> {
const benutzer = await erfordereBenutzer('/fristen')
const aktive = await aktiveBetreute(benutzer)
if (!aktive) {
// Ohne betreute Person gibt es keine Fristen. Das App-Layout leitet in
// diesem Fall bereits in die Ersteinrichtung um; hier steht der Fall
// nur zur Sicherheit.
throw new Error('Es ist keine betreute Person ausgewählt.')
}
const kontext = await erfordereZugang(aktive.id, 'FRISTEN', art)
return {
benutzer: kontext.benutzer,
betreute: kontext.betreute,
darfSchreiben: kontext.darfSchreiben('FRISTEN'),
}
}
+528
View File
@@ -0,0 +1,528 @@
import {
heuteAlsTag,
istVor,
nichtWerktagHinweis,
plusJahre,
plusMonate,
plusTage,
tag,
tagesdatum,
} from '@/lib/fristen/datum'
/**
* Ableitung der gesetzlichen Pflichtfristen aus den Stammdaten.
*
* Das ist das Herzstueck der Anwendung. Ein ehrenamtlicher Betreuer bekommt
* vom Gericht keine Erinnerung; wer den Jahresbericht vergisst, bekommt
* Post vom Rechtspfleger, und wer die Aufwandspauschale vergisst, bekommt
* gar nichts mehr - der Anspruch ist dann weg (§ 1878 Abs. 2 BGB).
*
* Alles hier haengt am Bestellungsdatum. Ohne Bestellungsdatum gibt es
* keine ableitbare Pflichtfrist, und die Funktion liefert eine leere Liste.
*/
export type Pflichtfrist = {
/**
* Stabiler Schluessel, verhindert Dubletten: "anfangsbericht",
* "jahresbericht-2027", "aufwandspauschale-2026", ...
*/
systemSchluessel: string
titel: string
beschreibung: string
datum: Date
rechtsgrundlage: string
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
ausschlussfrist: boolean
vorwarnungTage: number[]
wiederholungRrule?: string
}
export type Pflichtfristenstammdaten = {
bestellungAm: Date | null
befreiterBetreuer: boolean
ehrenamtlich: boolean
pflegegrad: number | null
/** Wie viele kuenftige Faelligkeiten je wiederkehrender Pflicht? Standard 3. */
jahreVoraus?: number
/** Bezugstag fuer "im Voraus". Nur zum Testen zu setzen. */
heute?: Date
/** Seit wann besteht der Pflegegrad? Basis der Wiedervorlage nach § 18 SGB XI. */
pflegegradSeit?: Date | null
/** Abstand der Pflegegrad-Wiedervorlage in Monaten. Standard 12. */
pflegegradPruefungNachMonaten?: number
}
/** Regelvorwarnung: zwei Monate, ein Monat, eine Woche, ein Tag vorher. */
const VORWARNUNG_STANDARD = [60, 30, 7, 1]
/**
* Ausschlussfristen bekommen zusaetzlich eine Vorwarnung ein Vierteljahr
* vorher: Wer die Aufwandspauschale geltend machen will, braucht dafuer
* das abgelaufene Betreuungsjahr sauber abgerechnet - das macht niemand
* an einem Nachmittag.
*/
const VORWARNUNG_AUSSCHLUSS = [90, 60, 30, 14, 7, 1]
// -----------------------------------------------------------------------------
// Betreuungsjahr
// -----------------------------------------------------------------------------
/**
* Das Betreuungsjahr laeuft ab dem Tag der Bestellung, nicht ab dem
* Kalenderjahr (§ 1878 Abs. 1 Satz 2 BGB spricht ausdruecklich vom
* "Betreuungsjahr"). Bestellung am 15.03.2025 heisst: erstes
* Betreuungsjahr 15.03.2025 bis 14.03.2026.
*/
export function betreuungsjahrBeginn(bestellungAm: Date, nummer: number): Date {
if (nummer <= 1) return tagesdatum(bestellungAm)
// Der Tag nach dem Ende des vorigen Jahres - so bleibt die Kette auch
// ueber den 29. Februar hinweg lueckenlos.
return plusTage(betreuungsjahrEnde(bestellungAm, nummer - 1), 1)
}
/**
* Letzter Tag des Betreuungsjahres.
*
* Im Regelfall ist das der Tag vor dem naechsten Jahrestag: § 188 Abs. 2
* BGB laesst eine nach § 187 Abs. 2 BGB berechnete Frist (der Anfangstag
* zaehlt mit, so ist es beim Bestellungstag) mit dem Tag ablaufen, der dem
* Anfangstag vorhergeht. Bestellung am 15.03. heisst also Ende am 14.03.
*
* Ausnahme: Bestellung am 29. Februar. Im Folgejahr gibt es diesen Tag
* nicht; § 188 Abs. 3 BGB laesst die Frist dann mit dem letzten Tag des
* Monats enden, also am 28. Februar. date-fns kappt beim Jahresschritt
* ohnehin auf den 28. - dann ist genau dieser Tag das Ende, und es darf
* kein weiterer Tag abgezogen werden.
*/
export function betreuungsjahrEnde(bestellungAm: Date, nummer: number): Date {
const start = tagesdatum(bestellungAm)
const jahrestag = plusJahre(start, nummer)
const gekappt = jahrestag.getUTCDate() !== start.getUTCDate()
return gekappt ? jahrestag : plusTage(jahrestag, -1)
}
/** Nummer des Betreuungsjahres, in dem "heute" liegt (>= 1). */
export function laufendesBetreuungsjahr(bestellungAm: Date, heute: Date): number {
const start = tagesdatum(bestellungAm)
const bezug = tagesdatum(heute)
if (istVor(bezug, start)) return 1
// Grobschaetzung ueber die Kalenderjahre, danach exakt nachjustieren -
// das kostet hoechstens zwei Schleifendurchlaeufe.
let nummer = Math.max(1, bezug.getUTCFullYear() - start.getUTCFullYear())
while (istVor(betreuungsjahrEnde(start, nummer), bezug)) nummer++
while (nummer > 1 && !istVor(betreuungsjahrEnde(start, nummer - 1), bezug)) nummer--
return nummer
}
// -----------------------------------------------------------------------------
// Ableitung
// -----------------------------------------------------------------------------
function mitWerktagshinweis(beschreibung: string, datum: Date): string {
const hinweis = nichtWerktagHinweis(datum)
return hinweis ? `${beschreibung}\n\n${hinweis}` : beschreibung
}
function frist(
rohdaten: Omit<Pflichtfrist, 'vorwarnungTage'> & { vorwarnungTage?: number[] },
): Pflichtfrist {
return {
...rohdaten,
beschreibung: mitWerktagshinweis(rohdaten.beschreibung, rohdaten.datum),
vorwarnungTage:
rohdaten.vorwarnungTage ??
(rohdaten.ausschlussfrist ? VORWARNUNG_AUSSCHLUSS : VORWARNUNG_STANDARD),
}
}
/**
* Leitet alle Pflichtfristen aus den Stammdaten ab. Rein, ohne Datenbank.
*
* Wiederkehrende Pflichten werden nur fuer die naechsten `jahreVoraus`
* faelligen Termine erzeugt. Bereits verstrichene Termine kommen nicht in
* die Liste - mit einer Ausnahme, die weiter unten beschrieben ist.
*/
export function pflichtfristenAbleiten(stammdaten: Pflichtfristenstammdaten): Pflichtfrist[] {
const {
bestellungAm,
befreiterBetreuer,
ehrenamtlich,
pflegegrad,
jahreVoraus = 3,
pflegegradSeit = null,
pflegegradPruefungNachMonaten = 12,
} = stammdaten
// Ohne Bestellungsdatum haengt nichts in der Luft, sondern gar nichts:
// jede gesetzliche Frist rechnet von diesem Tag aus.
if (!bestellungAm) return []
const bestellung = tagesdatum(bestellungAm)
const heute = stammdaten.heute ? tagesdatum(stammdaten.heute) : heuteAlsTag()
const anzahl = Math.max(1, Math.trunc(jahreVoraus))
const ergebnis: Pflichtfrist[] = []
// --- Einmalige Fristen aus der Bestellung --------------------------------
// Sie werden auch dann erzeugt, wenn sie laengst verstrichen sind: Sie
// gehoeren zur Akte, und der Betreuer soll sehen koennen, dass er sie
// erledigt hat (oder eben nicht).
const dreiMonate = plusMonate(bestellung, 3)
ergebnis.push(
frist({
systemSchluessel: 'vermoegensverzeichnis',
titel: 'Vermögensverzeichnis beim Gericht einreichen',
beschreibung:
'Bestandsaufnahme des gesamten Vermögens zum Tag der Bestellung: Konten, ' +
'Bargeld, Versicherungen mit Rückkaufswert, Immobilien, Forderungen und ' +
'Verbindlichkeiten. Das Verzeichnis ist die Grundlage jeder späteren ' +
'Rechnungslegung — sitzt es schief, zieht sich der Fehler durch alle Jahre.',
datum: dreiMonate,
rechtsgrundlage: '§ 1835 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
vorwarnungTage: [60, 30, 14, 7, 1],
}),
frist({
systemSchluessel: 'anfangsbericht',
titel: 'Anfangsbericht beim Betreuungsgericht einreichen',
beschreibung:
'Bericht über die persönlichen Verhältnisse: Lebenssituation, Gesundheits- ' +
'zustand, Wohnsituation, die Wünsche der betreuten Person und die Ziele der ' +
'Betreuung. Der Bericht ist zusammen mit der betreuten Person zu besprechen, ' +
'soweit das möglich ist.',
datum: dreiMonate,
rechtsgrundlage: '§ 1863 Abs. 1 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
vorwarnungTage: [60, 30, 14, 7, 1],
}),
)
// --- Wiederkehrende Fristen am Betreuungsjahr ----------------------------
const laufend = laufendesBetreuungsjahr(bestellung, heute)
// Kandidaten grosszuegig erzeugen und danach filtern: das vorige
// Betreuungsjahr kommt bewusst mit, weil seine Aufwandspauschale noch
// sechs Monate lang geltend gemacht werden kann.
const kandidaten: number[] = []
for (let n = Math.max(1, laufend - 1); n <= laufend + anzahl; n++) kandidaten.push(n)
const jahresberichte: Pflichtfrist[] = []
const uebersichten: Pflichtfrist[] = []
const pauschalen: Pflichtfrist[] = []
const rueckkaufswerte: Pflichtfrist[] = []
for (const n of kandidaten) {
const ende = betreuungsjahrEnde(bestellung, n)
const beginn = betreuungsjahrBeginn(bestellung, n)
const jahr = ende.getUTCFullYear()
const zeitraum = `${formatKurz(beginn)}–${formatKurz(ende)}`
jahresberichte.push(
frist({
systemSchluessel: `jahresbericht-${jahr}`,
titel: `Jahresbericht ${jahr} an das Betreuungsgericht`,
beschreibung:
`Bericht über das Betreuungsjahr ${zeitraum}: Was hat sich bei Gesundheit, ` +
`Wohnen und persönlichen Verhältnissen verändert, welche Wünsche wurden ` +
`umgesetzt, wie oft gab es persönlichen Kontakt, wie soll die Betreuung ` +
`weitergehen. Stichtag ist das Ende des Betreuungsjahres.`,
datum: ende,
rechtsgrundlage: '§ 1863 Abs. 3 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
}),
)
// Befreite Betreuer - typischerweise Angehoerige, § 1859 Abs. 1 BGB -
// schulden nur die knappe Vermoegensuebersicht statt der vollen
// Rechnungslegung nach § 1865 BGB. Der Schluessel bleibt trotzdem
// gleich, damit ein Wechsel der Einstufung keine Dublette erzeugt.
uebersichten.push(
frist({
systemSchluessel: `vermoegensuebersicht-${jahr}`,
titel: befreiterBetreuer
? `Vermögensübersicht ${jahr} einreichen`
: `Rechnungslegung ${jahr} einreichen`,
beschreibung: befreiterBetreuer
? `Als befreiter Betreuer genügt die Übersicht über den Bestand des ` +
`Vermögens zum Ende des Betreuungsjahres ${zeitraum} — keine ` +
`Einzelabrechnung jeder Buchung. Kontostände, Versicherungen, ` +
`Verbindlichkeiten, jeweils mit Nachweis.`
: `Vollständige Rechnungslegung für das Betreuungsjahr ${zeitraum}: ` +
`geordnete Zusammenstellung aller Einnahmen und Ausgaben mit Belegen, ` +
`Anfangs- und Endbestand.`,
datum: ende,
rechtsgrundlage: befreiterBetreuer ? '§ 1859 Abs. 1 BGB' : '§ 1865 BGB',
typ: 'GESETZLICH',
ausschlussfrist: false,
}),
)
// Vorbereitung, keine gesetzliche Frist: Rueckkaufswerte kommen von den
// Versicherern erst nach Wochen. Sechs Wochen vor dem Stichtag anfragen.
rueckkaufswerte.push(
frist({
systemSchluessel: `rueckkaufswerte-${jahr}`,
titel: `Rückkaufswerte für ${jahr} anfordern`,
beschreibung:
`Bei allen Lebens-, Renten- und Sterbegeldversicherungen den aktuellen ` +
`Rückkaufswert zum Stichtag ${formatKurz(ende)} anfordern. Die Versicherer ` +
`brauchen dafür erfahrungsgemäß mehrere Wochen; ohne die Werte lässt sich ` +
`die Vermögensübersicht nicht abschließen.`,
datum: plusTage(ende, -42),
rechtsgrundlage: 'Vorbereitung der Vermögensübersicht',
typ: 'SELBST_GESETZT',
ausschlussfrist: false,
vorwarnungTage: [14, 7, 1],
}),
)
// Die Aufwandspauschale steht nur ehrenamtlichen Betreuern zu; ein
// Berufsbetreuer rechnet nach VBVG ab.
if (ehrenamtlich) {
// § 1878 Abs. 2 BGB: Der Anspruch erlischt, wenn er nicht binnen
// sechs Monaten nach Ablauf des Betreuungsjahres beim Betreuungs-
// gericht geltend gemacht wird. Keine Wiedereinsetzung, kein
// Nachfassen - das Geld ist dann weg. Deshalb ausschlussfrist=true.
const verfall = plusMonate(ende, 6)
pauschalen.push(
frist({
systemSchluessel: `aufwandspauschale-${jahr}`,
titel: `Aufwandspauschale ${jahr} geltend machen (Ausschlussfrist)`,
beschreibung:
`Die Pauschale für das Betreuungsjahr ${zeitraum} muss bis zum ` +
`${formatKurz(verfall)} beim Betreuungsgericht beantragt sein. ` +
`Danach erlischt der Anspruch ersatzlos — eine Verlängerung oder ` +
`Nachsicht gibt es nicht. Ein formloses Schreiben mit Aktenzeichen ` +
`und Betreuungsjahr genügt.`,
datum: verfall,
rechtsgrundlage: '§ 1878 BGB',
typ: 'GESETZLICH',
ausschlussfrist: true,
}),
)
}
}
ergebnis.push(
...kuenftige(jahresberichte, heute, anzahl),
...kuenftige(uebersichten, heute, anzahl),
...kuenftige(rueckkaufswerte, heute, anzahl),
...kuenftige(pauschalen, heute, anzahl + 1),
)
// --- Wiederkehrende Fristen am Kalenderjahr ------------------------------
const kalenderjahre: number[] = []
for (let j = heute.getUTCFullYear() - 1; j <= heute.getUTCFullYear() + anzahl; j++) {
kalenderjahre.push(j)
}
// Landespflegegeld gibt es in Bayern ab Pflegegrad 2. Unter Pflegegrad 2
// waere die Frist eine leere Erinnerung an einen Antrag ohne Anspruch.
if ((pflegegrad ?? 0) >= 2) {
const landespflegegeld = kalenderjahre.map((j) =>
frist({
systemSchluessel: `landespflegegeld-${j}`,
titel: `Bayerisches Landespflegegeld ${j} beantragen`,
beschreibung:
`1.000 € jährlich, unabhängig von Einkommen und Vermögen, ab Pflegegrad 2. ` +
`Der Antrag für das Pflegegeldjahr muss bis zum 31.12.${j} beim Landesamt ` +
`für Pflege gestellt sein. Das Geld steht der betreuten Person zu und ist ` +
`im Kassenbuch als Einnahme zu führen.`,
datum: tag(j, 12, 31),
rechtsgrundlage: 'Art. 2 BayLPflGG',
typ: 'BEHOERDLICH',
ausschlussfrist: false,
}),
)
ergebnis.push(...kuenftige(landespflegegeld, heute, anzahl))
}
// Zuzahlungsbefreiung gilt immer nur fuer ein Kalenderjahr und muss
// deshalb jedes Jahr neu beantragt werden. Frueh im Jahr beantragen, dann
// fallen die Zuzahlungen des laufenden Jahres nicht erst an.
const zuzahlung = kalenderjahre.map((j) =>
frist({
systemSchluessel: `zuzahlungsbefreiung-${j}`,
titel: `Zuzahlungsbefreiung ${j} bei der Krankenkasse beantragen`,
beschreibung:
`Die Belastungsgrenze liegt bei 2 % der jährlichen Bruttoeinnahmen, bei ` +
`schwerwiegend chronisch Kranken bei 1 %. Wird sie erreicht, befreit die ` +
`Kasse für den Rest des Kalenderjahres ${j} von allen Zuzahlungen. ` +
`Die Befreiung gilt nicht automatisch weiter — sie ist jedes Jahr neu zu ` +
`beantragen. Nachweise: Rentenbescheid und gesammelte Quittungen.`,
datum: tag(j, 1, 31),
rechtsgrundlage: '§ 62 SGB V',
typ: 'BEHOERDLICH',
ausschlussfrist: false,
vorwarnungTage: [30, 14, 7, 1],
}),
)
ergebnis.push(...kuenftige(zuzahlung, heute, anzahl))
// --- Wiedervorlage Pflegegrad -------------------------------------------
// Nur sinnvoll, solange noch Luft nach oben ist: bei Pflegegrad 5 gibt es
// nichts mehr hoeherzustufen.
if (pflegegrad !== null && pflegegrad >= 1 && pflegegrad < 5) {
const basis = pflegegradSeit ? tagesdatum(pflegegradSeit) : bestellung
ergebnis.push(
frist({
systemSchluessel: 'pflegegrad-pruefung',
titel: 'Pflegegrad überprüfen — Höherstufung nötig?',
beschreibung:
`Aktuell Pflegegrad ${pflegegrad}. Hat sich der Hilfebedarf erhöht — mehr ` +
`Unterstützung beim Waschen, Anziehen, Essen, häufigere Atemnot, mehr ` +
`nächtliche Hilfe? Dann lohnt ein Höherstufungsantrag bei der Pflegekasse. ` +
`Vorher ein Pflegetagebuch über zwei Wochen führen und die Pflegedoku des ` +
`Heims anfordern — ohne beides wird der MD-Termin zum Ratespiel.`,
datum: plusMonate(basis, Math.max(1, Math.trunc(pflegegradPruefungNachMonaten))),
rechtsgrundlage: '§ 18 SGB XI',
typ: 'WIEDERVORLAGE',
ausschlussfrist: false,
vorwarnungTage: [30, 7],
}),
)
}
return ergebnis.sort((a, b) => a.datum.getTime() - b.datum.getTime())
}
/** Nur noch nicht verstrichene Termine, hoechstens `anzahl` Stueck. */
function kuenftige(liste: Pflichtfrist[], heute: Date, anzahl: number): Pflichtfrist[] {
return liste
.filter((f) => !istVor(f.datum, heute))
.sort((a, b) => a.datum.getTime() - b.datum.getTime())
.slice(0, anzahl)
}
function formatKurz(datum: Date): string {
const d = tagesdatum(datum)
return `${String(d.getUTCDate()).padStart(2, '0')}.${String(d.getUTCMonth() + 1).padStart(2, '0')}.${d.getUTCFullYear()}`
}
// -----------------------------------------------------------------------------
// Schreiben
// -----------------------------------------------------------------------------
export type Ableitungsergebnis = {
angelegt: number
aktualisiert: number
unveraendert: number
}
/**
* Schreibt die abgeleiteten Pflichtfristen in die Datenbank, ohne Dubletten.
*
* Der Schutz vor Dubletten sitzt im Schema: @@unique([betreuteId,
* systemSchluessel]). Hier wird zusaetzlich unterschieden, was ueberhaupt
* angefasst werden darf:
*
* - noch nicht vorhanden -> anlegen
* - vorhanden und OFFEN -> Datum und Texte nachziehen (z.B. nachdem das
* Bestellungsdatum in den Stammdaten korrigiert
* wurde)
* - erledigt, verschoben, entfallen oder im Papierkorb -> in Ruhe lassen.
* Was der Betreuer abgehakt hat, darf ihm die Automatik nicht wieder
* aufmachen.
*
* Der Rueckgabewert ist die Zahl der neu angelegten Fristen - das ist die
* Zahl, die der Nutzer nach dem Klick sehen will.
*/
export async function pflichtfristenSchreiben(
betreuteId: string,
jahreVoraus = 3,
): Promise<number> {
const ergebnis = await pflichtfristenSchreibenAusfuehrlich(betreuteId, jahreVoraus)
return ergebnis.angelegt
}
/** Wie pflichtfristenSchreiben, liefert aber die volle Bilanz fuer die Meldung. */
export async function pflichtfristenSchreibenAusfuehrlich(
betreuteId: string,
jahreVoraus = 3,
): Promise<Ableitungsergebnis> {
// Der Prisma-Client wird erst hier geladen, nicht beim Import des Moduls.
// So laesst sich pflichtfristenAbleiten() in Vitest testen, ohne dass eine
// Datenbankverbindung aufgebaut oder ein Client erzeugt werden muss.
const { db } = await import('@/lib/db')
const betreute = await db.betreute.findUnique({
where: { id: betreuteId },
select: {
bestellungAm: true,
befreiterBetreuer: true,
ehrenamtlich: true,
pflegegrad: true,
pflegegradSeit: true,
},
})
if (!betreute) return { angelegt: 0, aktualisiert: 0, unveraendert: 0 }
const abgeleitet = pflichtfristenAbleiten({ ...betreute, jahreVoraus })
if (abgeleitet.length === 0) return { angelegt: 0, aktualisiert: 0, unveraendert: 0 }
// Bewusst OHNE nichtGeloescht: eine im Papierkorb liegende Frist belegt
// den eindeutigen Schluessel weiterhin. Wuerde sie hier fehlen, liefe das
// Anlegen in eine Verletzung der Eindeutigkeit.
const vorhandene = await db.frist.findMany({
where: { betreuteId, systemSchluessel: { in: abgeleitet.map((f) => f.systemSchluessel) } },
select: { id: true, systemSchluessel: true, status: true, geloeschtAm: true, datum: true },
})
const nachSchluessel = new Map(vorhandene.map((f) => [f.systemSchluessel!, f]))
let angelegt = 0
let aktualisiert = 0
let unveraendert = 0
for (const pflicht of abgeleitet) {
const alt = nachSchluessel.get(pflicht.systemSchluessel)
if (!alt) {
await db.frist.create({
data: {
betreuteId,
titel: pflicht.titel,
beschreibung: pflicht.beschreibung,
datum: pflicht.datum,
typ: pflicht.typ,
rechtsgrundlage: pflicht.rechtsgrundlage,
vorwarnungTage: pflicht.vorwarnungTage,
ausschlussfrist: pflicht.ausschlussfrist,
systemSchluessel: pflicht.systemSchluessel,
wiederholungRrule: pflicht.wiederholungRrule ?? null,
},
})
angelegt++
continue
}
if (alt.geloeschtAm || alt.status !== 'OFFEN') {
unveraendert++
continue
}
await db.frist.update({
where: { id: alt.id },
data: {
titel: pflicht.titel,
beschreibung: pflicht.beschreibung,
datum: pflicht.datum,
typ: pflicht.typ,
rechtsgrundlage: pflicht.rechtsgrundlage,
vorwarnungTage: pflicht.vorwarnungTage,
ausschlussfrist: pflicht.ausschlussfrist,
},
})
if (alt.datum.getTime() === pflicht.datum.getTime()) unveraendert++
else aktualisiert++
}
return { angelegt, aktualisiert, unveraendert }
}
+189
View File
@@ -0,0 +1,189 @@
import { Frequency, RRule } from 'rrule'
import { tagesdatum } from '@/lib/fristen/datum'
/**
* Wiederkehrende Fristen.
*
* Die Wiederholung steht als iCalendar-RRULE an der Frist
* ("FREQ=YEARLY;INTERVAL=1"). Gespeichert wird immer nur die naechste
* faellige Frist, nicht eine Reihe im Voraus - erst beim Erledigen entsteht
* die naechste. Sonst haette man nach fuenf Jahren eine Liste voller
* Termine, die nie jemand angefasst hat.
*
* Die neue Frist zeigt ueber vorgaengerFristId auf die erledigte. Damit
* laesst sich die Kette rueckwaerts lesen: "wann habe ich das zuletzt
* gemacht?" ist gegenueber dem Gericht eine haeufige Frage.
*
* Achtung, bewusste Entscheidung: Die abgeleiteten Pflichtfristen aus
* pflichtfristen.ts tragen KEINE RRULE. Sie werden aus dem Bestellungsdatum
* neu berechnet. Haetten sie zusaetzlich eine RRULE, entstuende beim
* Erledigen eine zweite Frist neben der ohnehin abgeleiteten - eine
* Dublette, die der eindeutige systemSchluessel nicht abfaengt, weil die
* Nachfolgefrist keinen Systemschluessel bekommt.
*/
export type FristFuerWiederholung = {
id: string
betreuteId: string
vorgangId?: string | null
titel: string
beschreibung?: string | null
datum: Date
typ: 'GESETZLICH' | 'BEHOERDLICH' | 'SELBST_GESETZT' | 'WIEDERVORLAGE'
rechtsgrundlage?: string | null
vorwarnungTage: number[]
wiederholungRrule?: string | null
ausschlussfrist: boolean
}
/** Bauplan der Nachfolgefrist, fertig fuer db.frist.create({ data }). */
export type Nachfolgefrist = {
betreuteId: string
vorgangId: string | null
titel: string
beschreibung: string | null
datum: Date
typ: FristFuerWiederholung['typ']
rechtsgrundlage: string | null
vorwarnungTage: number[]
wiederholungRrule: string
ausschlussfrist: boolean
vorgaengerFristId: string
}
/**
* Baut die Regel neu auf und setzt DTSTART auf das uebergebene Datum.
*
* Ein in der Regel mitgeliefertes DTSTART wird dabei absichtlich verworfen:
* Massgeblich ist immer der Termin der zuletzt erledigten Frist, sonst
* wandert eine monatliche Wiedervorlage nach dem ersten Verschieben wieder
* auf das urspruengliche Raster zurueck.
*/
function regelMitStart(regel: string, dtstart: Date): RRule {
const nurRrule = regel
.split(/\r?\n/)
.map((zeile) => zeile.trim())
.filter((zeile) => zeile !== '' && !/^DTSTART/i.test(zeile))
.join('\n')
.replace(/^RRULE:/i, '')
const geparst = RRule.parseString(nurRrule)
if (geparst.freq === undefined || geparst.freq === null) {
throw new Error('Die Wiederholungsregel enthält kein FREQ.')
}
// rrule rechnet ohne TZID in UTC. Da unsere Fristen ohnehin auf
// UTC-Mitternacht liegen, passt das genau zusammen.
return new RRule({ ...geparst, dtstart: tagesdatum(dtstart) })
}
/** Ist die Regel ueberhaupt lesbar? Fuer die Formularpruefung. */
export function rruleGueltig(regel: string): boolean {
try {
regelMitStart(regel, new Date(Date.UTC(2026, 0, 1)))
return true
} catch {
return false
}
}
/**
* Der naechste Termin nach `nachDatum` - der Tag selbst zaehlt nicht mit,
* sonst bekaeme man beim Erledigen dieselbe Frist noch einmal.
* Liefert null, wenn die Regel endet (COUNT/UNTIL erschoepft).
*/
export function naechsterTermin(regel: string, nachDatum: Date): Date | null {
let rule: RRule
try {
rule = regelMitStart(regel, nachDatum)
} catch {
return null
}
const treffer = rule.after(tagesdatum(nachDatum), false)
return treffer ? tagesdatum(treffer) : null
}
/**
* Der Bauplan der Frist, die beim Erledigen einer wiederkehrenden Frist
* entsteht. Null, wenn die Frist nicht wiederkehrt oder die Regel
* ausgelaufen ist.
*
* Gerechnet wird ab dem Solltermin der erledigten Frist, nicht ab dem Tag
* der Erledigung: Wer den Bericht drei Tage zu spaet abgibt, soll den
* naechsten trotzdem wieder zum Stichtag abgeben und nicht dauerhaft drei
* Tage nach hinten rutschen.
*/
export function naechsteFristAusErledigter(frist: FristFuerWiederholung): Nachfolgefrist | null {
if (!frist.wiederholungRrule) return null
const naechstes = naechsterTermin(frist.wiederholungRrule, frist.datum)
if (!naechstes) return null
return {
betreuteId: frist.betreuteId,
vorgangId: frist.vorgangId ?? null,
titel: frist.titel,
beschreibung: frist.beschreibung ?? null,
datum: naechstes,
typ: frist.typ,
rechtsgrundlage: frist.rechtsgrundlage ?? null,
vorwarnungTage: frist.vorwarnungTage,
wiederholungRrule: frist.wiederholungRrule,
ausschlussfrist: frist.ausschlussfrist,
vorgaengerFristId: frist.id,
}
}
// -----------------------------------------------------------------------------
// Auswahl fuer die Oberflaeche
// -----------------------------------------------------------------------------
/**
* Die Wiederholungen, die im Betreueralltag tatsaechlich vorkommen. Ein
* freies RRULE-Eingabefeld waere fuer die Zielgruppe eine Zumutung; wer
* eine exotische Regel braucht, kann sie ueber "Eigene Regel" setzen.
*/
export const WIEDERHOLUNGEN: { wert: string; beschriftung: string }[] = [
{ wert: '', beschriftung: 'Einmalig — wiederholt sich nicht' },
{ wert: 'FREQ=WEEKLY;INTERVAL=1', beschriftung: 'Wöchentlich' },
{ wert: 'FREQ=WEEKLY;INTERVAL=2', beschriftung: 'Alle zwei Wochen' },
{ wert: 'FREQ=MONTHLY;INTERVAL=1', beschriftung: 'Monatlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=3', beschriftung: 'Vierteljährlich' },
{ wert: 'FREQ=MONTHLY;INTERVAL=6', beschriftung: 'Halbjährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=1', beschriftung: 'Jährlich' },
{ wert: 'FREQ=YEARLY;INTERVAL=2', beschriftung: 'Alle zwei Jahre' },
]
const FREQUENZ_TEXT: Record<number, [string, string]> = {
[Frequency.DAILY]: ['Täglich', 'Tage'],
[Frequency.WEEKLY]: ['Wöchentlich', 'Wochen'],
[Frequency.MONTHLY]: ['Monatlich', 'Monate'],
[Frequency.YEARLY]: ['Jährlich', 'Jahre'],
}
/** Deutsche Klartextbeschreibung einer Regel, z.B. "Alle 3 Monate". */
export function rruleBeschreiben(regel: string | null | undefined): string {
if (!regel) return 'Einmalig'
const bekannt = WIEDERHOLUNGEN.find((w) => w.wert === regel)
if (bekannt && bekannt.wert !== '') return bekannt.beschriftung
try {
const geparst = RRule.parseString(regel.replace(/^RRULE:/i, ''))
const freq = geparst.freq
if (freq === undefined || freq === null) return 'Wiederkehrend'
const text = FREQUENZ_TEXT[freq]
if (!text) return 'Wiederkehrend'
const intervall = geparst.interval ?? 1
const grund = intervall === 1 ? text[0] : `Alle ${intervall} ${text[1]}`
if (geparst.count) return `${grund}, ${geparst.count}-mal`
if (geparst.until) {
return `${grund}, bis ${tagesdatum(geparst.until).toLocaleDateString('de-DE', { timeZone: 'UTC' })}`
}
return grund
} catch {
return 'Wiederkehrend'
}
}
+203
View File
@@ -0,0 +1,203 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { z } from 'zod'
/**
* Hinweis-Wissensbasis.
*
* Die Karten stehen in daten/hinweise.json und nicht im Code. Wer eine
* Rechtslage nachtraegt oder eine Formulierung schaerft, aendert eine
* JSON-Datei - kein Deployment noetig, kein TypeScript noetig.
*
* Bewusst ohne "server-only": die Pruefung und die Kontextzuordnung sind
* reine Funktionen und werden getestet. Nur hinweiseLaden() liest wirklich
* vom Dateisystem.
*
* Grundsatz beim Fehlerfall: Eine kaputte JSON-Datei darf die Anwendung
* nicht anhalten. Dann gibt es eben keine Hinweise und eine Warnung im
* Serverlog - die Fristen und Vorgaenge funktionieren weiter.
*/
export const HinweiskarteSchema = z.object({
/** Stabiler Schluessel, nur Kleinbuchstaben, Ziffern und Bindestriche. */
schluessel: z
.string()
.min(3)
.max(60)
.regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'Nur Kleinbuchstaben, Ziffern und Bindestriche.'),
titel: z.string().min(5).max(120),
text: z.string().min(40).max(2000),
rechtsgrundlage: z.string().min(2).max(120),
link: z
.string()
.url()
.refine((wert) => wert.startsWith('https://'), 'Nur https-Links.'),
/** Ausloeser: Modul, VORGANG:<Kategorie>, VORLAGE:<schluessel>, FRIST:<systemSchluessel>. */
kontext: z.array(z.string().min(1).max(80)).min(1),
gewicht: z.number().int().min(0).max(100),
})
export const HinweisdateiSchema = z.object({
version: z.number().int().positive(),
karten: z.array(HinweiskarteSchema).min(1),
})
export type Hinweiskarte = z.infer<typeof HinweiskarteSchema>
export type Hinweisdatei = z.infer<typeof HinweisdateiSchema>
export type Ladeergebnis = {
karten: Hinweiskarte[]
/** Leer, wenn alles in Ordnung war. Sonst lesbare Meldungen. */
fehler: string[]
}
/**
* Prueft rohe Daten gegen das Schema. Reine Funktion - kein Dateisystem,
* kein Werfen. Doppelte Schluessel sind ein Fehler: sonst ueberschreibt
* eine spaete Karte klammheimlich eine fruehere.
*/
export function hinweiseAusDaten(roh: unknown): Ladeergebnis {
const ergebnis = HinweisdateiSchema.safeParse(roh)
if (!ergebnis.success) {
return {
karten: [],
fehler: ergebnis.error.issues.map(
(problem) => `${problem.path.join('.') || '(Wurzel)'}: ${problem.message}`,
),
}
}
const gesehen = new Set<string>()
const doppelte: string[] = []
for (const karte of ergebnis.data.karten) {
if (gesehen.has(karte.schluessel)) doppelte.push(karte.schluessel)
gesehen.add(karte.schluessel)
}
if (doppelte.length > 0) {
return {
karten: [],
fehler: [`Doppelte Schlüssel in der Wissensbasis: ${doppelte.join(', ')}.`],
}
}
return { karten: ergebnis.data.karten, fehler: [] }
}
/** Wie hinweiseAusDaten, nimmt aber den Dateiinhalt als Text entgegen. */
export function hinweiseAusText(inhalt: string): Ladeergebnis {
let roh: unknown
try {
roh = JSON.parse(inhalt)
} catch (fehler) {
return {
karten: [],
fehler: [`Die Datei ist kein gültiges JSON: ${(fehler as Error).message}`],
}
}
return hinweiseAusDaten(roh)
}
/** Wo liegt die Wissensbasis? Relativ zum Projektverzeichnis. */
export const HINWEISE_DATEI = path.join(process.cwd(), 'daten', 'hinweise.json')
let zwischenspeicher: Ladeergebnis | null = null
/**
* Liest die Wissensbasis. Das Ergebnis wird gemerkt, damit nicht bei jedem
* Seitenaufruf die Platte angefasst wird; in der Entwicklung reicht ein
* Neustart des Servers, um Aenderungen zu sehen.
*/
export function hinweiseLaden(datei: string = HINWEISE_DATEI): Ladeergebnis {
if (datei === HINWEISE_DATEI && zwischenspeicher) return zwischenspeicher
let ergebnis: Ladeergebnis
try {
ergebnis = hinweiseAusText(readFileSync(datei, 'utf8'))
} catch (fehler) {
ergebnis = {
karten: [],
fehler: [`Die Wissensbasis ${datei} lässt sich nicht lesen: ${(fehler as Error).message}`],
}
}
if (ergebnis.fehler.length > 0) {
console.warn(
`[hinweise] Wissensbasis nicht verwendbar, es werden keine Hinweise angezeigt:\n` +
ergebnis.fehler.map((z) => ` - ${z}`).join('\n'),
)
}
if (datei === HINWEISE_DATEI) zwischenspeicher = ergebnis
return ergebnis
}
/** Nur fuer Tests und fuer ein spaeteres Neuladen zur Laufzeit. */
export function hinweiseZwischenspeicherLeeren(): void {
zwischenspeicher = null
}
// -----------------------------------------------------------------------------
// Kontextzuordnung
// -----------------------------------------------------------------------------
/**
* Passt ein Ausloeser der Karte auf einen Kontext der Seite?
*
* Verglichen wird ohne Ruecksicht auf Gross- und Kleinschreibung. Zusaetzlich
* gilt eine Praefixregel mit Bindestrich: Die Karte traegt
* "FRIST:jahresbericht" und passt damit auch auf "FRIST:jahresbericht-2027".
* Ohne diese Regel muesste jede Jahreszahl einzeln gepflegt werden.
*/
export function ausloeserPasst(ausloeser: string, kontext: string): boolean {
const a = ausloeser.trim().toLowerCase()
const k = kontext.trim().toLowerCase()
if (a === '' || k === '') return false
if (a === k) return true
return k.startsWith(`${a}-`)
}
/**
* Alle Karten, die zu mindestens einem der Kontexte passen - sortiert nach
* Gewicht absteigend, bei Gleichstand alphabetisch nach Titel, damit die
* Reihenfolge stabil bleibt.
*/
export function passendeHinweise(
karten: Hinweiskarte[],
kontexte: readonly string[],
grenze?: number,
): Hinweiskarte[] {
if (kontexte.length === 0) return []
const treffer = karten.filter((karte) =>
karte.kontext.some((ausloeser) => kontexte.some((k) => ausloeserPasst(ausloeser, k))),
)
treffer.sort((a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'))
return grenze === undefined ? treffer : treffer.slice(0, grenze)
}
/** Bequemer Aufruf aus einer Server Component. */
export function hinweiseFuerKontext(kontexte: readonly string[], grenze?: number): Hinweiskarte[] {
return passendeHinweise(hinweiseLaden().karten, kontexte, grenze)
}
/** Volltextsuche ueber Titel, Text und Rechtsgrundlage - fuer /hinweise. */
export function hinweiseSuchen(karten: Hinweiskarte[], suche: string): Hinweiskarte[] {
const begriff = suche.trim().toLowerCase()
const sortiert = [...karten].sort(
(a, b) => b.gewicht - a.gewicht || a.titel.localeCompare(b.titel, 'de'),
)
if (begriff === '') return sortiert
return sortiert.filter((karte) =>
[karte.titel, karte.text, karte.rechtsgrundlage, karte.schluessel, ...karte.kontext]
.join(' ')
.toLowerCase()
.includes(begriff),
)
}
/** Eine einzelne Karte - z.B. um sie gezielt neben ein Formular zu stellen. */
export function hinweisFinden(schluessel: string): Hinweiskarte | undefined {
return hinweiseLaden().karten.find((karte) => karte.schluessel === schluessel)
}
+88
View File
@@ -0,0 +1,88 @@
/**
* Inventarliste der Wertgegenstaende.
*
* Das Schema kennt kein eigenes Inventarstueck (siehe
* docs/schema-wuensche/substanz.md, Punkt 3). Gefuehrt wird jedes Stueck
* deshalb als Vermoegensposten mit der Art WERTGEGENSTAND, verknuepft mit
* der Immobilie; der geschaetzte Wert liegt als Wertstand mit der Quelle
* "eigene Schaetzung".
*
* Raum und Verbleib passen in kein vorhandenes Feld. Sie stehen deshalb
* als beschriftete Zeilen im ohnehin verschluesselten Notizfeld -
* geschrieben und gelesen ausschliesslich hier, damit das Format an genau
* einer Stelle steht und sich spaeter in eine echte Tabelle migrieren
* laesst.
*/
export const VERBLEIB_MOEGLICHKEITEN = [
'im Haus',
'im Heimzimmer',
'bei Angehörigen',
'eingelagert',
'verkauft',
'verschenkt',
'entsorgt',
'unbekannt',
] as const
export type Verbleib = (typeof VERBLEIB_MOEGLICHKEITEN)[number]
export type InventarNotiz = {
raum: string | null
verbleib: string | null
bemerkung: string | null
}
const MARKE_RAUM = 'Raum:'
const MARKE_VERBLEIB = 'Verbleib:'
const MARKE_BEMERKUNG = 'Bemerkung:'
/** Baut den Notiztext, der verschluesselt gespeichert wird. */
export function inventarNotizSchreiben(notiz: InventarNotiz): string | null {
const zeilen: string[] = []
if (notiz.raum) zeilen.push(`${MARKE_RAUM} ${notiz.raum}`)
if (notiz.verbleib) zeilen.push(`${MARKE_VERBLEIB} ${notiz.verbleib}`)
if (notiz.bemerkung) zeilen.push(`${MARKE_BEMERKUNG} ${notiz.bemerkung}`)
return zeilen.length > 0 ? zeilen.join('\n') : null
}
/**
* Liest den Notiztext zurueck. Was nicht dem Format entspricht - etwa eine
* von Hand geschriebene Notiz aus einem anderen Modul - landet in
* "bemerkung", damit nichts verloren geht.
*/
export function inventarNotizLesen(text: string | null | undefined): InventarNotiz {
const notiz: InventarNotiz = { raum: null, verbleib: null, bemerkung: null }
if (!text) return notiz
const rest: string[] = []
for (const zeile of text.split('\n')) {
const getrimmt = zeile.trim()
if (getrimmt.startsWith(MARKE_RAUM)) {
notiz.raum = getrimmt.slice(MARKE_RAUM.length).trim() || null
} else if (getrimmt.startsWith(MARKE_VERBLEIB)) {
notiz.verbleib = getrimmt.slice(MARKE_VERBLEIB.length).trim() || null
} else if (getrimmt.startsWith(MARKE_BEMERKUNG)) {
notiz.bemerkung = getrimmt.slice(MARKE_BEMERKUNG.length).trim() || null
} else if (getrimmt !== '') {
rest.push(getrimmt)
}
}
if (rest.length > 0) {
notiz.bemerkung = [notiz.bemerkung, ...rest].filter(Boolean).join(' ')
}
return notiz
}
/** Die Quelle, mit der ein geschaetzter Inventarwert abgelegt wird. */
export const INVENTAR_WERTQUELLE = 'eigene Schätzung'
/**
* Summe der geschaetzten Werte. Wichtig fuer die Frage, ob sich eine
* Haushaltsaufloesung ueber einen Verwerter ueberhaupt lohnt - meist tut
* sie es nicht, und dann gehoert das begruendet in die Akte.
*/
export function inventarSumme(stuecke: { wertCent: bigint | null }[]): bigint {
return stuecke.reduce((summe, stueck) => summe + (stueck.wertCent ?? 0n), 0n)
}
+192
View File
@@ -0,0 +1,192 @@
import type { BuchungKategorie, ImmobilienStatusSozialhilfe } from '@prisma/client'
/**
* Feste Begriffe rund um das Hausgrundstueck.
*
* Alles, was in der Oberflaeche als Auswahl erscheint, steht hier - und
* daneben die Erklaerung, was der Begriff bedeutet. Grundbuchrecht ist
* nichts, was man als ehrenamtlicher Betreuer im Kopf hat.
*/
// -----------------------------------------------------------------------------
// Grundbuch
// -----------------------------------------------------------------------------
export const GRUNDBUCH_ABTEILUNGEN = ['II', 'III'] as const
export type Grundbuchabteilung = (typeof GRUNDBUCH_ABTEILUNGEN)[number]
/**
* Warum die Unterscheidung wichtig ist: Eine Grundschuld in Abteilung III
* mindert den Wert des Grundstuecks um den noch valutierenden Betrag. Ein
* Wohnrecht in Abteilung II mindert ihn ebenfalls, laesst sich aber nicht
* einfach abloesen - es haengt am Leben des Berechtigten. Fuer die Frage,
* was das Sozialamt als verwertbares Vermoegen ansieht, ist das der
* Unterschied zwischen "verkaufen und Erloes einsetzen" und "praktisch
* unverkaeuflich".
*/
export const GRUNDBUCH_ABTEILUNG_ERKLAERUNG: Record<Grundbuchabteilung, string> = {
II:
'Abteilung II: Lasten und Beschränkungen — alles, was jemand anderes am ' +
'Grundstück darf oder was den Eigentümer einschränkt. Zum Beispiel ' +
'Wohnrecht, Nießbrauch, Wegerecht, Reallast, Vorkaufsrecht, aber auch ' +
'ein Betreuungs- oder Nacherbenvermerk.',
III:
'Abteilung III: Grundpfandrechte — Grundschulden und Hypotheken, also ' +
'Sicherheiten für Geld. Der eingetragene Betrag ist die Höchstsumme, ' +
'nicht die tatsächliche Restschuld: Ein längst getilgtes Darlehen kann ' +
'mit voller Grundschuld im Grundbuch stehen. Für den Wert zählt, was die ' +
'Bank noch zu fordern hat.',
}
export const BELASTUNG_ARTEN = [
'Grundschuld',
'Hypothek',
'Wohnrecht',
'Nießbrauch',
'Reallast',
'Wegerecht',
'Vorkaufsrecht',
'Nacherbenvermerk',
'Sonstiges',
] as const
export type Belastungsart = (typeof BELASTUNG_ARTEN)[number]
/** Welche Art gehoert ueblicherweise in welche Abteilung? Nur ein Vorschlag. */
export const BELASTUNG_ART_ABTEILUNG: Record<Belastungsart, Grundbuchabteilung> = {
Grundschuld: 'III',
Hypothek: 'III',
Wohnrecht: 'II',
Nießbrauch: 'II',
Reallast: 'II',
Wegerecht: 'II',
Vorkaufsrecht: 'II',
Nacherbenvermerk: 'II',
Sonstiges: 'II',
}
// -----------------------------------------------------------------------------
// Bewohnt-Status
// -----------------------------------------------------------------------------
export const BEWOHNT_STATUS = ['leerstehend', 'vermietet', 'selbst bewohnt'] as const
export type BewohntStatus = (typeof BEWOHNT_STATUS)[number]
export const BEWOHNT_STATUS_ERKLAERUNG: Record<BewohntStatus, string> = {
leerstehend:
'Niemand wohnt darin. Damit greifen die Pflichten bei Leerstand — ' +
'Versicherer informieren, Frostschutz, Verkehrssicherung.',
vermietet: 'Ein Mietverhältnis besteht. Mieteinnahmen gehören ins Kassenbuch.',
'selbst bewohnt':
'Die betreute Person bewohnt das Objekt selbst. Das ist die Voraussetzung ' +
'für den Schutz als angemessenes Hausgrundstück nach § 90 Abs. 2 Nr. 8 SGB XII.',
}
// -----------------------------------------------------------------------------
// Wertquellen
// -----------------------------------------------------------------------------
/**
* Pflichtangabe bei jedem Wertstand. Ein Wert ohne Quelle ist gegenueber
* Gericht und Bezirk wertlos - die erste Rueckfrage ist immer "woher haben
* Sie die Zahl?".
*/
export const WERTQUELLEN_IMMOBILIE = [
'Bodenrichtwert',
'Maklergutachten',
'Verkehrswertgutachten',
'eigene Schätzung',
] as const
export type WertquelleImmobilie = (typeof WERTQUELLEN_IMMOBILIE)[number]
export const WERTQUELLE_ERKLAERUNG: Record<WertquelleImmobilie, string> = {
Bodenrichtwert:
'Amtlicher Durchschnittswert je Quadratmeter Boden, kostenlos beim ' +
'Gutachterausschuss bzw. im BORIS-Portal Bayern. Er bewertet nur den ' +
'Grund und Boden, nicht das Gebäude — als Untergrenze brauchbar, als ' +
'Verkehrswert nicht.',
Maklergutachten:
'Unverbindliche Einschätzung eines Maklers, meist kostenlos. Gut für ' +
'einen ersten Anhalt und für das Gespräch mit dem Bezirk, aber kein ' +
'Nachweis gegenüber dem Gericht.',
Verkehrswertgutachten:
'Gutachten eines öffentlich bestellten Sachverständigen nach § 194 BauGB. ' +
'Kostet Geld, ist aber die belastbare Zahl — bei Verkauf und beim ' +
'Genehmigungsantrag ans Betreuungsgericht in der Regel unverzichtbar.',
'eigene Schätzung':
'Meine eigene Einschätzung. Immer als solche kennzeichnen und ' +
'begründen, damit später niemand sie für eine Bewertung hält.',
}
// -----------------------------------------------------------------------------
// Status im Sozialhilfeverfahren
// -----------------------------------------------------------------------------
export const IMMOBILIEN_STATUS_BESCHRIFTUNG: Record<ImmobilienStatusSozialhilfe, string> = {
UNGEKLAERT: 'ungeklärt',
SELBST_BEWOHNT_GESCHUETZT: 'selbst bewohnt und geschützt',
HAERTEFALL_GELTEND_GEMACHT: 'Härtefall geltend gemacht',
DARLEHEN_91_SGB_XII: 'Darlehen nach § 91 SGB XII',
VERKAUF_LAEUFT: 'Verkauf läuft',
VERWERTET: 'verwertet',
}
export const IMMOBILIEN_STATUS_ERKLAERUNG: Record<ImmobilienStatusSozialhilfe, string> = {
UNGEKLAERT:
'Mit dem Sozialhilfeträger ist noch nicht besprochen, wie das Grundstück ' +
'behandelt wird. Das ist der Ausgangszustand — er sollte nicht lange so bleiben.',
SELBST_BEWOHNT_GESCHUETZT:
'Ein angemessenes Hausgrundstück, das die betreute Person selbst bewohnt, ' +
'bleibt geschützt (§ 90 Abs. 2 Nr. 8 SGB XII). Nach dem dauerhaften Einzug ' +
'ins Heim entfällt dieser Schutz nach Auffassung der meisten Träger — ' +
'genau hier wird gestritten.',
HAERTEFALL_GELTEND_GEMACHT:
'Die Verwertung wäre eine Härte (§ 90 Abs. 3 SGB XII), zum Beispiel weil ' +
'ein Angehöriger dort lebt oder eine Rückkehr nicht ausgeschlossen ist. ' +
'Die Begründung gehört schriftlich zur Akte.',
DARLEHEN_91_SGB_XII:
'Der Träger leistet vorerst als Darlehen und sichert es meist durch eine ' +
'Grundschuld (§ 91 SGB XII). Das Haus bleibt zunächst, die Schuld wächst — ' +
'die Höhe im Blick behalten.',
VERKAUF_LAEUFT:
'Der Verkauf ist eingeleitet. Achtung: Der Verkauf eines Grundstücks ist ' +
'genehmigungspflichtig — die Genehmigung des Betreuungsgerichts muss vor ' +
'dem Notartermin vorliegen.',
VERWERTET: 'Das Objekt ist verkauft oder anderweitig verwertet, der Erlös vereinnahmt.',
}
export const IMMOBILIEN_STATUS_RECHTSGRUNDLAGE = '§ 90 Abs. 2 Nr. 8, § 91 SGB XII'
// -----------------------------------------------------------------------------
// Kostenkonto
// -----------------------------------------------------------------------------
/**
* Buchungskategorien, die zum Objekt gehoeren. Buchungen legt das
* Finanzmodul an; hier wird nur ausgewertet.
*
* Eine Zuordnung "Buchung gehoert zu diesem Objekt" gibt es im Schema
* nicht (siehe docs/schema-wuensche/substanz.md). Solange nur ein
* Grundstueck gefuehrt wird, ist die Zuordnung ueber die Kategorie
* eindeutig - die Auswertung sagt das ausdruecklich dazu.
*/
export const IMMOBILIEN_AUSGABE_KATEGORIEN = [
'GRUNDSTEUER',
'IMMOBILIE_BETRIEBSKOSTEN',
'IMMOBILIE_INSTANDHALTUNG',
] as const satisfies readonly BuchungKategorie[]
export const IMMOBILIEN_EINNAHME_KATEGORIEN = [
'MIETEINNAHME',
] as const satisfies readonly BuchungKategorie[]
export const IMMOBILIEN_KATEGORIEN: BuchungKategorie[] = [
...IMMOBILIEN_AUSGABE_KATEGORIEN,
...IMMOBILIEN_EINNAHME_KATEGORIEN,
]
export const IMMOBILIEN_KATEGORIE_BESCHRIFTUNG: Partial<Record<BuchungKategorie, string>> = {
GRUNDSTEUER: 'Grundsteuer',
IMMOBILIE_BETRIEBSKOSTEN: 'Betriebskosten (Gebäudeversicherung, Energie, Wasser, Schornsteinfeger)',
IMMOBILIE_INSTANDHALTUNG: 'Instandhaltung und Reparatur',
MIETEINNAHME: 'Mieteinnahme',
}
+119
View File
@@ -0,0 +1,119 @@
import type { BuchungKategorie, BuchungRichtung } from '@prisma/client'
import {
IMMOBILIEN_AUSGABE_KATEGORIEN,
IMMOBILIEN_EINNAHME_KATEGORIEN,
} from '@/lib/immobilie/konstanten'
/**
* Kostenkonto des Objekts.
*
* Nur lesend: Die Buchungen legt das Finanzmodul an. Hier werden sie je
* Jahr und Kategorie zusammengezaehlt, damit im Jahresbericht und im
* Gespraech mit dem Bezirk eine Zahl steht: "Das Haus kostet im Jahr X
* Euro, ohne dass jemand darin wohnt."
*
* Genau diese Zahl ist das Argument in der Haertefall- und
* Verwertungsdiskussion - und sie ist auch das Argument dafuer, ein
* leerstehendes Haus nicht jahrelang stehen zu lassen.
*/
export type KostenBuchung = {
datum: Date
betragCent: bigint
richtung: BuchungRichtung
kategorie: BuchungKategorie
}
export type Kostenzeile = {
kategorie: BuchungKategorie
summeCent: bigint
anzahl: number
}
export type Kostenjahr = {
jahr: number
ausgabenCent: bigint
einnahmenCent: bigint
/** Einnahmen minus Ausgaben. Negativ heisst: Das Objekt kostet Geld. */
saldoCent: bigint
ausgaben: Kostenzeile[]
einnahmen: Kostenzeile[]
anzahlBuchungen: number
}
const AUSGABEN = new Set<string>(IMMOBILIEN_AUSGABE_KATEGORIEN)
const EINNAHMEN = new Set<string>(IMMOBILIEN_EINNAHME_KATEGORIEN)
export function istImmobilienbuchung(kategorie: BuchungKategorie): boolean {
return AUSGABEN.has(kategorie) || EINNAHMEN.has(kategorie)
}
/**
* Baut die Jahresuebersicht. Neueste Jahre zuerst - danach schaut man
* zuerst.
*
* Umbuchungen bleiben aussen vor: Sie verschieben Geld zwischen eigenen
* Konten und waeren in einer Kostenaufstellung eine Doppelzaehlung.
*/
export function kostenkontoAufbauen(buchungen: KostenBuchung[]): Kostenjahr[] {
const jahre = new Map<number, Map<BuchungKategorie, Kostenzeile>>()
for (const buchung of buchungen) {
if (buchung.richtung === 'UMBUCHUNG') continue
if (!istImmobilienbuchung(buchung.kategorie)) continue
const jahr = buchung.datum.getUTCFullYear()
let zeilen = jahre.get(jahr)
if (!zeilen) {
zeilen = new Map()
jahre.set(jahr, zeilen)
}
const vorhanden = zeilen.get(buchung.kategorie)
if (vorhanden) {
vorhanden.summeCent += buchung.betragCent
vorhanden.anzahl += 1
} else {
zeilen.set(buchung.kategorie, {
kategorie: buchung.kategorie,
summeCent: buchung.betragCent,
anzahl: 1,
})
}
}
const ergebnis: Kostenjahr[] = []
for (const [jahr, zeilen] of jahre) {
const alle = [...zeilen.values()]
const ausgaben = alle
.filter((z) => AUSGABEN.has(z.kategorie))
.sort((a, b) => Number(b.summeCent - a.summeCent))
const einnahmen = alle
.filter((z) => EINNAHMEN.has(z.kategorie))
.sort((a, b) => Number(b.summeCent - a.summeCent))
const ausgabenCent = ausgaben.reduce((summe, z) => summe + z.summeCent, 0n)
const einnahmenCent = einnahmen.reduce((summe, z) => summe + z.summeCent, 0n)
ergebnis.push({
jahr,
ausgaben,
einnahmen,
ausgabenCent,
einnahmenCent,
saldoCent: einnahmenCent - ausgabenCent,
anzahlBuchungen: alle.reduce((summe, z) => summe + z.anzahl, 0),
})
}
return ergebnis.sort((a, b) => b.jahr - a.jahr)
}
/** Durchschnittliche Jahreskosten ueber alle vollstaendig erfassten Jahre. */
export function durchschnittlicheJahreskosten(jahre: Kostenjahr[], laufendesJahr: number): bigint {
const abgeschlossen = jahre.filter((j) => j.jahr < laufendesJahr)
if (abgeschlossen.length === 0) return 0n
const summe = abgeschlossen.reduce((wert, j) => wert + j.ausgabenCent, 0n)
return summe / BigInt(abgeschlossen.length)
}
+245
View File
@@ -0,0 +1,245 @@
import { heuteAlsTag, plusJahre, plusTage, tag, tagesdatum } from '@/lib/fristen/datum'
/**
* Pflichten bei Leerstand.
*
* Das ist der Teil, der einem als Betreuer wirklich um die Ohren fliegen
* kann. Ein leerstehendes Haus ist kein ruhender Vermoegensposten, sondern
* eine laufende Verpflichtung: Der Gebaeudeversicherer wird leistungsfrei,
* wenn ihm der Leerstand nicht angezeigt wurde. Ein geplatztes Rohr im
* Januar kostet dann den Schaden aus dem eigenen Vermoegen der betreuten
* Person - und die Frage, warum niemand Bescheid gesagt hat, landet im
* Jahresbericht.
*
* Deshalb traegt jeder Punkt hier nicht nur einen Titel, sondern auch die
* Begruendung, warum er wichtig ist. Wer den Punkt abhakt, soll wissen,
* wovor er sich gerade geschuetzt hat.
*
* Reine Logik, keine Datenbank: Die Liste wird beim Anlegen einer
* leerstehenden Immobilie in ImmobilienCheckpunkt uebernommen.
*/
export type LeerstandPunkt = {
/** Stabiler Schluessel, damit die Uebernahme keine Dubletten erzeugt. */
schluessel: string
titel: string
/** Warum ist der Punkt wichtig? Steht so in der Oberflaeche. */
beschreibung: string
faelligAm: Date
/** iCalendar-RRULE fuer wiederkehrende Punkte, sonst undefined. */
wiederholungRrule?: string
reihenfolge: number
/**
* Punkte, bei denen ein Versaeumnis unmittelbar Geld kostet oder eine
* Haftung ausloest. In der Oberflaeche rot.
*/
dringend: boolean
}
/** Der Monat, in dem der Frostschutz stehen muss: Oktober. */
export const FROSTSCHUTZ_MONAT = 10
export const FROSTSCHUTZ_TAG = 1
/** Der Winterdienst muss organisiert sein, bevor der Frostschutz greift. */
export const WINTERDIENST_MONAT = 10
export const WINTERDIENST_TAG = 15
/**
* Der naechste 1. Oktober ab einem Stichtag. Faellt der Stichtag selbst
* auf den 1. Oktober, ist es dieser Tag - nicht erst der des Folgejahres.
*/
export function naechsterStichtagImJahr(
ab: Date,
monat: number,
tagImMonat: number,
): Date {
const basis = tagesdatum(ab)
const inDiesemJahr = tag(basis.getUTCFullYear(), monat, tagImMonat)
if (inDiesemJahr.getTime() >= basis.getTime()) return inDiesemJahr
return tag(basis.getUTCFullYear() + 1, monat, tagImMonat)
}
/**
* Erzeugt die Checkliste fuer ein leerstehendes Objekt.
*
* @param leerstandSeit Beginn des Leerstands. Fehlt er, wird ab heute gerechnet.
* @param heute Bezugstag (Test).
*/
export function leerstandCheckliste(
leerstandSeit?: Date | null,
heute: Date = heuteAlsTag(),
): LeerstandPunkt[] {
const start = leerstandSeit ? tagesdatum(leerstandSeit) : tagesdatum(heute)
// Faelligkeiten liegen nie in der Vergangenheit: Ein Punkt, der beim
// Anlegen schon ueberfaellig waere, hilft niemandem weiter.
const abHeute = (datum: Date) => (datum.getTime() < heute.getTime() ? tagesdatum(heute) : datum)
return [
{
schluessel: 'gebaeudeversicherung-leerstand-melden',
titel: 'Gebäudeversicherer über den Leerstand informieren',
beschreibung:
'Schriftlich, am besten per Einschreiben, und die Antwort zur Akte nehmen. ' +
'Leerstand ist eine Gefahrerhöhung: Wer sie nicht anzeigt, riskiert, dass ' +
'der Versicherer im Schadensfall leistungsfrei wird — dann zahlt niemand ' +
'den Wasserschaden. Viele Verträge verlangen bei Leerstand zusätzlich ' +
'wöchentliche Kontrollgänge und abgestellte Wasserzufuhr. Frag ausdrücklich ' +
'nach, welche Auflagen gelten, und halte sie schriftlich fest.',
faelligAm: abHeute(plusTage(start, 7)),
reihenfolge: 10,
dringend: true,
},
{
schluessel: 'kontrollgang',
titel: 'Kontrollgang durch das Haus',
beschreibung:
'Einmal pro Woche durch alle Räume, Keller und Dachboden: Wasser, Schimmel, ' +
'eingeschlagene Scheiben, Einbruchsspuren. Datum und Ergebnis notieren — ' +
'die meisten Gebäudeversicherer verlangen bei Leerstand regelmäßige ' +
'Kontrollen und wollen im Schadensfall den Nachweis sehen.',
faelligAm: abHeute(plusTage(start, 7)),
wiederholungRrule: 'FREQ=WEEKLY;INTERVAL=1',
reihenfolge: 20,
dringend: true,
},
{
schluessel: 'frostschutz',
titel: 'Frostschutz: Wasser abstellen, Leitungen entleeren, Heizung auf Frostschutz',
beschreibung:
'Haupthahn zu, Leitungen und Heizkörper entleeren oder die Heizung dauerhaft ' +
'auf Frostschutzstellung — beides zusammen geht nicht. Ein einziges geplatztes ' +
'Rohr richtet in einem leeren Haus über Wochen unbemerkt Schaden an. Jedes Jahr ' +
'vor der ersten Frostnacht erledigen, deshalb im Oktober.',
faelligAm: naechsterStichtagImJahr(heute, FROSTSCHUTZ_MONAT, FROSTSCHUTZ_TAG),
wiederholungRrule: `FREQ=YEARLY;BYMONTH=${FROSTSCHUTZ_MONAT};BYMONTHDAY=${FROSTSCHUTZ_TAG}`,
reihenfolge: 30,
dringend: true,
},
{
schluessel: 'winterdienst-verkehrssicherung',
titel: 'Winterdienst und Verkehrssicherungspflicht organisieren',
beschreibung:
'Räum- und Streupflicht auf Gehweg und Grundstückszufahrt trifft den ' +
'Eigentümer, auch wenn niemand im Haus wohnt. Dazu kommen lose Dachziegel, ' +
'morsche Äste und der Zustand der Treppe. Wer hier nichts veranlasst, haftet ' +
'persönlich für den Sturz eines Postboten. Schriftliche Beauftragung eines ' +
'Dienstleisters oder Nachbarn, Nachweis zur Akte.',
faelligAm: naechsterStichtagImJahr(heute, WINTERDIENST_MONAT, WINTERDIENST_TAG),
wiederholungRrule: `FREQ=YEARLY;BYMONTH=${WINTERDIENST_MONAT};BYMONTHDAY=${WINTERDIENST_TAG}`,
reihenfolge: 40,
dringend: true,
},
{
schluessel: 'zaehlerstaende',
titel: 'Zählerstände ablesen und dokumentieren',
beschreibung:
'Strom, Gas, Wasser mit Datum und Foto festhalten. Nur so lassen sich ' +
'Abschläge senken, Nachzahlungen prüfen und ein schleichender Wasserverlust ' +
'überhaupt bemerken. Bei einem leeren Haus ist jeder Verbrauch erklärungs' +
'bedürftig — auch gegenüber dem Bezirk.',
faelligAm: abHeute(plusTage(start, 7)),
wiederholungRrule: 'FREQ=MONTHLY;INTERVAL=3',
reihenfolge: 50,
dringend: false,
},
{
schluessel: 'schluesselverzeichnis',
titel: 'Schlüsselverzeichnis anlegen: wer hat welchen Schlüssel',
beschreibung:
'Jeder Schlüssel mit Nummer, Art (Haustür, Keller, Garage) und Person, ' +
'Übergabedatum und Unterschrift. Bei einem Einbruch ohne Aufbruchspuren ' +
'fragt der Versicherer als Erstes danach. Und beim späteren Verkauf muss ' +
'vollständig übergeben werden.',
faelligAm: abHeute(plusTage(start, 14)),
reihenfolge: 60,
dringend: false,
},
{
schluessel: 'fotodokumentation',
titel: 'Fotodokumentation vor der Räumung',
beschreibung:
'Jeden Raum vollständig fotografieren, bevor etwas bewegt wird, dazu ' +
'Wertgegenstände einzeln. Das schützt vor dem Vorwurf, es sei etwas ' +
'verschwunden, belegt den Zustand des Hauses gegenüber Käufern und ' +
'Versicherern und ist die Grundlage der Inventarliste. Fotos als Dokument ' +
'mit der Art „Foto“ hochladen und mit dem Objekt verknüpfen.',
faelligAm: abHeute(plusTage(start, 14)),
reihenfolge: 70,
dringend: false,
},
{
schluessel: 'briefkasten-nachsendeauftrag',
titel: 'Briefkasten leeren und Nachsendeauftrag einrichten',
beschreibung:
'Ein überquellender Briefkasten zeigt jedem, dass niemand da ist — und ' +
'wichtiger: Bescheide mit Rechtsbehelfsfristen gelten als zugegangen, auch ' +
'wenn sie ungelesen im Kasten liegen. Nachsendeauftrag auf die eigene ' +
'Anschrift, zusätzlich die Absender direkt umschreiben lassen.',
faelligAm: abHeute(plusTage(start, 7)),
reihenfolge: 80,
dringend: true,
},
{
schluessel: 'rauchmelder',
titel: 'Rauchmelder prüfen',
beschreibung:
'In Bayern sind Rauchwarnmelder in Schlaf- und Kinderzimmern sowie in ' +
'Fluren Pflicht (Art. 46 Abs. 4 BayBO), die jährliche Prüfung ebenso. ' +
'Der Eigentümer bleibt dafür verantwortlich, auch bei Leerstand. Batterien ' +
'wechseln, Funktionsprüfung mit Datum notieren.',
faelligAm: abHeute(plusTage(start, 30)),
wiederholungRrule: 'FREQ=YEARLY;INTERVAL=1',
reihenfolge: 90,
dringend: false,
},
{
schluessel: 'heizungswartung',
titel: 'Heizungswartung und Schornsteinfegertermin',
beschreibung:
'Läuft die Heizung im Frostschutzbetrieb weiter, muss sie gewartet bleiben, ' +
'sonst fällt sie im ungünstigsten Moment aus. Der Schornsteinfeger kommt ' +
'ohnehin und braucht Zugang — Termin und Schlüsselübergabe organisieren, ' +
'sonst kommt eine kostenpflichtige zweite Anfahrt.',
faelligAm: abHeute(plusTage(start, 30)),
wiederholungRrule: 'FREQ=YEARLY;INTERVAL=1',
reihenfolge: 100,
dringend: false,
},
]
}
/**
* Alle Punkte, die naechstes Jahr wieder anstehen - fuer den Hinweis in
* der Oberflaeche, dass die Liste kein einmaliges Abhaken ist.
*/
export function wiederkehrendePunkte(punkte: LeerstandPunkt[]): LeerstandPunkt[] {
return punkte.filter((p) => Boolean(p.wiederholungRrule))
}
/**
* Der naechste Termin eines wiederkehrenden Punktes nach dem Erledigen.
* Bewusst schlicht gehalten: Woche, Quartal, Jahr - mehr kommt in dieser
* Liste nicht vor.
*/
export function naechsteFaelligkeit(rrule: string, erledigtAm: Date): Date | null {
const regel = rrule.toUpperCase()
if (regel.includes('FREQ=WEEKLY')) return plusTage(erledigtAm, 7)
if (regel.includes('FREQ=MONTHLY')) {
const treffer = /INTERVAL=(\d+)/.exec(regel)
const monate = treffer?.[1] ? Number(treffer[1]) : 1
return plusTage(erledigtAm, monate * 30)
}
if (regel.includes('FREQ=YEARLY')) {
const monat = /BYMONTH=(\d+)/.exec(regel)?.[1]
const tagImMonat = /BYMONTHDAY=(\d+)/.exec(regel)?.[1]
if (monat && tagImMonat) {
return naechsterStichtagImJahr(
plusTage(erledigtAm, 1),
Number(monat),
Number(tagImMonat),
)
}
return plusJahre(erledigtAm, 1)
}
return null
}
+47
View File
@@ -0,0 +1,47 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import {
statistikBerechnen,
textbausteinErzeugen,
textkontextAusAnrede,
type Kontaktstatistik,
} from '@/lib/kontakte/statistik'
/**
* Die Auswertung, aus der spaeter der Jahresbericht nach § 1863 BGB
* gespeist wird: wie oft bestand Kontakt, wie oft davon persoenlich, wie
* lange insgesamt - und ein fertiger Absatz in ganzen Saetzen.
*
* Gerechnet wird auf den unverschluesselten Feldern (Datum, Art, Dauer,
* mitBetreuter). Die Freitexte bleiben unangetastet: fuer die Statistik
* werden sie nicht gebraucht, und was nicht entschluesselt wird, kann auch
* nicht versehentlich irgendwo landen.
*/
export type KontaktAuswertung = Kontaktstatistik & {
/** Fertige Saetze zur Uebernahme in den Jahresbericht. */
textbaustein: string
}
export async function kontaktstatistik(
betreuteId: string,
von: Date,
bis: Date,
): Promise<KontaktAuswertung> {
const [protokolle, betreute] = await Promise.all([
db.kontaktprotokoll.findMany({
where: { betreuteId, datum: { gte: von, lte: bis }, ...nichtGeloescht },
orderBy: { datum: 'asc' },
select: { datum: true, art: true, dauerMinuten: true, mitBetreuter: true },
}),
db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { anrede: true, heimName: true },
}),
])
const statistik = statistikBerechnen(protokolle, von, bis)
const kontext = textkontextAusAnrede(betreute?.anrede, betreute?.heimName)
return { ...statistik, textbaustein: textbausteinErzeugen(statistik, kontext) }
}
+299
View File
@@ -0,0 +1,299 @@
import { differenceInCalendarMonths } from 'date-fns'
import type { KontaktArt } from '@prisma/client'
/**
* Auswertung der Kontaktprotokolle - ohne Datenbank, ohne React.
*
* Der Jahresbericht nach § 1863 BGB will wissen, ob und wie oft
* persoenlicher Kontakt zur betreuten Person bestand. Diese Datei zaehlt
* das aus und schreibt daraus ganze Saetze, die sich in den Bericht
* uebernehmen lassen. Was hier steht, ist eine Zusammenfassung meiner
* eigenen Aufzeichnungen - keine Rechtsauskunft darueber, ob der Umfang
* ausreicht.
*/
export type ProtokollFuerStatistik = {
datum: Date
art: KontaktArt
dauerMinuten: number | null
mitBetreuter: boolean
}
export type Monatszeile = {
jahr: number
monat: number
gesamt: number
/** Davon persoenlicher Kontakt zur betreuten Person selbst. */
persoenlich: number
}
export type Kontaktstatistik = {
von: Date
bis: Date
/** Volle bzw. angefangene Kalendermonate im Zeitraum. */
monateImZeitraum: number
gesamt: number
/** Protokolle mit mitBetreuter = true. */
persoenlich: number
/** Kalendertage, an denen ein persoenlicher Kontakt stattfand. */
persoenlicheTage: number
/** Besuchstage bei der betreuten Person (Besuch oder Vorsprache vor Ort). */
besuchstage: number
telefonate: number
schriftlich: number
/** Monate mit mindestens einem persoenlichen Kontakt. */
monateMitPersoenlichemKontakt: number
jeArt: Record<KontaktArt, number>
jeMonat: Monatszeile[]
gesamtdauerMinuten: number
/** Zu wie vielen Protokollen ist ueberhaupt eine Dauer erfasst? */
mitDauerErfasst: number
}
/** Persoenliche Begegnung vor Ort. */
const ARTEN_BESUCH: KontaktArt[] = ['BESUCH', 'VORSPRACHE']
/** Gespraech auf Distanz. */
const ARTEN_TELEFON: KontaktArt[] = ['TELEFONAT', 'VIDEOCALL']
/** Schriftverkehr. */
const ARTEN_SCHRIFTLICH: KontaktArt[] = ['EMAIL', 'BRIEF', 'FAX']
const ALLE_ARTEN: KontaktArt[] = [
'BESUCH',
'TELEFONAT',
'EMAIL',
'BRIEF',
'VIDEOCALL',
'FAX',
'VORSPRACHE',
]
function tagesschluessel(datum: Date): string {
return `${datum.getUTCFullYear()}-${datum.getUTCMonth()}-${datum.getUTCDate()}`
}
/**
* Ab welchem Anteil von Monaten mit persoenlichem Kontakt formuliere ich
* "regelmaessig"? Drei Viertel - wer in neun von zwoelf Monaten da war,
* war regelmaessig da.
*/
const REGELMAESSIG_AB_ANTEIL = 0.75
export function statistikBerechnen(
protokolle: ProtokollFuerStatistik[],
von: Date,
bis: Date,
): Kontaktstatistik {
const jeArt = Object.fromEntries(ALLE_ARTEN.map((art) => [art, 0])) as Record<KontaktArt, number>
const monate = new Map<string, Monatszeile>()
const persoenlicheTage = new Set<string>()
const besuchstage = new Set<string>()
const monateMitPersoenlich = new Set<string>()
let gesamt = 0
let persoenlich = 0
let telefonate = 0
let schriftlich = 0
let gesamtdauerMinuten = 0
let mitDauerErfasst = 0
for (const eintrag of protokolle) {
gesamt += 1
jeArt[eintrag.art] = (jeArt[eintrag.art] ?? 0) + 1
if (ARTEN_TELEFON.includes(eintrag.art)) telefonate += 1
if (ARTEN_SCHRIFTLICH.includes(eintrag.art)) schriftlich += 1
if (eintrag.dauerMinuten !== null && eintrag.dauerMinuten > 0) {
gesamtdauerMinuten += eintrag.dauerMinuten
mitDauerErfasst += 1
}
const jahr = eintrag.datum.getUTCFullYear()
const monat = eintrag.datum.getUTCMonth() + 1
const monatsschluessel = `${jahr}-${String(monat).padStart(2, '0')}`
const zeile = monate.get(monatsschluessel) ?? { jahr, monat, gesamt: 0, persoenlich: 0 }
zeile.gesamt += 1
if (eintrag.mitBetreuter) {
persoenlich += 1
zeile.persoenlich += 1
persoenlicheTage.add(tagesschluessel(eintrag.datum))
monateMitPersoenlich.add(monatsschluessel)
if (ARTEN_BESUCH.includes(eintrag.art)) besuchstage.add(tagesschluessel(eintrag.datum))
}
monate.set(monatsschluessel, zeile)
}
const jeMonat = [...monate.values()].sort((a, b) =>
a.jahr === b.jahr ? a.monat - b.monat : a.jahr - b.jahr,
)
return {
von,
bis,
monateImZeitraum: Math.max(1, differenceInCalendarMonths(bis, von) + 1),
gesamt,
persoenlich,
persoenlicheTage: persoenlicheTage.size,
besuchstage: besuchstage.size,
telefonate,
schriftlich,
monateMitPersoenlichemKontakt: monateMitPersoenlich.size,
jeArt,
jeMonat,
gesamtdauerMinuten,
mitDauerErfasst,
}
}
// -----------------------------------------------------------------------------
// Textbaustein fuer den Jahresbericht
// -----------------------------------------------------------------------------
export type Textkontext = {
/** "die Betreute", "der Betreute", "die betreute Person". */
nominativ: string
/** "die Betreute", "den Betreuten", "die betreute Person". */
akkusativ: string
/** Fertige Praepositionalphrase: "zur Betreuten", "zum Betreuten". */
kontaktZu: string
/** "das Pflegeheim Sonnenhof" - wird zu "im Pflegeheim Sonnenhof". */
heimName: string | null
}
export const TEXTKONTEXT_NEUTRAL: Textkontext = {
nominativ: 'die betreute Person',
akkusativ: 'die betreute Person',
kontaktZu: 'zur betreuten Person',
heimName: null,
}
/** Aus der Anrede der Stammdaten die passende Bezeichnung bilden. */
export function textkontextAusAnrede(
anrede: string | null | undefined,
heimName: string | null | undefined,
): Textkontext {
const gereinigt = (anrede ?? '').trim().toLowerCase()
if (gereinigt.startsWith('frau')) {
return {
nominativ: 'die Betreute',
akkusativ: 'die Betreute',
kontaktZu: 'zur Betreuten',
heimName: heimName ?? null,
}
}
if (gereinigt.startsWith('herr')) {
return {
nominativ: 'der Betreute',
akkusativ: 'den Betreuten',
kontaktZu: 'zum Betreuten',
heimName: heimName ?? null,
}
}
return { ...TEXTKONTEXT_NEUTRAL, heimName: heimName ?? null }
}
function datumDeutsch(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
function mehrzahl(anzahl: number, einzahl: string, mehrzahlForm: string): string {
return `${anzahl} ${anzahl === 1 ? einzahl : mehrzahlForm}`
}
/** "3 Stunden 15 Minuten", "45 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
if (minuten <= 0) return '0 Minuten'
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
if (stunden === 0) return mehrzahl(rest, 'Minute', 'Minuten')
if (rest === 0) return mehrzahl(stunden, 'Stunde', 'Stunden')
return `${mehrzahl(stunden, 'Stunde', 'Stunden')} ${mehrzahl(rest, 'Minute', 'Minuten')}`
}
/**
* Ganze Saetze fuer den Jahresbericht. Bewusst zurueckhaltend formuliert:
* der Text beschreibt, was dokumentiert ist, und behauptet nichts darueber
* hinaus. Fehlt persoenlicher Kontakt, sagt der Text das auch - ein
* geschoenter Bericht hilft niemandem.
*/
export function textbausteinErzeugen(
statistik: Kontaktstatistik,
kontext: Textkontext = TEXTKONTEXT_NEUTRAL,
): string {
const zeitraum = `Im Berichtszeitraum vom ${datumDeutsch(statistik.von)} bis ${datumDeutsch(
statistik.bis,
)}`
if (statistik.gesamt === 0) {
return (
`${zeitraum} ist kein Kontakt dokumentiert. Sofern Kontakte stattgefunden haben, ` +
`sind sie noch nachzutragen.`
)
}
const saetze: string[] = []
// Satz 1: Gab es persoenlichen Kontakt, und war er regelmaessig?
if (statistik.persoenlich === 0) {
saetze.push(`${zeitraum} ist kein persönlicher Kontakt ${kontext.kontaktZu} dokumentiert.`)
} else {
const anteil = statistik.monateMitPersoenlichemKontakt / statistik.monateImZeitraum
const regelmaessig = anteil >= REGELMAESSIG_AB_ANTEIL
saetze.push(
`${zeitraum} bestand ${regelmaessig ? 'regelmäßiger ' : ''}persönlicher Kontakt ` +
`${kontext.kontaktZu}.`,
)
}
// Satz 2: Besuche, Telefonate, Schriftverkehr in Zahlen.
const teile: string[] = []
if (statistik.besuchstage > 0) {
const ort = kontext.heimName ? ` in ${kontext.heimName}` : ''
teile.push(
`habe ${kontext.akkusativ} an ${mehrzahl(
statistik.besuchstage,
'Tag',
'Tagen',
)} persönlich${ort} besucht`,
)
}
if (statistik.telefonate > 0) {
teile.push(`${mehrzahl(statistik.telefonate, 'Telefonat', 'Telefonate')} geführt`)
}
if (statistik.schriftlich > 0) {
teile.push(
`${mehrzahl(statistik.schriftlich, 'schriftlichen Kontakt', 'schriftliche Kontakte')} gehabt`,
)
}
if (teile.length === 1) {
saetze.push(`Ich ${teile[0]}.`)
} else if (teile.length > 1) {
const letzter = teile[teile.length - 1]!
saetze.push(`Ich ${teile.slice(0, -1).join(', ')} und ${letzter}.`)
}
// Satz 3: Umfang insgesamt.
saetze.push(
`Insgesamt sind ${mehrzahl(statistik.gesamt, 'Kontakt', 'Kontakte')} dokumentiert` +
(statistik.gesamtdauerMinuten > 0
? `, davon ${mehrzahl(statistik.mitDauerErfasst, 'Kontakt', 'Kontakte')} mit einer ` +
`erfassten Dauer von zusammen ${dauerAusgeschrieben(statistik.gesamtdauerMinuten)}.`
: '.'),
)
// Satz 4: Verteilung ueber die Monate - die Frage "war es regelmaessig?".
if (statistik.persoenlich > 0) {
saetze.push(
`Persönlicher Kontakt fand in ${statistik.monateMitPersoenlichemKontakt} von ` +
`${mehrzahl(statistik.monateImZeitraum, 'Monat', 'Monaten')} des Berichtszeitraums statt.`,
)
}
return saetze.join(' ')
}
+321
View File
@@ -0,0 +1,321 @@
import 'server-only'
import { execFile } from 'node:child_process'
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import { env } from '@/lib/env'
import { typErkennen } from '@/lib/speicher/dateityp'
import { pdfSeitenzahlSchaetzen, pdfTextLesen, textAufraeumen } from '@/lib/ocr/pdf-text'
/**
* Texterkennung ueber das System-Binary `tesseract` (Sprache deutsch).
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 3.
*
* Die wichtigste Eigenschaft dieser Datei: **sie wirft nie**. Ein fehlendes
* Tesseract auf dem Entwicklungsrechner, ein kaputtes Foto oder ein
* verschluesseltes PDF darf niemals dazu fuehren, dass ein Beleg nicht
* gespeichert wird. Im Zweifel kommt `nicht_verfuegbar` zurueck, das
* Dokument liegt trotzdem in der Akte, und die Metadaten traegt der Nutzer
* von Hand ein.
*
* Ablauf:
* Bild -> tesseract
* PDF -> pdftoppm (Seiten als PNG) -> tesseract je Seite
* PDF ohne pdftoppm -> eingebetteten Text lesen
*/
const ausfuehren = promisify(execFile)
export type OcrStatus = 'fertig' | 'fehlgeschlagen' | 'nicht_verfuegbar'
export type OcrErgebnis = {
text: string
status: OcrStatus
seitenzahl?: number
meldung?: string
}
/** Ein Scan mit 20 Seiten darf den Server nicht eine Stunde beschaeftigen. */
const ZEITGRENZE_JE_SEITE_MS = 60_000
const MAX_SEITEN = 30
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
const MAX_ZEICHEN = 400_000
// -----------------------------------------------------------------------------
// Verfuegbarkeit
// -----------------------------------------------------------------------------
let tesseractGemerkt: boolean | null = null
let pdftoppmGemerkt: boolean | null = null
async function binaerVorhanden(befehl: string, argumente: string[]): Promise<boolean> {
try {
await ausfuehren(befehl, argumente, { timeout: 10_000, windowsHide: true })
return true
} catch (fehler) {
// Manche Binaries melden --version mit Exitcode != 0, liefern aber
// Ausgabe. Nur "gibt es nicht" zaehlt als nicht vorhanden.
const code = (fehler as NodeJS.ErrnoException | undefined)?.code
if (code === 'ENOENT') return false
const stdout = (fehler as { stdout?: string } | undefined)?.stdout ?? ''
const stderr = (fehler as { stderr?: string } | undefined)?.stderr ?? ''
return `${stdout}${stderr}`.trim().length > 0
}
}
/** Ist Tesseract auf diesem System vorhanden? */
export async function ocrVerfuegbar(): Promise<boolean> {
if (!env.ocrAktiv) return false
if (tesseractGemerkt !== null) return tesseractGemerkt
tesseractGemerkt = await binaerVorhanden(env.tesseractBefehl, ['--version'])
return tesseractGemerkt
}
/** Kann pdftoppm PDF-Seiten in Bilder wandeln? (Teil von poppler-utils) */
export async function pdfWandlerVerfuegbar(): Promise<boolean> {
if (pdftoppmGemerkt !== null) return pdftoppmGemerkt
pdftoppmGemerkt = await binaerVorhanden('pdftoppm', ['-v'])
return pdftoppmGemerkt
}
/** Nur fuer Tests und den Diagnosebereich: gemerkte Antworten vergessen. */
export function verfuegbarkeitVergessen(): void {
tesseractGemerkt = null
pdftoppmGemerkt = null
}
/** Welche Sprachpakete kennt Tesseract? Fuer die Diagnoseseite. */
export async function ocrSprachen(): Promise<string[]> {
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, ['--list-langs'], {
timeout: 10_000,
windowsHide: true,
})
return stdout
.split(/\r?\n/)
.slice(1)
.map((z) => z.trim())
.filter(Boolean)
} catch {
return []
}
}
// -----------------------------------------------------------------------------
// Erkennung
// -----------------------------------------------------------------------------
async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?: string }> {
const grundArgumente = [pfad, 'stdout', '--psm', '3']
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, ['-l', 'deu', ...grundArgumente], {
timeout: ZEITGRENZE_JE_SEITE_MS,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
})
return { text: stdout }
} catch (fehler) {
const meldung = String((fehler as { stderr?: string } | undefined)?.stderr ?? fehler)
// Haeufigster Stolperstein: Tesseract ist da, das deutsche Sprachpaket
// fehlt. Dann lieber englisch erkennen als gar nichts - mit Hinweis.
if (/Failed loading language|Could not initialize tesseract|tessdata/i.test(meldung)) {
try {
const { stdout } = await ausfuehren(env.tesseractBefehl, grundArgumente, {
timeout: ZEITGRENZE_JE_SEITE_MS,
maxBuffer: 32 * 1024 * 1024,
windowsHide: true,
})
return {
text: stdout,
meldung:
'Das deutsche Sprachpaket (tesseract-ocr-deu) fehlt. Erkannt wurde mit der ' +
'Standardsprache - Umlaute und Fachbegriffe sind dadurch oft falsch.',
}
} catch {
throw fehler
}
}
throw fehler
}
}
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
await ausfuehren(
'pdftoppm',
['-r', '300', '-gray', '-png', '-l', String(MAX_SEITEN), pdfPfad, join(verzeichnis, 'seite')],
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, windowsHide: true },
)
const dateien = await readdir(verzeichnis)
return dateien
.filter((name) => name.startsWith('seite') && name.endsWith('.png'))
.sort()
.map((name) => join(verzeichnis, name))
}
/**
* Ruft Tesseract (deutsch) auf. Nie werfen - Status zurueckgeben.
*/
export async function ocrLesen(inhalt: Buffer, mimeTyp: string): Promise<OcrErgebnis> {
if (!env.ocrAktiv) {
return { text: '', status: 'nicht_verfuegbar', meldung: 'Die Texterkennung ist abgeschaltet.' }
}
// Der gemeldete MIME-Typ ist nur ein Hinweis; entschieden wird am Inhalt.
const erkannt = typErkennen(inhalt)
const istPdf = erkannt ? erkannt.mimeTyp === 'application/pdf' : mimeTyp === 'application/pdf'
if (istPdf) return pdfLesen(inhalt)
if (erkannt && erkannt.istBild) return bildLesen(inhalt, erkannt.endung)
return {
text: '',
status: 'nicht_verfuegbar',
meldung: 'Aus diesem Dateityp lässt sich kein Text lesen.',
}
}
async function bildLesen(inhalt: Buffer, endung: string): Promise<OcrErgebnis> {
if (!(await ocrVerfuegbar())) {
return {
text: '',
status: 'nicht_verfuegbar',
seitenzahl: 1,
meldung:
'Tesseract ist auf diesem System nicht installiert. Das Dokument ist gespeichert, ' +
'nur der Volltext fehlt - trage Absender und Datum bitte von Hand ein.',
}
}
let verzeichnis: string | null = null
try {
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-'))
const pfad = join(verzeichnis, `bild.${endung}`)
await writeFile(pfad, inhalt)
const { text, meldung } = await tesseractAufDatei(pfad)
return {
text: kuerzen(textAufraeumen(text)),
status: 'fertig',
seitenzahl: 1,
meldung,
}
} catch (fehler) {
return {
text: '',
status: 'fehlgeschlagen',
seitenzahl: 1,
meldung: fehlermeldung(fehler),
}
} finally {
await aufraeumen(verzeichnis)
}
}
async function pdfLesen(inhalt: Buffer): Promise<OcrErgebnis> {
const seitenzahl = pdfSeitenzahlSchaetzen(inhalt) ?? undefined
// Weg 1: Seiten rendern und erkennen - funktioniert auch bei reinen Scans.
if ((await ocrVerfuegbar()) && (await pdfWandlerVerfuegbar())) {
let verzeichnis: string | null = null
try {
verzeichnis = await mkdtemp(join(tmpdir(), 'bc-ocr-'))
const pdfPfad = join(verzeichnis, 'quelle.pdf')
await writeFile(pdfPfad, inhalt)
const bilder = await pdfInBilder(verzeichnis, pdfPfad)
if (bilder.length === 0) throw new Error('pdftoppm hat keine Seiten erzeugt.')
const teile: string[] = []
let hinweis: string | undefined
for (const bild of bilder) {
const ergebnis = await tesseractAufDatei(bild)
teile.push(ergebnis.text)
hinweis ??= ergebnis.meldung
}
const text = kuerzen(textAufraeumen(teile.join('\n\n')))
const abgeschnitten =
seitenzahl !== undefined && seitenzahl > bilder.length
? `Erkannt wurden die ersten ${bilder.length} von ${seitenzahl} Seiten.`
: undefined
return {
text,
status: 'fertig',
seitenzahl: seitenzahl ?? bilder.length,
meldung: [hinweis, abgeschnitten].filter(Boolean).join(' ') || undefined,
}
} catch (fehler) {
// Weg 2 als Rettung: vielleicht steckt ohnehin Text im PDF.
const eingebettet = pdfTextLesen(inhalt)
if (eingebettet.length > 40) {
return {
text: kuerzen(eingebettet),
status: 'fertig',
seitenzahl,
meldung:
'Die Bilderkennung ist fehlgeschlagen; gelesen wurde der im PDF eingebettete Text.',
}
}
return { text: '', status: 'fehlgeschlagen', seitenzahl, meldung: fehlermeldung(fehler) }
} finally {
await aufraeumen(verzeichnis)
}
}
// Weg 2: kein Tesseract oder kein pdftoppm - eingebetteten Text lesen.
const eingebettet = pdfTextLesen(inhalt)
if (eingebettet.length > 40) {
return {
text: kuerzen(eingebettet),
status: 'fertig',
seitenzahl,
meldung:
'Gelesen wurde der im PDF eingebettete Text. Für gescannte Seiten ohne Text ' +
'wird zusätzlich Tesseract benötigt.',
}
}
return {
text: '',
status: 'nicht_verfuegbar',
seitenzahl,
meldung:
'Dieses PDF enthält keinen lesbaren Text, und für die Bilderkennung fehlen ' +
'Tesseract bzw. pdftoppm. Das Dokument ist gespeichert - trage Absender und ' +
'Datum bitte von Hand ein.',
}
}
function kuerzen(text: string): string {
return text.length > MAX_ZEICHEN ? `${text.slice(0, MAX_ZEICHEN)}\n…` : text
}
function fehlermeldung(fehler: unknown): string {
const roh =
(fehler as { stderr?: string } | undefined)?.stderr ||
(fehler instanceof Error ? fehler.message : String(fehler))
return roh.trim().slice(0, 300) || 'Unbekannter Fehler bei der Texterkennung.'
}
async function aufraeumen(verzeichnis: string | null): Promise<void> {
if (!verzeichnis) return
try {
await rm(verzeichnis, { recursive: true, force: true })
} catch {
// Temporaeres Verzeichnis bleibt liegen - kein Grund, die Aktion abzubrechen.
}
}
/** Kleiner Helfer fuer die Diagnoseseite: liest eine Datei und erkennt sie. */
export async function ocrAusDatei(pfad: string, mimeTyp: string): Promise<OcrErgebnis> {
try {
return await ocrLesen(await readFile(pfad), mimeTyp)
} catch (fehler) {
return { text: '', status: 'fehlgeschlagen', meldung: fehlermeldung(fehler) }
}
}
+227
View File
@@ -0,0 +1,227 @@
import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
/**
* Notnagel: den bereits im PDF eingebetteten Text lesen.
*
* Der Regelweg ist Tesseract. Fehlt auf dem System auch `pdftoppm`, um das
* PDF in Bilder zu wandeln, bleibt nur dieser Weg - und er reicht
* ueberraschend oft: Bescheide von Behoerden und Rechnungen kommen heute
* meist als "digitales" PDF mit echtem Text, nicht als Scan.
*
* Es ist bewusst ein einfacher Extraktor, kein PDF-Renderer:
* unkomprimierte und Flate-komprimierte Inhaltsstroeme werden ausgepackt
* und die Text-Operatoren (Tj, TJ, ', ") daraus gelesen. Was er nicht kann,
* meldet er als "kein Text" - dann bleibt der ocrStatus eben
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
*
* Rein, ohne Seiteneffekte, ohne Netz.
*/
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const zeichen = roh[i]!
if (zeichen !== '\\') {
ergebnis += zeichen
continue
}
const naechstes = roh[++i]
if (naechstes === undefined) break
switch (naechstes) {
case 'n':
ergebnis += '\n'
break
case 'r':
ergebnis += '\n'
break
case 't':
ergebnis += '\t'
break
case 'b':
case 'f':
ergebnis += ' '
break
case '\n':
break // Zeilenumbruch im Quelltext, kein Zeichen
default:
if (naechstes >= '0' && naechstes <= '7') {
let oktal = naechstes
while (oktal.length < 3 && roh[i + 1] !== undefined && roh[i + 1]! >= '0' && roh[i + 1]! <= '7') {
oktal += roh[++i]!
}
ergebnis += String.fromCharCode(Number.parseInt(oktal, 8))
} else {
ergebnis += naechstes
}
}
}
return ergebnis
}
/**
* PDF-Bytes in Zeichen wandeln. Ohne die Font-Tabelle des Dokuments ist
* das eine Naeherung; fuer WinAnsi (der Normalfall bei deutschen Texten)
* stimmt sie, inklusive Umlauten.
*/
const WINANSI_SONDER: Record<number, string> = {
0x80: '€',
0x82: '‚',
0x84: '„',
0x85: '…',
0x91: '‘',
0x92: '’',
0x93: '“',
0x94: '”',
0x95: '•',
0x96: '–',
0x97: '—',
0x9c: 'œ',
0x9f: 'Ÿ',
}
function bytesZuText(roh: string): string {
let ergebnis = ''
for (let i = 0; i < roh.length; i++) {
const code = roh.charCodeAt(i)
ergebnis += WINANSI_SONDER[code] ?? String.fromCharCode(code)
}
return ergebnis
}
/** Hex-String <48616C6C6F> */
function hexEntpacken(roh: string): string {
const sauber = roh.replace(/[^0-9a-fA-F]/g, '')
let ergebnis = ''
for (let i = 0; i + 1 < sauber.length; i += 2) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(i, i + 2), 16))
}
// Ungerade Laenge: das letzte Nibble wird laut Spezifikation mit 0 aufgefuellt.
if (sauber.length % 2 === 1) {
ergebnis += String.fromCharCode(Number.parseInt(sauber.slice(-1) + '0', 16))
}
return ergebnis
}
/** Holt alle Streams aus dem PDF und packt sie aus, soweit moeglich. */
function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1')
const stroeme: string[] = []
let suchAb = 0
while (stroeme.length < 2000) {
const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break
let inhaltVon = beginn + 'stream'.length
if (roh[inhaltVon] === '\r') inhaltVon++
if (roh[inhaltVon] === '\n') inhaltVon++
const ende = roh.indexOf('endstream', inhaltVon)
if (ende < 0) break
suchAb = ende + 'endstream'.length
const daten = Buffer.from(roh.slice(inhaltVon, ende), 'latin1')
if (daten.length === 0) continue
// Nur der Kopf des Objekts direkt davor sagt, wie komprimiert wurde.
const kopf = roh.slice(Math.max(0, beginn - 500), beginn)
if (/\/Filter\s*\/(DCTDecode|JPXDecode|CCITTFaxDecode|JBIG2Decode|RunLengthDecode)/.test(kopf)) {
continue // Bilddaten - hier ist kein Text zu holen
}
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
continue
}
if (!/\/Filter/.test(kopf)) {
stroeme.push(daten.toString('latin1'))
continue
}
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten)
if (entpackt) stroeme.push(entpackt.toString('latin1'))
}
return stroeme
}
function flateVersuchen(daten: Buffer): Buffer | null {
for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try {
return fn(daten)
} catch {
// naechster Versuch
}
}
return null
}
/** Liest die Text-Operatoren aus einem ausgepackten Inhaltsstrom. */
function textAusStrom(strom: string): string {
if (!/\bTj\b|\bTJ\b|\bTd\b|\bTf\b/.test(strom)) return ''
let ergebnis = ''
const muster = /\((?:\\.|[^\\()])*\)|<[0-9a-fA-F\s]*>|\bTJ\b|\bTj\b|\bT\*\b|\bTd\b|\bTD\b|\bET\b|'|"/g
let treffer: RegExpExecArray | null
while ((treffer = muster.exec(strom)) !== null) {
const wert = treffer[0]!
if (wert.startsWith('(')) {
ergebnis += bytesZuText(literalEntpacken(wert.slice(1, -1)))
} else if (wert.startsWith('<')) {
ergebnis += bytesZuText(hexEntpacken(wert.slice(1, -1)))
} else if (wert === 'T*' || wert === 'TD' || wert === 'Td' || wert === 'ET' || wert === "'" || wert === '"') {
ergebnis += '\n'
} else if (wert === 'TJ' || wert === 'Tj') {
ergebnis += ' '
}
}
return ergebnis
}
/**
* Zieht den eingebetteten Text aus einem PDF. Gibt eine leere Zeichenkette
* zurueck, wenn nichts zu holen ist (reiner Scan, verschluesseltes PDF,
* exotische Kodierung) - nie einen Fehler.
*/
export function pdfTextLesen(pdf: Buffer): string {
try {
const teile = stroemeAuspacken(pdf).map(textAusStrom).filter((t) => t.trim().length > 0)
return textAufraeumen(teile.join('\n'))
} catch {
return ''
}
}
/** Vereinheitlicht Leerraum, ohne Zeilenstruktur zu zerstoeren. */
export function textAufraeumen(roh: string): string {
return roh
.replace(/\r\n?/g, '\n')
// Nullbytes, weiche Trennstriche und Zero-Width-Zeichen entfernen.
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u00ad\u200b-\u200d\ufeff]/g, '')
.split('\n')
.map((zeile) => zeile.replace(/[ \t\u00a0]+/g, ' ').trim())
.filter((zeile, i, alle) => zeile !== '' || (alle[i - 1] ?? '') !== '')
.join('\n')
.trim()
}
/**
* Grobe Seitenzahl aus der PDF-Struktur. Nutzt zuerst /Count aus dem
* Seitenbaum, sonst die Zahl der /Type /Page-Objekte.
*/
export function pdfSeitenzahlSchaetzen(pdf: Buffer): number | null {
const roh = pdf.toString('latin1')
const zaehler = [...roh.matchAll(/\/Type\s*\/Pages[^>]*?\/Count\s+(\d+)/g)].map((t) =>
Number.parseInt(t[1]!, 10),
)
if (zaehler.length > 0) return Math.max(...zaehler)
const seiten = roh.match(/\/Type\s*\/Page[^s]/g)
return seiten ? seiten.length : null
}
+206
View File
@@ -0,0 +1,206 @@
import 'server-only'
import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp'
/**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
*
* Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/
/** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89
const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh)
}
/**
* Wandelt ein Bild in ein einseitiges PDF.
*
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
}
let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png'
}
const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab
const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, {
x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite,
height: hoehe,
})
if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND,
y: RAND / 2,
size: 8,
font: schrift,
color: rgb(0.3, 0.3, 0.3),
})
}
return Buffer.from(await pdf.save())
}
/**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) {
const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite)
}
if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE])
}
return Buffer.from(await ziel.save())
}
/**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/
export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount()
}
/**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
*
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages()
const gesamt = seiten.length
seiten.forEach((seite, i) => {
const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung),
})
return
}
seite.drawText(text, {
x: 36,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, {
x: width - 36 - breite,
y: 18,
size: groesse,
font: schrift,
color: rgb(0.35, 0.35, 0.35),
})
})
return Buffer.from(await dokument.save())
}
/**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren.
*/
export function fuerStandardschrift(text: string): string {
return text
.replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
}
function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
}
+370
View File
@@ -0,0 +1,370 @@
import * as React from 'react'
import { Document, Page, StyleSheet, Text, View } from '@react-pdf/renderer'
import { formatCent, formatDatum } from '@/lib/utils'
/**
* Der gemeinsame Seitenrahmen aller PDF-Ausgaben.
*
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. Jeder Bericht, jede
* Belegmappe und jedes Unterlagenpaket benutzt ihn - damit alles, was aus
* dieser Anwendung an Gericht oder Bezirk geht, gleich aussieht und
* dieselben Pflichtangaben traegt: wer ist gemeint, welches Aktenzeichen,
* welcher Zeitraum, wann erstellt, von wem, Seite x von y.
*
* Schrift: ausschliesslich die in @react-pdf/renderer eingebauten
* Standardschriften (Helvetica). Kein Nachladen von Fonts - das waere ein
* Aufruf nach draussen, und diese Anwendung ruft niemanden an.
*/
export type Berichtskopf = {
titel: string
betreute: { name: string; geburtsdatum: Date | null }
/** z.B. "Amtsgericht Bayreuth · Az. XVII 123/25" */
behoerde?: string
aktenzeichen?: string
zeitraumVon?: Date | null
zeitraumBis?: Date | null
stichtag?: Date | null
rechtsgrundlage?: string
erstelltAm: Date
erstelltVon: string
}
export const FARBEN = {
text: '#111827',
gedaempft: '#4b5563',
linie: '#d1d5db',
linieHell: '#e5e7eb',
hintergrund: '#f3f4f6',
} as const
export const pdfStile = StyleSheet.create({
seite: {
paddingTop: 40,
paddingBottom: 60,
paddingHorizontal: 46,
fontSize: 10,
fontFamily: 'Helvetica',
color: FARBEN.text,
lineHeight: 1.4,
},
kopfLeiste: {
borderBottomWidth: 1.5,
borderBottomColor: FARBEN.text,
paddingBottom: 8,
marginBottom: 14,
},
titel: { fontSize: 16, fontFamily: 'Helvetica-Bold', marginBottom: 4 },
kopfZeile: { flexDirection: 'row', flexWrap: 'wrap', gap: 3 },
kopfPaar: { flexDirection: 'row', marginRight: 16, marginTop: 2 },
kopfBezeichnung: { fontSize: 8.5, color: FARBEN.gedaempft, marginRight: 4 },
kopfWert: { fontSize: 8.5, fontFamily: 'Helvetica-Bold' },
fusszeile: {
position: 'absolute',
bottom: 26,
left: 46,
right: 46,
borderTopWidth: 0.5,
borderTopColor: FARBEN.linie,
paddingTop: 5,
flexDirection: 'row',
justifyContent: 'space-between',
fontSize: 7.5,
color: FARBEN.gedaempft,
},
abschnitt: { marginTop: 14, marginBottom: 4 },
abschnittTitel: {
fontSize: 11.5,
fontFamily: 'Helvetica-Bold',
marginBottom: 5,
paddingBottom: 3,
borderBottomWidth: 0.5,
borderBottomColor: FARBEN.linieHell,
},
absatz: { marginBottom: 6 },
tabelleKopf: {
flexDirection: 'row',
backgroundColor: FARBEN.hintergrund,
paddingVertical: 4,
paddingHorizontal: 4,
fontFamily: 'Helvetica-Bold',
fontSize: 8.5,
},
tabelleZeile: {
flexDirection: 'row',
paddingVertical: 3.5,
paddingHorizontal: 4,
borderBottomWidth: 0.5,
borderBottomColor: FARBEN.linieHell,
fontSize: 9,
},
tabelleZelle: { paddingRight: 6 },
paarZeile: { flexDirection: 'row', marginBottom: 2.5 },
paarBezeichnung: { width: 150, color: FARBEN.gedaempft },
paarWert: { flex: 1 },
hinweis: {
marginTop: 10,
padding: 7,
backgroundColor: FARBEN.hintergrund,
fontSize: 8.5,
color: FARBEN.gedaempft,
},
unterschrift: { marginTop: 34, flexDirection: 'row', justifyContent: 'space-between' },
unterschriftFeld: { width: '45%' },
unterschriftLinie: {
borderTopWidth: 0.75,
borderTopColor: FARBEN.text,
marginTop: 30,
paddingTop: 3,
fontSize: 8,
color: FARBEN.gedaempft,
},
})
function KopfPaar({ bezeichnung, wert }: { bezeichnung: string; wert: string }) {
return (
<View style={pdfStile.kopfPaar}>
<Text style={pdfStile.kopfBezeichnung}>{bezeichnung}:</Text>
<Text style={pdfStile.kopfWert}>{wert}</Text>
</View>
)
}
function zeitraumText(kopf: Berichtskopf): string | null {
if (kopf.zeitraumVon && kopf.zeitraumBis) {
return `${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`
}
if (kopf.zeitraumVon) return `ab ${formatDatum(kopf.zeitraumVon)}`
if (kopf.zeitraumBis) return `bis ${formatDatum(kopf.zeitraumBis)}`
return null
}
/**
* Seitenrahmen mit Kopf, Fusszeile (Seite x von y, Erstelldatum) und
* optionalem Unterschriftenfeld. Jeder Bericht benutzt ihn.
*
* Rueckgabe ist eine <Page>; der Aufrufer wickelt sie in ein <Document>
* (oder benutzt Berichtsdokument() weiter unten).
*/
export function Berichtsseite({
kopf,
mitUnterschrift,
querformat,
children,
}: {
kopf: Berichtskopf
mitUnterschrift?: boolean
/** Fuer breite Tabellen (Kassenbuch, Vermoegensuebersicht). */
querformat?: boolean
children: React.ReactNode
}): React.JSX.Element {
const zeitraum = zeitraumText(kopf)
return (
<Page size="A4" orientation={querformat ? 'landscape' : 'portrait'} style={pdfStile.seite}>
{/* Der Kopf wiederholt sich auf jeder Seite - ein einzelnes Blatt darf
nie ohne Zuordnung zur betreuten Person in der Akte landen. */}
<View style={pdfStile.kopfLeiste} fixed>
<Text style={pdfStile.titel}>{kopf.titel}</Text>
<View style={pdfStile.kopfZeile}>
<KopfPaar bezeichnung="Betreute Person" wert={kopf.betreute.name} />
{kopf.betreute.geburtsdatum && (
<KopfPaar bezeichnung="geboren am" wert={formatDatum(kopf.betreute.geburtsdatum)} />
)}
{kopf.behoerde && <KopfPaar bezeichnung="Behörde" wert={kopf.behoerde} />}
{kopf.aktenzeichen && <KopfPaar bezeichnung="Aktenzeichen" wert={kopf.aktenzeichen} />}
{zeitraum && <KopfPaar bezeichnung="Zeitraum" wert={zeitraum} />}
{kopf.stichtag && <KopfPaar bezeichnung="Stichtag" wert={formatDatum(kopf.stichtag)} />}
{kopf.rechtsgrundlage && (
<KopfPaar bezeichnung="Grundlage" wert={kopf.rechtsgrundlage} />
)}
</View>
</View>
{children}
{mitUnterschrift && <Unterschriftenfeld erstelltVon={kopf.erstelltVon} />}
<View style={pdfStile.fusszeile} fixed>
<Text>
{kopf.betreute.name} · {kopf.titel}
</Text>
<Text
render={({ pageNumber, totalPages }) =>
`Seite ${pageNumber} von ${totalPages} · erstellt am ${formatDatum(kopf.erstelltAm)} von ${kopf.erstelltVon}`
}
/>
</View>
</Page>
)
}
/** Ort/Datum und Unterschrift - ohne das nimmt kein Gericht ein Blatt an. */
export function Unterschriftenfeld({
erstelltVon,
ort,
}: {
erstelltVon: string
ort?: string
}): React.JSX.Element {
return (
<View style={pdfStile.unterschrift} wrap={false}>
<View style={pdfStile.unterschriftFeld}>
<Text style={pdfStile.unterschriftLinie}>Ort, Datum{ort ? ` (${ort})` : ''}</Text>
</View>
<View style={pdfStile.unterschriftFeld}>
<Text style={pdfStile.unterschriftLinie}>
Unterschrift der Betreuerin / des Betreuers ({erstelltVon})
</Text>
</View>
</View>
)
}
/**
* Bequeme Hulle: ein vollstaendiges Dokument mit einer fortlaufenden
* Berichtsseite. Fuer Berichte, die keine Sonderseiten brauchen.
*/
export function Berichtsdokument({
kopf,
mitUnterschrift,
querformat,
children,
}: {
kopf: Berichtskopf
mitUnterschrift?: boolean
querformat?: boolean
children: React.ReactNode
}): React.JSX.Element {
return (
<Document
title={`${kopf.titel} – ${kopf.betreute.name}`}
author={kopf.erstelltVon}
subject={kopf.aktenzeichen ?? undefined}
creator="Betreuungs-Cockpit"
producer="Betreuungs-Cockpit"
>
<Berichtsseite kopf={kopf} mitUnterschrift={mitUnterschrift} querformat={querformat}>
{children}
</Berichtsseite>
</Document>
)
}
// -----------------------------------------------------------------------------
// Bausteine, die jeder Bericht braucht
// -----------------------------------------------------------------------------
export function Abschnitt({
titel,
children,
}: {
titel: string
children: React.ReactNode
}): React.JSX.Element {
return (
<View style={pdfStile.abschnitt}>
<Text style={pdfStile.abschnittTitel}>{titel}</Text>
{children}
</View>
)
}
export function Absatz({ children }: { children: React.ReactNode }): React.JSX.Element {
return <Text style={pdfStile.absatz}>{children}</Text>
}
/** Bezeichnung links, Wert rechts - fuer Stammdaten und Kennzahlen. */
export function Angabe({
bezeichnung,
wert,
}: {
bezeichnung: string
wert: React.ReactNode
}): React.JSX.Element {
return (
<View style={pdfStile.paarZeile}>
<Text style={pdfStile.paarBezeichnung}>{bezeichnung}</Text>
<Text style={pdfStile.paarWert}>{wert}</Text>
</View>
)
}
export type Spalte<T> = {
kopf: string
/** Anteil an der Breite, z.B. 2 bei insgesamt 10. */
breite: number
rechts?: boolean
zelle: (zeile: T, index: number) => React.ReactNode
}
/**
* Schlichte Tabelle mit wiederholtem Kopf. react-pdf kann keine
* HTML-Tabellen, deshalb Flexbox mit Anteilen.
*/
export function Tabelle<T>({
spalten,
zeilen,
leerText = 'Keine Einträge.',
}: {
spalten: Spalte<T>[]
zeilen: T[]
leerText?: string
}): React.JSX.Element {
const summe = spalten.reduce((s, spalte) => s + spalte.breite, 0) || 1
if (zeilen.length === 0) {
return <Text style={{ fontSize: 9, color: FARBEN.gedaempft }}>{leerText}</Text>
}
return (
<View>
<View style={pdfStile.tabelleKopf} fixed>
{spalten.map((spalte, i) => (
<Text
key={i}
style={[
pdfStile.tabelleZelle,
{ width: `${(spalte.breite / summe) * 100}%` },
spalte.rechts ? { textAlign: 'right' } : {},
]}
>
{spalte.kopf}
</Text>
))}
</View>
{zeilen.map((zeile, z) => (
<View key={z} style={pdfStile.tabelleZeile} wrap={false}>
{spalten.map((spalte, i) => (
<Text
key={i}
style={[
pdfStile.tabelleZelle,
{ width: `${(spalte.breite / summe) * 100}%` },
spalte.rechts ? { textAlign: 'right' } : {},
]}
>
{spalte.zelle(zeile, z)}
</Text>
))}
</View>
))}
</View>
)
}
/** Grauer Kasten fuer Rechtshinweise und Erlaeuterungen unter einer Tabelle. */
export function Hinweiskasten({ children }: { children: React.ReactNode }): React.JSX.Element {
return <Text style={pdfStile.hinweis}>{children}</Text>
}
/** Betrag rechtsbuendig - Cent-BigInt, damit nichts gerundet wird. */
export function Betrag({ cent }: { cent: bigint | number | null }): React.JSX.Element {
return <Text style={{ textAlign: 'right' }}>{formatCent(cent)}</Text>
}
+109
View File
@@ -0,0 +1,109 @@
import type { GesundheitsArt } from '@prisma/client'
import { tag, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/**
* Abwesenheitstage.
*
* Warum das gezaehlt wird: Liegt die betreute Person im Krankenhaus, wird
* der Heimplatz freigehalten, aber nicht voll berechnet. Ab einer im
* Heimvertrag geregelten Zahl von Tagen mindert sich der Eigenanteil.
* Die Einrichtung zieht das nicht immer von selbst ab, und der
* Sozialhilfetraeger rechnet es erst recht nicht nach - wer die Tage nicht
* mitzaehlt, zahlt zu viel.
*
* Gezaehlt werden die Tage zwischen Aufnahme und Entlassung; der
* Entlassungstag zaehlt nicht mit, weil die betreute Person an diesem Tag
* wieder im Heim verpflegt wird. Ob der Aufnahmetag mitzaehlt, regelt der
* Heimvertrag unterschiedlich - deshalb laesst sich die Zahl an jedem
* Ereignis von Hand ueberschreiben.
*/
export type AbwesenheitsEreignis = {
datum: Date
bisDatum: Date | null
abwesenheitstage: number | null
art: GesundheitsArt
}
export type Abwesenheitsjahr = {
jahr: number
tage: number
/** Wie viele Ereignisse haben zu diesen Tagen beigetragen? */
ereignisse: number
}
/**
* Tage eines einzelnen Ereignisses. Ist die Zahl von Hand eingetragen,
* gilt sie; sonst wird aus dem Zeitraum gerechnet.
*/
export function tageEines(ereignis: AbwesenheitsEreignis): number {
if (ereignis.abwesenheitstage !== null && ereignis.abwesenheitstage >= 0) {
return ereignis.abwesenheitstage
}
if (!ereignis.bisDatum) return 0
return Math.max(0, tageZwischen(ereignis.datum, ereignis.bisDatum))
}
/**
* Summe je Kalenderjahr.
*
* Ein Aufenthalt ueber den Jahreswechsel wird auf beide Jahre aufgeteilt -
* die Heimrechnung kommt monatlich, und der Jahresbericht braucht die Tage
* dort, wo sie angefallen sind. Nur wenn die Tageszahl von Hand
* eingetragen wurde, wird sie ungeteilt dem Jahr des Beginns zugeordnet:
* Dann hat jemand bewusst eine Zahl gesetzt, und die soll nicht durch eine
* Automatik zerlegt werden.
*/
export function abwesenheitstageJeJahr(
ereignisse: AbwesenheitsEreignis[],
): Abwesenheitsjahr[] {
const jahre = new Map<number, Abwesenheitsjahr>()
const dazu = (jahr: number, tage: number) => {
if (tage <= 0) return
const vorhanden = jahre.get(jahr)
if (vorhanden) {
vorhanden.tage += tage
} else {
jahre.set(jahr, { jahr, tage, ereignisse: 0 })
}
}
for (const ereignis of ereignisse) {
const beginn = tagesdatum(ereignis.datum)
const handgezaehlt = ereignis.abwesenheitstage !== null && ereignis.abwesenheitstage >= 0
if (handgezaehlt) {
dazu(beginn.getUTCFullYear(), ereignis.abwesenheitstage!)
zaehleEreignis(jahre, beginn.getUTCFullYear())
continue
}
if (!ereignis.bisDatum) continue
const ende = tagesdatum(ereignis.bisDatum)
if (ende.getTime() <= beginn.getTime()) continue
// Jahresweise zerlegen.
let abschnittVon = beginn
while (abschnittVon.getTime() < ende.getTime()) {
const jahr = abschnittVon.getUTCFullYear()
const jahresende = tag(jahr + 1, 1, 1)
const abschnittBis = jahresende.getTime() < ende.getTime() ? jahresende : ende
dazu(jahr, tageZwischen(abschnittVon, abschnittBis))
zaehleEreignis(jahre, jahr)
abschnittVon = abschnittBis
}
}
return [...jahre.values()].sort((a, b) => b.jahr - a.jahr)
}
function zaehleEreignis(jahre: Map<number, Abwesenheitsjahr>, jahr: number): void {
const eintrag = jahre.get(jahr)
if (eintrag) eintrag.ereignisse += 1
}
/** Gesamtsumme ueber alle Jahre - fuer die Kachel auf der Uebersicht. */
export function abwesenheitstageGesamt(jahre: Abwesenheitsjahr[]): number {
return jahre.reduce((summe, j) => summe + j.tage, 0)
}
+181
View File
@@ -0,0 +1,181 @@
import type { GesundheitsArt } from '@prisma/client'
/**
* Zahlen und Staffeln aus dem Pflegerecht - an EINER Stelle, jede mit
* Stand-Datum.
*
* Der Gesetzgeber aendert diese Werte. Zuletzt zum 01.01.2024, als die
* Leistungszuschlaege nach § 43c SGB XI von 5/25/45/70 auf 15/30/50/75
* Prozent angehoben wurden. Wer solche Zahlen im Code verstreut, findet
* beim naechsten Mal nicht alle - deshalb stehen sie hier und nirgends
* sonst.
*/
// -----------------------------------------------------------------------------
// Leistungszuschlaege nach § 43c SGB XI
// -----------------------------------------------------------------------------
/**
* Stand der Staffel. Bei jeder Anzeige mit ausgegeben, damit sofort
* auffaellt, wenn die Zahlen alt sind.
*/
export const LEISTUNGSZUSCHLAG_STAND = '01.01.2024'
/**
* Quelle: § 43c Abs. 1 SGB XI in der Fassung des Pflegeunterstuetzungs-
* und -entlastungsgesetzes, gueltig ab 01.01.2024.
*
* ACHTUNG bei der Auslegung der Grenzen: Das Gesetz spricht von Jahren
* ("im ersten Jahr", "im zweiten Jahr" ...), gerechnet ab dem Beginn der
* vollstationaeren Pflege. Das erste Jahr sind die Monate 0 bis 11; ab dem
* zwoelften vollendeten Monat greift die naechste Stufe.
*
* Der Zuschlag bezieht sich AUSSCHLIESSLICH auf den pflegebedingten
* Eigenanteil (den einrichtungseinheitlichen Eigenanteil, EEE) - nicht auf
* Unterkunft und Verpflegung, nicht auf die Investitionskosten und nicht
* auf die Ausbildungsumlage. Das wird regelmaessig falsch gerechnet.
*/
export type Zuschlagsstufe = {
/** Ab wie vielen vollendeten Monaten in der Einrichtung? */
abMonat: number
/** Bis einschliesslich; null heisst: nach oben offen. */
bisMonat: number | null
prozent: number
bezeichnung: string
}
export const LEISTUNGSZUSCHLAG_STAFFEL: readonly Zuschlagsstufe[] = [
{ abMonat: 0, bisMonat: 11, prozent: 15, bezeichnung: 'erstes Jahr' },
{ abMonat: 12, bisMonat: 23, prozent: 30, bezeichnung: 'zweites Jahr' },
{ abMonat: 24, bisMonat: 35, prozent: 50, bezeichnung: 'drittes Jahr' },
{ abMonat: 36, bisMonat: null, prozent: 75, bezeichnung: 'ab dem vierten Jahr' },
] as const
export const LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE = '§ 43c SGB XI'
/**
* Ab welchem Pflegegrad gibt es den Zuschlag ueberhaupt? Ab Pflegegrad 2 -
* bei Pflegegrad 1 zahlt die Pflegekasse in der vollstationaeren Pflege
* nur den Zuschuss nach § 43 Abs. 3 SGB XI, und § 43c greift nicht.
*/
export const LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD = 2
// -----------------------------------------------------------------------------
// Pflegegrad
// -----------------------------------------------------------------------------
export const PFLEGEGRAD_BESCHRIFTUNG: Record<number, string> = {
1: 'Pflegegrad 1 — geringe Beeinträchtigung',
2: 'Pflegegrad 2 — erhebliche Beeinträchtigung',
3: 'Pflegegrad 3 — schwere Beeinträchtigung',
4: 'Pflegegrad 4 — schwerste Beeinträchtigung',
5: 'Pflegegrad 5 — schwerste Beeinträchtigung mit besonderen Anforderungen',
}
export const PFLEGEGRAD_RECHTSGRUNDLAGE_BEGUTACHTUNG = '§ 18 SGB XI'
/**
* Wie lange sollte zwischen zwei Hoeherstufungsantraegen mindestens
* liegen? Keine gesetzliche Frist - ein Antrag ist jederzeit moeglich.
* Sechs Monate sind der Erfahrungswert, ab dem eine Verschlechterung
* dokumentiert genug ist, um nicht sofort wieder abgelehnt zu werden.
*/
export const HOEHERSTUFUNG_ABSTAND_MONATE = 6
// -----------------------------------------------------------------------------
// Abwesenheit
// -----------------------------------------------------------------------------
/**
* Wie viele Abwesenheitstage am Stueck werden ueblicherweise voll
* berechnet, bevor der Heimplatz reduziert abgerechnet wird? Das steht im
* HEIMVERTRAG, nicht im Gesetz - der Rahmenvertrag nach § 75 SGB XI
* regelt es je Bundesland unterschiedlich. Die Zahl hier ist ein
* Anhaltspunkt fuer den Hinweistext, keine Berechnungsgrundlage.
*/
export const ABWESENHEIT_HINWEIS_AB_TAGEN = 3
export const ABWESENHEIT_HINWEIS =
'Abwesenheitstage — vor allem Krankenhausaufenthalte — können den Eigenanteil ' +
'mindern: Der Heimplatz wird ab einer im Vertrag geregelten Zahl von Tagen nur ' +
'noch teilweise berechnet. Maßgeblich ist der Heimvertrag, nicht das Gesetz. ' +
'Sieh dort nach, ab wann gekürzt wird und wie hoch der Abschlag ist, und prüfe ' +
'jede Heimrechnung daraufhin. Das wird regelmäßig übersehen.'
// -----------------------------------------------------------------------------
// Gesundheitsereignisse
// -----------------------------------------------------------------------------
export const GESUNDHEITS_ART_BESCHRIFTUNG: Record<GesundheitsArt, string> = {
ARZTBESUCH: 'Arztbesuch',
KRANKENHAUS: 'Krankenhausaufenthalt',
EXAZERBATION: 'Exazerbation (COPD-Verschlechterung)',
STURZ: 'Sturz',
MEDIKATIONSAENDERUNG: 'Medikationsänderung',
SAUERSTOFFVERSORGUNG: 'Sauerstoffversorgung',
INHALATION: 'Inhalation',
HILFSMITTEL: 'Hilfsmittel',
IMPFUNG: 'Impfung',
REHA: 'Rehabilitation',
PFLEGEBEGUTACHTUNG: 'Pflegebegutachtung',
HEILMITTEL: 'Heilmittel (Physio-, Ergo-, Logopädie)',
SONSTIGES: 'Sonstiges',
}
/** Reihenfolge in der Schnellerfassung: das Häufigste zuerst. */
export const SCHNELLERFASSUNG_REIHENFOLGE: GesundheitsArt[] = [
'EXAZERBATION',
'KRANKENHAUS',
'SAUERSTOFFVERSORGUNG',
'MEDIKATIONSAENDERUNG',
'INHALATION',
'STURZ',
'HILFSMITTEL',
'IMPFUNG',
'ARZTBESUCH',
'PFLEGEBEGUTACHTUNG',
'REHA',
'HEILMITTEL',
'SONSTIGES',
]
export const EXAZERBATION_SCHWEREGRADE = [
{ wert: 'leicht', beschriftung: 'leicht — nur Bedarfsmedikation gesteigert' },
{ wert: 'mittel', beschriftung: 'mittel — Antibiotikum und/oder Kortison' },
{ wert: 'schwer', beschriftung: 'schwer — Krankenhausbehandlung nötig' },
] as const
export const EXAZERBATION_AUSLOESER = [
'Atemwegsinfekt',
'Lungenentzündung',
'unklar',
'Kälte / Witterung',
'Belastung',
'Medikament abgesetzt',
'Sonstiges',
] as const
export const HILFSMITTEL_TYPISCH = [
'Rollator',
'Rollstuhl',
'Sauerstoffkonzentrator',
'mobile Sauerstoffflasche',
'Pulsoxymeter',
'Inhalationsgerät',
'Pflegebett',
'Toilettenstuhl',
'Hausnotruf',
] as const
/**
* Warum die COPD hier ueberall auftaucht: Der Verlauf ist die Grundlage
* fuer zwei Dinge, die Geld und Versorgung bedeuten - den Jahresbericht
* ans Betreuungsgericht und den Antrag auf Hoeherstufung des Pflegegrades.
* Wer Exazerbationen und Krankenhaustage nicht mitzaehlt, kann bei der
* Begutachtung nicht belegen, dass sich der Zustand verschlechtert hat.
*/
export const COPD_HINWEIS =
'Eine COPD verläuft in Schüben. Zwischen zwei Exazerbationen wirkt der Zustand ' +
'stabil — die Verschlechterung zeigt sich erst in der Zahl der Schübe und der ' +
'Krankenhaustage über das Jahr. Genau diese Zahlen brauchst du für den ' +
'Jahresbericht und für einen Höherstufungsantrag.'
+181
View File
@@ -0,0 +1,181 @@
import { differenceInMonths } from 'date-fns'
import { heuteAlsTag, plusMonate, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
import {
LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD,
LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE,
LEISTUNGSZUSCHLAG_STAFFEL,
LEISTUNGSZUSCHLAG_STAND,
type Zuschlagsstufe,
} from '@/lib/pflege/konstanten'
/**
* Leistungszuschlag nach § 43c SGB XI.
*
* Die Pflegekasse uebernimmt einen wachsenden Anteil des pflegebedingten
* Eigenanteils, je laenger jemand in der Einrichtung lebt. Der Sprung von
* 15 auf 30 Prozent nach zwoelf Monaten ist bares Geld - bei einem
* Eigenanteil von 1.500 Euro sind das 225 Euro im Monat.
*
* Er wird oft uebersehen, weil er nicht beantragt werden muss: Die
* Einrichtung rechnet ihn mit der Pflegekasse ab. Genau deshalb lohnt der
* Blick auf die Heimrechnung - wenn die Stufe nicht umgestellt wurde,
* merkt es sonst niemand.
*
* Hier wird nur gerechnet und angezeigt. Die monatliche Gegenueberstellung
* von Heimrechnung und Deckungsquellen baut das Sozialhilfemodul unter
* /sozialhilfe/heimkosten - diese Datei doppelt sie ausdruecklich nicht.
*/
export { LEISTUNGSZUSCHLAG_STAFFEL, LEISTUNGSZUSCHLAG_STAND, LEISTUNGSZUSCHLAG_RECHTSGRUNDLAGE }
/**
* Vollendete Monate in der Einrichtung. Der Einzugstag zaehlt mit: Wer am
* 15.01. eingezogen ist, hat am 15.02. einen vollen Monat hinter sich.
*/
export function verweildauerMonate(einzugAm: Date, stichtag: Date): number {
const von = tagesdatum(einzugAm)
const bis = tagesdatum(stichtag)
if (bis.getTime() < von.getTime()) return 0
// date-fns rechnet auf Ortszeit - beide Daten auf die Mittagsstunde
// heben, sonst verschiebt die Sommerzeit den Monatswechsel.
const mittags = (d: Date) => new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 12)
return Math.max(0, differenceInMonths(mittags(bis), mittags(von)))
}
export function stufeFuerMonate(monate: number): Zuschlagsstufe {
for (const stufe of LEISTUNGSZUSCHLAG_STAFFEL) {
if (monate >= stufe.abMonat && (stufe.bisMonat === null || monate <= stufe.bisMonat)) {
return stufe
}
}
// Die Staffel ist nach oben offen; der Fall kann nicht eintreten.
return LEISTUNGSZUSCHLAG_STAFFEL[LEISTUNGSZUSCHLAG_STAFFEL.length - 1]!
}
export function naechsteStufeNach(stufe: Zuschlagsstufe): Zuschlagsstufe | null {
const index = LEISTUNGSZUSCHLAG_STAFFEL.indexOf(stufe)
if (index < 0 || index + 1 >= LEISTUNGSZUSCHLAG_STAFFEL.length) return null
return LEISTUNGSZUSCHLAG_STAFFEL[index + 1] ?? null
}
/** Prozentsatz auf einen Cent-Betrag, kaufmaennisch gerundet. */
export function anteilCent(betragCent: bigint, prozent: number): bigint {
const zaehler = betragCent * BigInt(prozent) * 2n
const nenner = 200n
const rest = zaehler % nenner
const ganz = zaehler / nenner
return rest * 2n >= nenner ? ganz + 1n : ganz
}
export type Zuschlagsergebnis = {
/** Laesst sich rechnen? Ohne Einzugsdatum nicht. */
berechenbar: boolean
verweildauerMonate: number
stufe: Zuschlagsstufe | null
prozent: number
/** Was die Pflegekasse vom Eigenanteil uebernimmt. */
zuschlagCent: bigint | null
/** Was nach dem Zuschlag vom pflegebedingten Eigenanteil bleibt. */
verbleibtCent: bigint | null
naechsteStufe: {
stufe: Zuschlagsstufe
/** Ab diesem Tag greift die naechste Stufe. */
abDatum: Date
tageBis: number
/** Wie viel mehr uebernimmt die Kasse dann im Monat? */
mehrCent: bigint | null
} | null
hinweise: string[]
}
export type ZuschlagEingabe = {
einzugAm: Date | null
/** Der pflegebedingte Eigenanteil (EEE) im Monat. */
eigenanteilCent: bigint | null
pflegegrad?: number | null
stichtag?: Date
}
export function leistungszuschlagBerechnen(eingabe: ZuschlagEingabe): Zuschlagsergebnis {
const stichtag = tagesdatum(eingabe.stichtag ?? heuteAlsTag())
const hinweise: string[] = []
if (!eingabe.einzugAm) {
return {
berechenbar: false,
verweildauerMonate: 0,
stufe: null,
prozent: 0,
zuschlagCent: null,
verbleibtCent: null,
naechsteStufe: null,
hinweise: [
'Ohne Einzugsdatum lässt sich der Leistungszuschlag nicht bestimmen. ' +
'Trag es im Heimvertrag nach.',
],
}
}
if (
eingabe.pflegegrad !== null &&
eingabe.pflegegrad !== undefined &&
eingabe.pflegegrad < LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD
) {
hinweise.push(
`Der Leistungszuschlag setzt mindestens Pflegegrad ${LEISTUNGSZUSCHLAG_AB_PFLEGEGRAD} ` +
`voraus. Bei Pflegegrad 1 zahlt die Pflegekasse in der vollstationären Pflege ` +
`nur den Zuschuss nach § 43 Abs. 3 SGB XI.`,
)
}
const monate = verweildauerMonate(eingabe.einzugAm, stichtag)
const stufe = stufeFuerMonate(monate)
const eigenanteil = eingabe.eigenanteilCent
const zuschlag = eigenanteil !== null ? anteilCent(eigenanteil, stufe.prozent) : null
const folge = naechsteStufeNach(stufe)
let naechsteStufe: Zuschlagsergebnis['naechsteStufe'] = null
if (folge) {
const abDatum = plusMonate(tagesdatum(eingabe.einzugAm), folge.abMonat)
const mehr =
eigenanteil !== null
? anteilCent(eigenanteil, folge.prozent) - anteilCent(eigenanteil, stufe.prozent)
: null
naechsteStufe = {
stufe: folge,
abDatum,
tageBis: tageZwischen(stichtag, abDatum),
mehrCent: mehr,
}
}
if (eigenanteil === null) {
hinweise.push(
'Ohne den pflegebedingten Eigenanteil aus dem Heimvertrag lässt sich nur der ' +
'Prozentsatz anzeigen, nicht der Betrag.',
)
}
hinweise.push(
'Der Zuschlag gilt nur für den pflegebedingten Eigenanteil (den ' +
'einrichtungseinheitlichen Eigenanteil). Unterkunft, Verpflegung, ' +
'Investitionskosten und Ausbildungsumlage bleiben davon unberührt — dort ' +
'ändert sich mit der Stufe nichts.',
)
hinweise.push(
'Der Zuschlag muss nicht beantragt werden; die Einrichtung rechnet ihn mit der ' +
'Pflegekasse ab. Prüf trotzdem die Heimrechnung nach dem Stufenwechsel — ' +
'nicht umgestellte Stufen fallen sonst niemandem auf.',
)
return {
berechenbar: true,
verweildauerMonate: monate,
stufe,
prozent: stufe.prozent,
zuschlagCent: zuschlag,
verbleibtCent: eigenanteil !== null && zuschlag !== null ? eigenanteil - zuschlag : null,
naechsteStufe,
hinweise,
}
}
+202
View File
@@ -0,0 +1,202 @@
import type { GesundheitsArt } from '@prisma/client'
import { tagesdatum } from '@/lib/fristen/datum'
import {
abwesenheitstageJeJahr,
tageEines,
type AbwesenheitsEreignis,
} from '@/lib/pflege/abwesenheit'
/**
* Auswertung des Gesundheitsverlaufs.
*
* Zwei Zahlen wandern in den Jahresbericht und in jeden
* Hoeherstufungsantrag: Wie viele Exazerbationen gab es im Jahr, und wie
* viele Tage lag die betreute Person im Krankenhaus. Beides ist bei einer
* COPD der belastbare Beleg dafuer, dass sich der Zustand verschlechtert -
* "es geht ihr schlechter" allein ueberzeugt keinen Gutachter.
*
* Reine Logik ohne Datenbank; die Beschreibungen der Ereignisse bleiben
* verschluesselt und werden hier gar nicht angefasst.
*/
export type VerlaufEreignis = {
datum: Date
bisDatum: Date | null
art: GesundheitsArt
abwesenheitstage: number | null
/** Titel im Klartext - er enthaelt keine Diagnose, nur eine Bezeichnung. */
titel?: string
}
export type Verlaufsjahr = {
jahr: number
exazerbationen: number
/** Exazerbationen, die zu einem Krankenhausaufenthalt gefuehrt haben. */
krankenhausaufenthalte: number
krankenhaustage: number
abwesenheitstage: number
stuerze: number
ereignisseGesamt: number
}
/**
* Exazerbationen je Kalenderjahr.
*
* Gezaehlt wird das Jahr des Beginns. Ein Schub, der ueber Silvester
* laeuft, ist ein Ereignis und gehoert in das Jahr, in dem er begann.
*/
export function exazerbationenJeJahr(
ereignisse: VerlaufEreignis[],
): { jahr: number; anzahl: number }[] {
const jahre = new Map<number, number>()
for (const ereignis of ereignisse) {
if (ereignis.art !== 'EXAZERBATION') continue
const jahr = tagesdatum(ereignis.datum).getUTCFullYear()
jahre.set(jahr, (jahre.get(jahr) ?? 0) + 1)
}
return [...jahre.entries()]
.map(([jahr, anzahl]) => ({ jahr, anzahl }))
.sort((a, b) => b.jahr - a.jahr)
}
/**
* Krankenhaustage je Kalenderjahr. Gerechnet wird wie bei den
* Abwesenheitstagen: Tage zwischen Aufnahme und Entlassung, ueber den
* Jahreswechsel aufgeteilt.
*/
export function krankenhaustageJeJahr(
ereignisse: VerlaufEreignis[],
): { jahr: number; tage: number }[] {
const nurKrankenhaus: AbwesenheitsEreignis[] = ereignisse
.filter((e) => e.art === 'KRANKENHAUS')
.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
abwesenheitstage: e.abwesenheitstage,
art: e.art,
}))
return abwesenheitstageJeJahr(nurKrankenhaus).map((j) => ({ jahr: j.jahr, tage: j.tage }))
}
/** Die vollstaendige Jahresauswertung, wie sie auf /pflege/verlauf steht. */
export function verlaufAuswerten(ereignisse: VerlaufEreignis[]): Verlaufsjahr[] {
const jahre = new Map<number, Verlaufsjahr>()
const holen = (jahr: number): Verlaufsjahr => {
let eintrag = jahre.get(jahr)
if (!eintrag) {
eintrag = {
jahr,
exazerbationen: 0,
krankenhausaufenthalte: 0,
krankenhaustage: 0,
abwesenheitstage: 0,
stuerze: 0,
ereignisseGesamt: 0,
}
jahre.set(jahr, eintrag)
}
return eintrag
}
for (const ereignis of ereignisse) {
const jahr = tagesdatum(ereignis.datum).getUTCFullYear()
const eintrag = holen(jahr)
eintrag.ereignisseGesamt += 1
if (ereignis.art === 'EXAZERBATION') eintrag.exazerbationen += 1
if (ereignis.art === 'STURZ') eintrag.stuerze += 1
if (ereignis.art === 'KRANKENHAUS') eintrag.krankenhausaufenthalte += 1
}
for (const zeile of krankenhaustageJeJahr(ereignisse)) {
holen(zeile.jahr).krankenhaustage = zeile.tage
}
const abwesend = abwesenheitstageJeJahr(
ereignisse.map((e) => ({
datum: e.datum,
bisDatum: e.bisDatum,
abwesenheitstage: e.abwesenheitstage,
art: e.art,
})),
)
for (const zeile of abwesend) {
holen(zeile.jahr).abwesenheitstage = zeile.tage
}
return [...jahre.values()].sort((a, b) => b.jahr - a.jahr)
}
/**
* Kurzer Satz fuer den Jahresbericht - fertig zum Hineinkopieren.
* Bewusst nuechtern und ohne Diagnosen: Der Bericht geht ans Gericht,
* nicht an die Krankenkasse.
*/
export function verlaufZusammenfassung(jahr: Verlaufsjahr): string {
const teile: string[] = []
if (jahr.exazerbationen > 0) {
teile.push(
jahr.exazerbationen === 1
? 'eine akute Verschlechterung der Atemwegserkrankung'
: `${jahr.exazerbationen} akute Verschlechterungen der Atemwegserkrankung`,
)
}
if (jahr.krankenhausaufenthalte > 0) {
teile.push(
`${jahr.krankenhausaufenthalte} Krankenhausaufenthalt${
jahr.krankenhausaufenthalte === 1 ? '' : 'e'
} mit insgesamt ${jahr.krankenhaustage} Tagen`,
)
}
if (jahr.stuerze > 0) {
teile.push(`${jahr.stuerze} dokumentierte${jahr.stuerze === 1 ? 'r' : ''} Sturz${jahr.stuerze === 1 ? '' : 'e'}`)
}
if (teile.length === 0) {
return `Im Jahr ${jahr.jahr} wurden keine besonderen gesundheitlichen Ereignisse erfasst.`
}
return `Im Jahr ${jahr.jahr} sind dokumentiert: ${teile.join(', ')}.`
}
/**
* Steigt die Zahl der Exazerbationen gegenueber dem Vorjahr? Genau das ist
* das Argument fuer eine Neubegutachtung nach § 18 SGB XI.
*/
export function verschlechterungErkannt(
jahre: Verlaufsjahr[],
bezugsjahr: number,
): { erkannt: boolean; begruendung: string | null } {
const aktuell = jahre.find((j) => j.jahr === bezugsjahr)
const vorjahr = jahre.find((j) => j.jahr === bezugsjahr - 1)
if (!aktuell || !vorjahr) return { erkannt: false, begruendung: null }
const mehrSchuebe = aktuell.exazerbationen > vorjahr.exazerbationen
const mehrTage = aktuell.krankenhaustage > vorjahr.krankenhaustage
if (!mehrSchuebe && !mehrTage) return { erkannt: false, begruendung: null }
const teile: string[] = []
if (mehrSchuebe) {
teile.push(
`${vorjahr.exazerbationen} Exazerbationen im Jahr ${vorjahr.jahr} gegenüber ` +
`${aktuell.exazerbationen} im Jahr ${aktuell.jahr}`,
)
}
if (mehrTage) {
teile.push(
`${vorjahr.krankenhaustage} Krankenhaustage im Jahr ${vorjahr.jahr} gegenüber ` +
`${aktuell.krankenhaustage} im Jahr ${aktuell.jahr}`,
)
}
return {
erkannt: true,
begruendung:
`Der erfasste Verlauf zeigt eine Zunahme: ${teile.join(' und ')}. ` +
`Das kann eine erneute Begutachtung rechtfertigen.`,
}
}
/** Summe der Tage eines einzelnen Ereignisses - fuer die Zeitstrahl-Zeile. */
export { tageEines }

Some files were not shown because too many files have changed in this diff Show More