Rueckkaufswerte aller Versicherungen in einem Zug anfragen

Vor Vermoegensverzeichnis und Jahresbericht braucht es von jeder Lebens-
und Rentenversicherung den Rueckkaufswert zum Stichtag - jedes Mal
dieselben fuenf bis zehn Briefe. Neu unter Vertraege: Stichtag waehlen,
Liste pruefen, ein Klick. Heraus kommt ein ZIP mit einer Word-Datei je
Gesellschaft plus Begleitblatt; je Vertrag entstehen nebenbei die
PDF-Fassung in der Akte, die Wiedervorlage nach 28 Tagen und der
Anfragevermerk.

Aufbau:
- Auswahllogik als reine Funktionen in vertraege/serienabfrage.ts:
  Vorauswahl, Hindernisse mit Begruendung statt stummem Ausblenden,
  28-Tage-Sperre im Gleichschritt mit der Wiedervorlage.
- Der Lauf in vertraege/serienlauf.ts: je Vertrag das volle Paket aus
  Issue #4, aber in einem Durchgang. Kein Alles-oder-nichts - ein
  gescheiterter Vertrag steht mit Grund im Begleitblatt, die uebrigen
  laufen durch. Eine Transaktion ueber alle waere die falsche Zusage.
- Dublettenschutz tagesgenau: Der Doppelklick erzeugt nichts Doppeltes,
  die bewusste zweite Anfrage nach einer Woche bleibt moeglich.
- Die Anzahl steht vor dem Klick auf dem Knopf.

Gekuendigte Vertraege bleiben anfragbar: Bis zum Wirksamwerden besteht
der Vertrag, und genau dann will der Sozialhilfetraeger den Wert sehen.

Geprueft: zwoelf Einheitstests, drei End-to-End-Tests inklusive der
Nebenwirkungen in der Datenbank und des Dublettenschutzes. 602
Unit-Tests, 87 e2e, null Typfehler.

Refs #6
This commit is contained in:
Florian Wilde
2026-08-23 12:10:32 +02:00
parent 96a9bc4d63
commit 9fe6f10f25
14 changed files with 1345 additions and 250 deletions
@@ -0,0 +1,165 @@
'use client'
import * as React from 'react'
import { useRouter } from 'next/navigation'
import { AlertCircle, FileArchive, Info } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
export type SerienZeile = {
vertragId: string
bezeichnung: string
gesellschaft: string | null
artBeschriftung: string
moeglich: boolean
hindernis: string | null
vorausgewaehlt: boolean
}
/**
* Auswahl und Start der Serienabfrage.
*
* Die Zusammenfassung ("Es entstehen N Schreiben") steht direkt auf dem
* Knopf. Man soll den Fehlgriff bemerken, BEVOR zehn Briefe entstehen —
* nicht danach beim Aufräumen.
*
* Der Start läuft als gewöhnliches Formular-POST gegen die Route: Der
* Browser bekommt das ZIP als Download, und die Nebenwirkungen (Akte,
* Fristen, Anfragedatum) sind serverseitig schon passiert. Nach dem
* Klick wird die Seite neu geladen, damit die Sperrvermerke stimmen.
*/
export function SerienabfrageFormular({
betreuteId,
zeilen,
stichtagVorschlag,
}: {
betreuteId: string
zeilen: SerienZeile[]
stichtagVorschlag: string
}) {
const router = useRouter()
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(
() => new Set(zeilen.filter((z) => z.vorausgewaehlt).map((z) => z.vertragId)),
)
const [stichtag, setStichtag] = React.useState(stichtagVorschlag)
const [gestartet, setGestartet] = React.useState(false)
function umschalten(id: string) {
setGewaehlt((bisher) => {
const neu = new Set(bisher)
if (neu.has(id)) neu.delete(id)
else neu.add(id)
return neu
})
}
const anzahl = gewaehlt.size
function beimAbsenden() {
// Das Formular lädt als Download - die Seite bleibt stehen. Nach
// kurzer Zeit neu laden, damit Sperrvermerke und "Wartet auf andere"
// den neuen Stand zeigen.
setGestartet(true)
window.setTimeout(() => {
router.refresh()
setGestartet(false)
}, 4000)
}
return (
<form
action="/vertraege/serienabfrage"
method="post"
onSubmit={beimAbsenden}
className="space-y-5"
>
<input type="hidden" name="betreuteId" value={betreuteId} />
<Feld
id="stichtag"
beschriftung="Stichtag der Auskünfte"
erklaerung="Zu diesem Tag sollen die Gesellschaften den Rückkaufswert mitteilen — üblicherweise der Stichtag des Vermögensverzeichnisses oder der Beginn des Betreuungsjahres."
pflicht
>
<Input
id="stichtag"
name="stichtag"
type="date"
value={stichtag}
onChange={(ereignis) => setStichtag(ereignis.target.value)}
required
/>
</Feld>
<ul className="divide-y rounded-md border">
{zeilen.map((zeile) => (
<li key={zeile.vertragId} className="flex items-start gap-3 p-3">
{zeile.moeglich ? (
<Checkbox
id={`vertrag-${zeile.vertragId}`}
name="vertragId"
value={zeile.vertragId}
checked={gewaehlt.has(zeile.vertragId)}
onChange={() => umschalten(zeile.vertragId)}
className="mt-0.5"
/>
) : (
<span className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
)}
<label
htmlFor={zeile.moeglich ? `vertrag-${zeile.vertragId}` : undefined}
className={`min-w-0 flex-1 ${zeile.moeglich ? 'cursor-pointer' : 'opacity-60'}`}
>
<span className="flex flex-wrap items-center gap-2">
<span className="font-medium">{zeile.bezeichnung}</span>
<Badge variant="secondary">{zeile.artBeschriftung}</Badge>
</span>
<span className="block text-sm text-muted-foreground">
{zeile.gesellschaft ?? 'ohne Gesellschaft'}
</span>
{zeile.hindernis && (
<span className="mt-0.5 block text-xs text-ampel-orange">{zeile.hindernis}</span>
)}
</label>
</li>
))}
</ul>
{zeilen.length === 0 && (
<Alert variant="hinweis">
<Info aria-hidden="true" />
<AlertDescription>
Es gibt keine Verträge, die für eine Rückkaufswert-Anfrage in Frage kommen. Lege
zuerst die Versicherungen unter <em>Verträge</em> an — oder finde sie über{' '}
<em>Vermögen &amp; Kasse → Wiederkehrende Zahlungen</em>.
</AlertDescription>
</Alert>
)}
{anzahl === 0 && zeilen.length > 0 && (
<Alert variant="hinweis">
<AlertCircle aria-hidden="true" />
<AlertDescription>Kein Vertrag gewählt — es entsteht nichts.</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Button type="submit" disabled={anzahl === 0 || gestartet} size="lg">
<FileArchive aria-hidden="true" />
{gestartet
? 'Wird erzeugt …'
: `${anzahl} Schreiben erzeugen und herunterladen`}
</Button>
<p className="text-sm text-muted-foreground">
Je Vertrag entsteht eine Word-Datei im ZIP, eine PDF-Fassung in der Akte, eine
Wiedervorlage nach 28 Tagen — und der Vertrag gilt als angefragt.
</p>
</div>
</form>
)
}