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:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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äudeversicherung,
|
||||
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)
|
||||
}
|
||||
```
|
||||
Generated
+3073
-15
File diff suppressed because it is too large
Load Diff
+14
-1
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' } },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()}`
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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(' ')
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user