Wiederkehrende Zahlungen aufspueren
Am Anfang einer Betreuung weiss niemand, welche Vertraege es gibt. Die Antwort steht aber im Kontoauszug: Wer regelmaessig abbucht, hat einen Vertrag. Neu unter Vermoegen & Kasse wird aus hunderten Buchungszeilen eine Arbeitsliste. Aufbau: - Die Erkennung liegt als reine Funktionen in src/lib/finanzen/wiederkehrend.ts, ohne Datenbank. Nur so laesst sich jede Einordnung einzeln nachrechnen. - Der Datenzugriff steht getrennt in wiederkehrend-laden.ts. - Ein kleines Modell haelt fest, was bewusst KEIN Vertrag ist. Es haengt am normalisierten Empfaengernamen, nicht an Buchungen - nur so ueberlebt die Entscheidung den naechsten Import. Zwei Haltungen liegen der Erkennung zugrunde: Im Zweifel getrennt lassen. Zusammengefasst wird nur das Unstrittige: Gross- und Kleinschreibung, Satzzeichen, Umlaute, Rechtsform am Ende. Keine Aehnlichkeitssuche - "Stadtwerke Bamberg" und "Stadtwerke Bayreuth" trennen drei Zeichen und zwei Rechnungssteller. Zwei Zeilen zu viel sind harmlos; eine falsche Gruppe verbirgt einen Vertrag. Nichts behaupten, was nicht belegt ist. Statt "monatlich" steht dort "6 Zahlungen, etwa monatlich". Schwankt der Betrag, wird die Spanne genannt. Ohne erkennbaren Abstand wird nicht hochgerechnet, sondern gezeigt, was geflossen ist. Dazu eine zweite Liste fuer einzelne groessere Zahlungen: Die Gruppierung braucht zwei Zahlungen, ein Jahresbeitrag erscheint bei einem importierten Jahr aber nur einmal - und ausgerechnet Versicherungen zahlt man oft jaehrlich. Geprueft: 37 Einheitstests, die Haelfte davon in der Gegenrichtung, plus vier End-to-End-Tests auf beiden Geraeteprofilen. Der Browsertest hat zwei Fehler gefunden, die Typpruefung und Einheitstests durchgelassen haben - darunter ein verlorener Backslash, der die Vorbelegung des Beitrags stillschweigend verschluckte. Nebenbei: .claude/ ins .gitignore. Dort legen parallel arbeitende Agenten ihre Worktrees ab; ein "git add -A" haette einen kompletten zweiten Checkout eingecheckt. Refs #3
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { useActionState } from 'react'
|
||||
import { AlertCircle, CheckCircle2, Undo2, XCircle } from 'lucide-react'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Absendeknopf } from '@/components/finanzen/eingaben'
|
||||
import {
|
||||
zahlungsgruppeAbhaken,
|
||||
zahlungsgruppeZurueckholen,
|
||||
type Formularzustand,
|
||||
} from '@/server/actions/finanzen'
|
||||
|
||||
/**
|
||||
* „Kein Vertrag" — eine Gruppe dauerhaft aus der Liste nehmen.
|
||||
*
|
||||
* Die Begründung ist freiwillig, steht aber im Formular ganz vorn. In einem
|
||||
* Jahr weiß niemand mehr, warum „Stadtwerke Bamberg" abgehakt wurde, und
|
||||
* dann fängt die Prüfung von vorn an. Ein halber Satz jetzt spart das.
|
||||
*/
|
||||
export function AlsGeklaertAbhaken({
|
||||
betreuteId,
|
||||
schluessel,
|
||||
anzeigename,
|
||||
}: {
|
||||
betreuteId: string
|
||||
schluessel: string
|
||||
anzeigename: string
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
|
||||
zahlungsgruppeAbhaken,
|
||||
{},
|
||||
)
|
||||
const [offen, setOffen] = React.useState(false)
|
||||
|
||||
if (!offen) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{zustand.fehler && (
|
||||
<Alert variant="fehler">
|
||||
<AlertCircle aria-hidden="true" />
|
||||
<AlertDescription>{zustand.fehler}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(true)}>
|
||||
<XCircle aria-hidden="true" />
|
||||
Kein Vertrag
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={absenden} className="space-y-2 rounded-md border p-3">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="schluessel" value={schluessel} />
|
||||
<input type="hidden" name="anzeigename" value={anzeigename} />
|
||||
|
||||
<p className="text-sm">
|
||||
„{anzeigename}“ verschwindet aus der Liste. Zurückholen geht jederzeit.
|
||||
</p>
|
||||
|
||||
<Input
|
||||
name="begruendung"
|
||||
placeholder="Warum kein Vertrag? (z. B. Miete, Strom, Steuer)"
|
||||
maxLength={200}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Absendeknopf size="sm" wartetext="Wird abgehakt …">
|
||||
Abhaken
|
||||
</Absendeknopf>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={() => setOffen(false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/** Holt eine abgehakte Gruppe zurück in die Liste. */
|
||||
export function Zurueckholen({
|
||||
betreuteId,
|
||||
eintragId,
|
||||
}: {
|
||||
betreuteId: string
|
||||
eintragId: string
|
||||
}) {
|
||||
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
|
||||
zahlungsgruppeZurueckholen,
|
||||
{},
|
||||
)
|
||||
|
||||
return (
|
||||
<form action={absenden} className="inline">
|
||||
<input type="hidden" name="betreuteId" value={betreuteId} />
|
||||
<input type="hidden" name="eintragId" value={eintragId} />
|
||||
{zustand.fehler && (
|
||||
<span className="mr-2 text-xs text-destructive">{zustand.fehler}</span>
|
||||
)}
|
||||
<Absendeknopf variant="ghost" size="sm" wartetext="…">
|
||||
<Undo2 aria-hidden="true" />
|
||||
Zurückholen
|
||||
</Absendeknopf>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt die Rückmeldung der letzten Aktion.
|
||||
*
|
||||
* Bewusst getrennt von den Knöpfen: Nach dem Abhaken verschwindet die
|
||||
* ganze Zeile samt Knopf, und mit ihr die Erfolgsmeldung. Sie hier
|
||||
* anzuzeigen wäre sinnlos — deshalb steht der Erfolg als kurzer Hinweis
|
||||
* oben auf der Seite, sobald die Liste neu geladen ist.
|
||||
*/
|
||||
export function Rueckmeldung({ text }: { text: string | null }) {
|
||||
if (!text) return null
|
||||
return (
|
||||
<Alert variant="erfolg">
|
||||
<CheckCircle2 aria-hidden="true" />
|
||||
<AlertDescription>{text}</AlertDescription>
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user