Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert. Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr derselbe Satz, mal Anzahl der Vertraege. Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre Aussage gegenueber dem Rechtspfleger. Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle, Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung nennt den Vertrag samt Nummer statt "ohne Beleg". Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer. Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht, und das soll auffallen. Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb vergessen werden. Gefunden hat es der Browsertest. Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt. Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler. Refs #11
338 lines
15 KiB
TypeScript
338 lines
15 KiB
TypeScript
import Link from 'next/link'
|
|
import { AlertCircle, FileSearch, Plus, Repeat } from 'lucide-react'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
|
import { Leerzustand, SeitenKopf } from '@/components/seiten-kopf'
|
|
import { Betrag } from '@/components/finanzen/bausteine'
|
|
import {
|
|
AlsGeklaertAbhaken,
|
|
ZahlungenZuordnen,
|
|
Zurueckholen,
|
|
} from '@/components/finanzen/wiederkehrend-aktionen'
|
|
import { finanzKontext } from '@/lib/finanzen/seite'
|
|
import { wiederkehrendLaden } from '@/lib/finanzen/wiederkehrend-laden'
|
|
import {
|
|
EINZELZAHLUNG_AB_CENT,
|
|
RHYTHMUS_BESCHRIFTUNG,
|
|
rhythmusErklaerung,
|
|
} from '@/lib/finanzen/wiederkehrend'
|
|
import { centZuZahl } from '@/lib/db'
|
|
import { formatCent, formatDatum } from '@/lib/utils'
|
|
|
|
export const metadata = { title: 'Wiederkehrende Zahlungen' }
|
|
export const dynamic = 'force-dynamic'
|
|
|
|
/**
|
|
* Wiederkehrende Zahlungen — die Suche nach unbekannten Verträgen.
|
|
*
|
|
* Am Anfang einer Betreuung weiß niemand, welche Verträge es gibt. Die
|
|
* Antwort steht im Kontoauszug: Wer regelmäßig abbucht, hat einen Vertrag.
|
|
* Diese Seite macht aus hunderten Buchungszeilen eine Arbeitsliste.
|
|
*
|
|
* Sie legt von sich aus nichts an. Jede Zeile endet in einer Entscheidung,
|
|
* die ein Mensch trifft — Vertrag anlegen oder abhaken.
|
|
*/
|
|
export default async function WiederkehrendSeite() {
|
|
const { betreute, darfSchreiben } = await finanzKontext()
|
|
const schreiben = darfSchreiben('FINANZEN')
|
|
const bild = await wiederkehrendLaden(betreute.id)
|
|
|
|
const ohneVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId === null)
|
|
const mitVertrag = bild.gruppen.filter((gruppe) => gruppe.vertragId !== null)
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<SeitenKopf
|
|
titel="Wiederkehrende Zahlungen"
|
|
beschreibung="Wer regelmäßig abbucht, hat einen Vertrag. Hier stehen die Kandidaten, zu denen noch keiner in der Akte liegt."
|
|
aktionen={
|
|
<Button asChild variant="outline">
|
|
<Link href="/finanzen/buchungen">Zu den Buchungen</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
|
|
{bild.ausgabenGesamt === 0 ? (
|
|
<Leerzustand
|
|
icon={FileSearch}
|
|
titel="Noch keine Ausgaben erfasst"
|
|
beschreibung="Diese Auswertung liest die Kontobewegungen. Importiere zuerst einen Kontoauszug, dann findest du hier die regelmäßigen Abbuchungen."
|
|
aktion={
|
|
<Button asChild>
|
|
<Link href="/finanzen/buchungen/import">Kontoauszug einlesen</Link>
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<>
|
|
{/* --- Kandidaten ohne Vertrag ----------------------------------- */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2 text-base">
|
|
<Repeat className="size-5" aria-hidden="true" />
|
|
Ohne hinterlegten Vertrag
|
|
{ohneVertrag.length > 0 && (
|
|
<Badge variant="secondary">{ohneVertrag.length}</Badge>
|
|
)}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Nach Jahresbetrag sortiert — was am meisten kostet, will man zuerst geklärt haben.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{ohneVertrag.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
Zu jeder regelmäßigen Abbuchung liegt ein Vertrag in der Akte. Das ist der
|
|
Zustand, den man haben will.
|
|
</p>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Empfänger</TableHead>
|
|
<TableHead>Rhythmus</TableHead>
|
|
<TableHead className="text-right">je Zahlung</TableHead>
|
|
<TableHead className="text-right">im Jahr</TableHead>
|
|
<TableHead>Zeitraum</TableHead>
|
|
{schreiben && <TableHead className="text-right">Und jetzt?</TableHead>}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{ohneVertrag.map((gruppe) => (
|
|
<TableRow key={gruppe.schluessel}>
|
|
<TableCell className="align-top">
|
|
<p className="font-medium">{gruppe.anzeigename}</p>
|
|
{gruppe.schreibweisen.length > 1 && (
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{gruppe.schreibweisen.length} Schreibweisen zusammengefasst
|
|
</p>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell className="align-top">
|
|
<span
|
|
className={
|
|
gruppe.rhythmus === 'UNREGELMAESSIG'
|
|
? 'text-muted-foreground'
|
|
: undefined
|
|
}
|
|
>
|
|
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]}
|
|
</span>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
{rhythmusErklaerung(gruppe)}
|
|
</p>
|
|
</TableCell>
|
|
|
|
<TableCell className="text-right align-top">
|
|
<Betrag cent={centZuZahl(gruppe.betragCent)} />
|
|
{gruppe.betragSchwankt && (
|
|
<p className="mt-0.5 text-xs text-ampel-orange">
|
|
schwankt: {formatCent(centZuZahl(gruppe.kleinsterBetragCent))} bis{' '}
|
|
{formatCent(centZuZahl(gruppe.groessterBetragCent))}
|
|
</p>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell className="text-right align-top">
|
|
<Betrag cent={centZuZahl(gruppe.jahresbetragCent)} />
|
|
{gruppe.rhythmus === 'UNREGELMAESSIG' && (
|
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
tatsächlich gezahlt
|
|
</p>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell className="align-top text-sm text-muted-foreground">
|
|
{formatDatum(gruppe.ersteZahlung)}
|
|
<br />
|
|
bis {formatDatum(gruppe.letzteZahlung)}
|
|
</TableCell>
|
|
|
|
{schreiben && (
|
|
<TableCell className="align-top">
|
|
<div className="flex flex-col items-end gap-2">
|
|
<Button asChild size="sm" variant="outline">
|
|
<Link
|
|
href={`/vertraege/neu?gesellschaft=${encodeURIComponent(
|
|
gruppe.anzeigename,
|
|
)}&beitragCent=${gruppe.betragCent}&rhythmus=${gruppe.rhythmus}`}
|
|
>
|
|
<Plus aria-hidden="true" />
|
|
Vertrag anlegen
|
|
</Link>
|
|
</Button>
|
|
<AlsGeklaertAbhaken
|
|
betreuteId={betreute.id}
|
|
schluessel={gruppe.schluessel}
|
|
anzeigename={gruppe.anzeigename}
|
|
/>
|
|
</div>
|
|
</TableCell>
|
|
)}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* --- Einzelne groessere Zahlungen ------------------------------ */}
|
|
{bild.einzelne.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Einzelne größere Zahlungen</CardTitle>
|
|
<CardDescription>
|
|
Kein Vertragsverdacht, sondern eine Merkliste. Die Gruppierung oben braucht
|
|
mindestens zwei Zahlungen — eine jährlich abgebuchte Prämie erscheint bei
|
|
einem importierten Jahr aber nur einmal. Ausgerechnet Versicherungen werden
|
|
oft jährlich gezahlt, deshalb steht hier alles ab{' '}
|
|
{formatCent(centZuZahl(EINZELZAHLUNG_AB_CENT))}.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>Empfänger</TableHead>
|
|
<TableHead>Datum</TableHead>
|
|
<TableHead className="text-right">Betrag</TableHead>
|
|
<TableHead>Vertrag</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{bild.einzelne.map((zahlung) => (
|
|
<TableRow key={zahlung.buchungId}>
|
|
<TableCell className="font-medium">{zahlung.anzeigename}</TableCell>
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{formatDatum(zahlung.datum)}
|
|
</TableCell>
|
|
<TableCell className="text-right">
|
|
<Betrag cent={centZuZahl(zahlung.betragCent)} />
|
|
</TableCell>
|
|
<TableCell className="text-sm">
|
|
{zahlung.vertragBezeichnung ? (
|
|
<Badge variant="secondary">{zahlung.vertragBezeichnung}</Badge>
|
|
) : (
|
|
<span className="text-muted-foreground">keiner</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* --- Bereits erklaert ------------------------------------------ */}
|
|
{mitVertrag.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Schon einem Vertrag zugeordnet</CardTitle>
|
|
<CardDescription>
|
|
Erkannt am Namen der Gesellschaft. Steht hier etwas Falsches, stimmt die
|
|
Schreibweise am Vertrag nicht mit der auf dem Kontoauszug überein.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="divide-y text-sm">
|
|
{mitVertrag.map((gruppe) => (
|
|
<li key={gruppe.schluessel} className="space-y-2 py-2">
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<span>
|
|
<span className="font-medium">{gruppe.anzeigename}</span>
|
|
<span className="ml-2 text-muted-foreground">
|
|
{RHYTHMUS_BESCHRIFTUNG[gruppe.rhythmus]},{' '}
|
|
{formatCent(centZuZahl(gruppe.jahresbetragCent))} im Jahr
|
|
</span>
|
|
</span>
|
|
<Button asChild variant="ghost" size="sm">
|
|
<Link href={`/vertraege/${gruppe.vertragId}`}>
|
|
{gruppe.vertragBezeichnung}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Der Vertrag liegt vor - dann belegt er auch die
|
|
Zahlungen. Einzeln anzuklicken waeren bei drei
|
|
Jahren Laufzeit 36 Klicks. */}
|
|
{schreiben && gruppe.offeneBuchungIds.length > 0 && (
|
|
<ZahlungenZuordnen
|
|
betreuteId={betreute.id}
|
|
vertragId={gruppe.vertragId!}
|
|
vertragBezeichnung={gruppe.vertragBezeichnung ?? 'Vertrag'}
|
|
buchungIds={gruppe.offeneBuchungIds}
|
|
/>
|
|
)}
|
|
|
|
{gruppe.offeneBuchungIds.length === 0 && (
|
|
<p className="text-xs text-ampel-gruen">
|
|
Alle {gruppe.anzahl} Zahlungen sind dem Vertrag zugeordnet und damit
|
|
belegt.
|
|
</p>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* --- Abgehakt --------------------------------------------------- */}
|
|
{bild.abgehakt.length > 0 && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Als „kein Vertrag“ abgehakt</CardTitle>
|
|
<CardDescription>
|
|
Diese Empfänger bleiben aus der Liste heraus, auch nach dem nächsten Import.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<ul className="divide-y text-sm">
|
|
{bild.abgehakt.map((eintrag) => (
|
|
<li
|
|
key={eintrag.id}
|
|
className="flex flex-wrap items-center justify-between gap-2 py-2"
|
|
>
|
|
<span>
|
|
<span className="font-medium">{eintrag.anzeigename}</span>
|
|
{eintrag.begruendung && (
|
|
<span className="ml-2 text-muted-foreground">
|
|
— {eintrag.begruendung}
|
|
</span>
|
|
)}
|
|
</span>
|
|
{schreiben && (
|
|
<Zurueckholen betreuteId={betreute.id} eintragId={eintrag.id} />
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Alert variant="hinweis">
|
|
<AlertCircle aria-hidden="true" />
|
|
<AlertDescription>
|
|
Diese Seite wertet nur aus, sie legt nichts an. Gruppiert wird nach dem Empfänger
|
|
aus dem Kontoauszug — Groß- und Kleinschreibung, Satzzeichen und die Rechtsform
|
|
werden dabei zusammengefasst. Ähnliche, aber verschiedene Namen bleiben bewusst
|
|
getrennt: „Stadtwerke Bamberg“ und „Stadtwerke Bayreuth“ sind zwei Rechnungssteller.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|