Phase 9: Kassenverwaltung (#14)

This commit was merged in pull request #14.
This commit is contained in:
2026-09-05 02:30:51 +00:00
parent d70acad4cd
commit 2b53556175
78 changed files with 12314 additions and 42 deletions
+179 -4
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import Image from "next/image";
import { Camera, Check, Trash2 } from "lucide-react";
import { Camera, Check, Trash2, Wallet } from "lucide-react";
import { KiStand } from "@prisma/client";
import type { inferRouterOutputs } from "@trpc/server";
import { trpc } from "@/lib/trpc/client";
@@ -12,6 +12,21 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KiAbzeichen, KiFehltHinweis } from "@/components/ki/kennzeichnung";
import { dateiHochladen } from "@/lib/hochladen";
@@ -33,9 +48,11 @@ import { alsDatum, alsEuro } from "@/lib/utils";
* Feld ist eine Aufforderung; ein geratener Wert wäre eine falsche Buchung,
* die niemand bemerkt.
*/
export function BelegListe() {
export function BelegListe({ darfBuchen }: { darfBuchen: boolean }) {
const werkzeuge = trpc.useUtils();
const [buchen, setBuchen] = React.useState<Beleg | null>(null);
const [laedtHoch, setLaedtHoch] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
@@ -141,7 +158,8 @@ export function BelegListe() {
<th className="py-2 pr-4">Kategorie</th>
<th className="py-2 pr-4 text-right">Betrag</th>
<th className="py-2 pr-4">Stand</th>
<th className="py-2">Gegengezeichnet</th>
<th className="py-2 pr-4">Gegengezeichnet</th>
<th className="py-2">Kasse</th>
</tr>
</thead>
<tbody>
@@ -158,11 +176,23 @@ export function BelegListe() {
{KI_STAND_TEXT[beleg.stand]}
</Badge>
</td>
<td className="py-2">
<td className="py-2 pr-4">
{beleg.bestaetigtVon
? `${beleg.bestaetigtVon.vorname} ${beleg.bestaetigtVon.nachname}`
: "—"}
</td>
<td className="py-2">
{beleg.buchungId ? (
<Badge variant="einsatzbereit">gebucht</Badge>
) : beleg.stand === KiStand.UEBERNOMMEN && darfBuchen ? (
<Button size="sm" variant="outline" onClick={() => setBuchen(beleg)}>
<Wallet aria-hidden />
Buchen
</Button>
) : (
"—"
)}
</td>
</tr>
))}
</tbody>
@@ -170,6 +200,19 @@ export function BelegListe() {
</div>
</section>
)}
<BelegBuchenDialog
beleg={buchen}
schliessen={() => setBuchen(null)}
fehler={setMeldung}
fertig={(betrag) => {
setBuchen(null);
setHinweis(
`Beleg über ${betrag} gebucht. Die Buchung steht als Entwurf im Kassenbuch.`,
);
void werkzeuge.assistent.invalidate();
}}
/>
</div>
);
}
@@ -327,3 +370,135 @@ function BelegKarte({ beleg, fehler }: { beleg: Beleg; fehler: (nachricht: strin
</Card>
);
}
/**
* Aus einem gegengezeichneten Beleg eine Buchung machen.
*
* Konto und Kategorie fehlen dem Beleg — das kann keine Maschine entscheiden,
* und der Kassier soll es auch nicht raten müssen. Betrag, Datum und Händler
* stehen bereits fest; sie stammen aus dem gegengezeichneten Beleg und werden
* hier nicht noch einmal zur Bearbeitung gestellt.
*
* Das Foto hängt danach an der Buchung. Genau das verlangt eine
* Kassenprüfung: zu jeder Ausgabe ein Beleg, und zwar an der Buchung und
* nicht in einem Ordner daneben.
*/
function BelegBuchenDialog({
beleg,
schliessen,
fertig,
fehler,
}: {
beleg: Beleg | null;
schliessen: () => void;
fertig: (betrag: string) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery(undefined, { enabled: beleg !== null });
const kategorien = trpc.buchungskategorie.liste.useQuery(
{ art: "AUSGABE" },
{ enabled: beleg !== null },
);
const vorschlag = trpc.buchung.vorschlag.useQuery(
{
verwendungszweck: `${beleg?.haendler ?? ""} ${beleg?.kategorie ?? ""}`.trim(),
art: "AUSGABE",
},
{ enabled: beleg !== null && Boolean(beleg.haendler ?? beleg.kategorie) },
);
const buchen = trpc.buchung.belegBuchen.useMutation({
onSuccess: (buchung) => fertig(alsEuro(Number(buchung.betrag))),
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Derselbe Vorschlag wie in der Buchungsmaske — sonst landet derselbe
// Händler einmal unter Kraftstoff und einmal unter Fahrzeuge, und die
// Jahresauswertung ist dahin.
React.useEffect(() => {
if (!kategorieId && vorschlag.data) setKategorieId(vorschlag.data.kategorieId);
}, [kategorieId, vorschlag.data]);
React.useEffect(() => {
if (!beleg) setKategorieId("");
}, [beleg]);
return (
<Dialog open={beleg !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beleg buchen</DialogTitle>
<DialogDescription>
{beleg?.haendler ?? "Beleg"}
{beleg?.betrag !== null &&
beleg?.betrag !== undefined &&
` · ${alsEuro(Number(beleg.betrag))}`}
{beleg?.belegdatum && ` · ${alsDatum(beleg.belegdatum)}`}. Es entsteht eine Ausgabe im
Kassenbuch, mit dem Foto als Beleg. Sie ist zunächst ein Entwurf.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="beleg-konto">Von welchem Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="beleg-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="beleg-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="beleg-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
{vorschlag.data && (
<p className="text-xs text-[var(--muted-foreground)]">
Vorgeschlagen: {vorschlag.data.name} — {vorschlag.data.grund}
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => beleg && buchen.mutate({ belegId: beleg.id, kontoId, kategorieId })}
disabled={!kontoId || !kategorieId || buchen.isPending}
>
{buchen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+5 -5
View File
@@ -8,9 +8,9 @@ export const metadata = { title: "Belege" };
/**
* Belegerfassung.
*
* Quittung fotografieren, Angaben prüfen, gegenzeichnen. Die Buchung daraus
* kommt mit der Kasse in Phase 9 — hier entsteht erst einmal ein sauber
* erfasster Beleg mit Foto.
* Quittung fotografieren, Angaben prüfen, gegenzeichnen — und seit Phase 9
* buchen. Der letzte Schritt braucht ein eigenes Recht: wer Belege erfasst,
* führt deshalb noch lange nicht die Kasse.
*/
export default async function BelegeSeite() {
const sitzung = await aktuelleSitzung();
@@ -22,11 +22,11 @@ export default async function BelegeSeite() {
<div>
<h1 className="text-2xl font-semibold tracking-tight">Belege</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Quittung fotografieren, Angaben prüfen, gegenzeichnen.
Quittung fotografieren, Angaben prüfen, gegenzeichnen — und ins Kassenbuch buchen.
</p>
</div>
<BelegListe />
<BelegListe darfBuchen={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+780
View File
@@ -0,0 +1,780 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Download, FileText, Lock, Plus } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
const MONATE = [
"Januar",
"Februar",
"März",
"April",
"Mai",
"Juni",
"Juli",
"August",
"September",
"Oktober",
"November",
"Dezember",
];
/**
* Abschluss und Auswertung.
*
* Zwei Dinge, die zusammengehören: der Kassenprüfer will die Jahreszahlen
* sehen, bevor er unterschreibt, und die Jahreshauptversammlung will beides.
*/
export function Abschlussansicht({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const [reiter, setReiter] = React.useState<"pruefung" | "auswertung">("pruefung");
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
<div className="flex gap-2 border-b border-[var(--border)]">
<Reiter aktiv={reiter === "pruefung"} beim={() => setReiter("pruefung")}>
Kassenprüfung
</Reiter>
<Reiter aktiv={reiter === "auswertung"} beim={() => setReiter("auswertung")}>
Auswertung
</Reiter>
</div>
{reiter === "pruefung" ? (
<Pruefungen darfAbschliessen={darfAbschliessen} />
) : (
<Auswertung />
)}
</div>
);
}
function Reiter({
aktiv,
beim,
children,
}: {
aktiv: boolean;
beim: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={beim}
className={`-mb-px border-b-2 px-3 py-2 text-sm ${
aktiv
? "border-[var(--primary)] font-medium"
: "border-transparent text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
}`}
>
{children}
</button>
);
}
/** Die Liste der Prüfungen — und der Weg zu einer neuen. */
function Pruefungen({ darfAbschliessen }: { darfAbschliessen: boolean }) {
const werkzeuge = trpc.useUtils();
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const abschluesse = trpc.kassenabschluss.liste.useQuery();
const festschreiben = trpc.kassenabschluss.festschreiben.useMutation({
onSuccess: () => {
setHinweis(
"Festgeschrieben. Der Zeitraum ist zu, und der Prüfbericht entsteht im Hintergrund.",
);
void werkzeuge.kassenabschluss.invalidate();
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
return (
<div className="space-y-4">
{meldung && <Fehlermeldung meldung={meldung} kennung="abschlussfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{darfAbschliessen && (
<div className="flex justify-end">
<Button onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Kassenprüfung anlegen
</Button>
</div>
)}
{!abschluesse.isLoading && (abschluesse.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Kassenprüfung"
hinweis="Ein Abschluss hält fest, was gezählt wurde und was das Buch sagt — und schließt den Zeitraum danach."
/>
)}
<div className="space-y-3">
{abschluesse.data?.map((abschluss) => {
const differenz = Number(abschluss.differenz);
return (
<Card key={abschluss.id}>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-2">
<div>
<CardTitle className="text-base">
{alsDatum(abschluss.vonAm)} bis {alsDatum(abschluss.bisAm)}
</CardTitle>
<CardDescription>
{abschluss.pruefer
? `Geprüft von ${abschluss.pruefer.vorname} ${abschluss.pruefer.nachname}`
: "Noch kein Prüfer eingetragen"}
{abschluss.zweiterPruefer &&
` und ${abschluss.zweiterPruefer.vorname} ${abschluss.zweiterPruefer.nachname}`}
{abschluss.geprueftAm && ` am ${alsDatum(abschluss.geprueftAm)}`}
{` · ${abschluss._count.posten} Konten`}
</CardDescription>
</div>
{abschluss.festgeschriebenAm ? (
<Badge variant="einsatzbereit">
<Lock className="size-3" aria-hidden />
Festgeschrieben
</Badge>
) : (
<Badge variant="outline">Entwurf</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="grid gap-3 text-sm sm:grid-cols-3">
<Wert titel="Laut Buch" wert={abschluss.bestandLautBuch} />
<Wert titel="Gezählt" wert={abschluss.gezaehlterBestand} />
<Wert
titel="Differenz"
wert={abschluss.differenz}
warnen={Math.abs(differenz) > 0.005}
/>
</div>
{abschluss.erlaeuterung && (
<p className="rounded-md bg-[var(--muted)] p-3 text-sm">
{abschluss.erlaeuterung}
</p>
)}
<div className="flex flex-wrap gap-2">
{abschluss.berichtDatei && (
<Button size="sm" variant="outline" asChild>
<a href={`/api/dateien/${abschluss.berichtDatei.id}`} target="_blank">
<Download aria-hidden />
Prüfbericht
</a>
</Button>
)}
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<Button
size="sm"
onClick={() => festschreiben.mutate({ id: abschluss.id })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Festschreiben
</Button>
)}
</div>
{darfAbschliessen && !abschluss.festgeschriebenAm && (
<p className="text-xs text-[var(--muted-foreground)]">
Festschreiben schließt den Zeitraum: die Buchungen darin lassen sich danach
nicht mehr ändern, und ein neues Datum in diesem Zeitraum wird abgelehnt.
</p>
)}
</CardContent>
</Card>
);
})}
</div>
{darfAbschliessen && (
<AbschlussAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={() => {
setAnlegenOffen(false);
setHinweis(
"Angelegt — noch als Entwurf. Die Prüfer können ihre Zahlen korrigieren, solange sie beisammensitzen.",
);
void werkzeuge.kassenabschluss.invalidate();
}}
/>
)}
</div>
);
}
function Wert({
titel,
wert,
warnen = false,
}: {
titel: string;
wert: string | { toString(): string };
warnen?: boolean;
}) {
return (
<div>
<p className="text-xs text-[var(--muted-foreground)]">{titel}</p>
<p
className={`text-lg font-semibold tabular-nums ${
warnen ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</div>
);
}
/**
* Die Prüfungsmaske.
*
* Links steht, was das Buch sagt; rechts wird eingetragen, was gezählt wurde.
* In dieser Reihenfolge — wer erst das Buch sieht und dann zählt, zählt, bis
* es passt.
*
* Das ist der Grund, warum die Buchbestände hier zwar zu sehen sind (der
* Prüfer braucht sie für die Differenz), die gezählten Werte aber leer
* anfangen und nicht vorbelegt sind.
*/
function AbschlussAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const jahr = new Date().getFullYear() - 1;
const [vonAm, setVonAm] = React.useState(`${jahr}-01-01`);
const [bisAm, setBisAm] = React.useState(`${jahr}-12-31`);
const [prueferId, setPrueferId] = React.useState("");
const [zweiterId, setZweiterId] = React.useState("");
const [geprueftAm, setGeprueftAm] = React.useState(new Date().toISOString().slice(0, 10));
const [erlaeuterung, setErlaeuterung] = React.useState("");
const [gezaehlt, setGezaehlt] = React.useState<Record<string, string>>({});
const vorbereitung = trpc.kassenabschluss.vorbereiten.useQuery(
{ vonAm: new Date(vonAm), bisAm: new Date(bisAm) },
{ enabled: offen && Boolean(vonAm) && Boolean(bisAm) },
);
const mitglieder = trpc.person.liste.useQuery(
{ seite: 1, seitengroesse: 200, richtung: "asc" },
{ enabled: offen },
);
const anlegen = trpc.kassenabschluss.anlegen.useMutation({
onSuccess: () => {
setGezaehlt({});
setErlaeuterung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
const posten = vorbereitung.data?.posten ?? [];
const alleGezaehlt = posten.every((eintrag) => gezaehlt[eintrag.kontoId]?.trim());
const summeGezaehlt = posten.reduce(
(gesamt, eintrag) => gesamt + Number(gezaehlt[eintrag.kontoId] || 0),
0,
);
const differenz = summeGezaehlt - Number(vorbereitung.data?.gesamt ?? 0);
const abweichung = Math.abs(differenz) > 0.005;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Kassenprüfung</DialogTitle>
<DialogDescription>
Erst zählen, dann eintragen. Der Buchbestand steht daneben, damit die Differenz
sichtbar wird — und nicht, damit die gezählte Zahl daran angepasst wird.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-von">Zeitraum von</Label>
<Input
id="abschluss-von"
type="date"
value={vonAm}
onChange={(ereignis) => setVonAm(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-bis">bis</Label>
<Input
id="abschluss-bis"
type="date"
value={bisAm}
onChange={(ereignis) => setBisAm(ereignis.target.value)}
/>
</div>
</div>
{(vorbereitung.data?.offeneBuchungen ?? 0) > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
{vorbereitung.data?.offeneBuchungen} Buchungen im Zeitraum sind noch nicht
festgeschrieben. Das Festschreiben des Abschlusses nimmt sie mit — wer sie vorher
durchsieht, prüft einen Stand, der sich danach nicht mehr ändert.
</div>
)}
{posten.length > 0 && (
<div className="overflow-x-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Laut Buch</th>
<th className="px-3 py-2 text-right">Gezählt</th>
</tr>
</thead>
<tbody>
{posten.map((eintrag) => {
const wert = gezaehlt[eintrag.kontoId] ?? "";
const einzelDifferenz = wert
? Number(wert) - Number(eintrag.bestandLautBuch)
: 0;
return (
<tr
key={eintrag.kontoId}
className="border-b border-[var(--border)] last:border-0"
>
<td className="px-3 py-2">
{eintrag.bezeichnung}
<span className="block text-xs text-[var(--muted-foreground)]">
im Zeitraum +{alsBetrag(eintrag.einnahmen)} / −
{alsBetrag(eintrag.ausgaben)}
</span>
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(eintrag.bestandLautBuch)}
</td>
<td className="px-3 py-2 text-right">
<Input
type="number"
step="0.01"
className="w-32 text-right"
value={wert}
onChange={(ereignis) =>
setGezaehlt((vorher) => ({
...vorher,
[eintrag.kontoId]: ereignis.target.value,
}))
}
aria-label={`Gezählter Bestand ${eintrag.bezeichnung}`}
/>
{wert && Math.abs(einzelDifferenz) > 0.005 && (
<span className="block text-xs text-[var(--destructive)]">
{einzelDifferenz > 0 ? "+" : ""}
{alsBetrag(einzelDifferenz.toFixed(2))}
</span>
)}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-[var(--border)] font-medium">
<td className="px-3 py-2">Summe</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(vorbereitung.data?.gesamt)}
</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(summeGezaehlt.toFixed(2))}
</td>
</tr>
</tfoot>
</table>
</div>
)}
{abweichung && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
Differenz von <strong>{alsBetrag(differenz.toFixed(2))}</strong>. Sie muss erklärt
werden, sonst lässt sich der Abschluss nicht festschreiben — in fünf Jahren weiß
sonst niemand mehr, woher sie kam.
</div>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="abschluss-pruefer">Kassenprüfer</Label>
<Select value={prueferId} onValueChange={setPrueferId}>
<SelectTrigger id="abschluss-pruefer">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-zweiter">Zweiter Prüfer</Label>
<Select value={zweiterId} onValueChange={setZweiterId}>
<SelectTrigger id="abschluss-zweiter">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{mitglieder.data?.eintraege.map((person) => (
<SelectItem key={person.id} value={person.id}>
{person.nachname}, {person.vorname}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-geprueft">Geprüft am</Label>
<Input
id="abschluss-geprueft"
type="date"
value={geprueftAm}
onChange={(ereignis) => setGeprueftAm(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="abschluss-erlaeuterung">
Erläuterung {abweichung && <span className="text-[var(--destructive)]">*</span>}
</Label>
<Textarea
id="abschluss-erlaeuterung"
rows={3}
value={erlaeuterung}
onChange={(ereignis) => setErlaeuterung(ereignis.target.value)}
placeholder="Bar 3,20 € weniger — Wechselgeld vom Maifest, Beleg fehlt."
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
vonAm: new Date(vonAm),
bisAm: new Date(bisAm),
prueferId: prueferId || null,
zweiterPrueferId: zweiterId || null,
geprueftAm: geprueftAm ? new Date(geprueftAm) : null,
erlaeuterung: erlaeuterung.trim() || null,
posten: posten.map((eintrag) => ({
kontoId: eintrag.kontoId,
gezaehlterBestand: Number(gezaehlt[eintrag.kontoId] ?? 0),
})),
})
}
disabled={posten.length === 0 || !alleGezaehlt || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Jahreszahlen für die Jahreshauptversammlung. */
function Auswertung() {
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [exportLaeuft, setExportLaeuft] = React.useState(false);
const werkzeuge = trpc.useUtils();
const auswertung = trpc.kassenabschluss.auswertung.useQuery({ jahr });
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
async function herunterladen() {
setExportLaeuft(true);
try {
const datei = await werkzeuge.kassenabschluss.export.fetch({ jahr });
// Der Umweg über einen Blob statt eines Links auf eine API-Route: die
// Daten liegen bereits vor, und so entsteht kein zweiter Weg an der
// Rechteprüfung vorbei.
const ziel = URL.createObjectURL(
new Blob([datei.inhalt], { type: "text/csv;charset=utf-8" }),
);
const verweis = document.createElement("a");
verweis.href = ziel;
verweis.download = datei.dateiname;
verweis.click();
URL.revokeObjectURL(ziel);
} finally {
setExportLaeuft(false);
}
}
const einnahmen = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.EINNAHME,
);
const ausgaben = auswertung.data?.kategorien.filter(
(eintrag) => eintrag.art === BuchungsArt.AUSGABE,
);
const hoechster = Math.max(
1,
...(auswertung.data?.monate.flatMap((monat) => [
Number(monat.einnahmen),
Number(monat.ausgaben),
]) ?? [1]),
);
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="outline"
className="ml-auto"
onClick={() => void herunterladen()}
disabled={exportLaeuft}
>
<FileText aria-hidden />
{exportLaeuft ? "Wird erzeugt …" : "Kassenbuch als CSV"}
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<Vergleich
titel="Einnahmen"
wert={auswertung.data?.gesamt.einnahmen}
vorjahr={auswertung.data?.vorjahr.einnahmen}
jahr={jahr}
/>
<Vergleich
titel="Ausgaben"
wert={auswertung.data?.gesamt.ausgaben}
vorjahr={auswertung.data?.vorjahr.ausgaben}
jahr={jahr}
/>
<Vergleich
titel="Saldo"
wert={auswertung.data?.gesamt.saldo}
vorjahr={auswertung.data?.vorjahr.saldo}
jahr={jahr}
/>
</div>
{auswertung.data?.anzahl === 0 && (
<NichtsGefunden
titel={`Für ${jahr} steht nichts im Kassenbuch`}
hinweis="Ohne Buchungen gibt es nichts auszuwerten."
/>
)}
{(auswertung.data?.anzahl ?? 0) > 0 && (
<>
{/* Der Jahresverlauf. Zwölf Balkenpaare, kein Diagrammpaket —
das reicht, um zu sehen, wo das Fest war. */}
<Card>
<CardHeader>
<CardTitle className="text-base">Verlauf {jahr}</CardTitle>
<CardDescription>
Einnahmen und Ausgaben je Monat, gemessen am größten Monatswert.
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-1.5">
{auswertung.data?.monate.map((monat) => (
<div key={monat.monat} className="flex items-center gap-3 text-xs">
<span className="w-20 shrink-0 text-[var(--muted-foreground)]">
{MONATE[monat.monat - 1]}
</span>
<div className="flex flex-1 flex-col gap-0.5">
<div
className="h-2 rounded-sm bg-[var(--einsatzbereit)]"
style={{ width: `${(Number(monat.einnahmen) / hoechster) * 100}%` }}
/>
<div
className="h-2 rounded-sm bg-[var(--destructive)]"
style={{ width: `${(Number(monat.ausgaben) / hoechster) * 100}%` }}
/>
</div>
<span className="w-28 shrink-0 text-right tabular-nums">
{alsBetrag(monat.saldo)}
</span>
</div>
))}
</div>
</CardContent>
</Card>
<div className="grid gap-4 lg:grid-cols-2">
<Kategorieliste titel="Einnahmen nach Kategorie" zeilen={einnahmen} />
<Kategorieliste titel="Ausgaben nach Kategorie" zeilen={ausgaben} />
</div>
</>
)}
</div>
);
}
function Vergleich({
titel,
wert,
vorjahr,
jahr,
}: {
titel: string;
wert: string | undefined;
vorjahr: string | undefined;
jahr: number;
}) {
const jetzt = Number(wert ?? 0);
const davor = Number(vorjahr ?? 0);
const unterschied = jetzt - davor;
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
<p className="text-xs text-[var(--muted-foreground)]">
{jahr - 1}: {alsBetrag(vorjahr)}
{davor !== 0 && (
<>
{" · "}
{unterschied >= 0 ? "+" : "−"}
{alsBetrag(Math.abs(unterschied).toFixed(2))}
</>
)}
</p>
</CardContent>
</Card>
);
}
function Kategorieliste({
titel,
zeilen,
}: {
titel: string;
zeilen: Array<{ id: string; name: string; oberkategorie: string | null; summe: string }> | undefined;
}) {
const gesamt = zeilen?.reduce((wert, zeile) => wert + Number(zeile.summe), 0) ?? 0;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{titel}</CardTitle>
</CardHeader>
<CardContent>
{(zeilen?.length ?? 0) === 0 ? (
<p className="text-sm text-[var(--muted-foreground)]">Nichts gebucht.</p>
) : (
<ul className="space-y-1.5 text-sm">
{zeilen?.map((zeile) => (
<li key={zeile.id}>
<div className="flex justify-between gap-2">
<span>
{zeile.name}
{zeile.oberkategorie && (
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.oberkategorie}
</span>
)}
</span>
<span className="tabular-nums">{alsBetrag(zeile.summe)}</span>
</div>
<div
className="mt-0.5 h-1 rounded-sm bg-[var(--primary)]/40"
style={{ width: `${gesamt > 0 ? (Number(zeile.summe) / gesamt) * 100 : 0}%` }}
/>
</li>
))}
</ul>
)}
</CardContent>
</Card>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Abschlussansicht } from "@/app/(app)/kasse/abschluss/ansicht";
export const metadata = { title: "Kassenabschluss" };
export default async function AbschlussSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Abschluss und Auswertung</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kassenprüfung, Jahreszahlen und der Export für Steuerberater oder Kämmerei.
</p>
</div>
<Abschlussansicht darfAbschliessen={hatRecht(sitzung, "kasse.abschluss")} />
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Beitragsverwaltung } from "@/app/(app)/kasse/beitraege/verwaltung";
export const metadata = { title: "Beiträge" };
export default async function BeitraegeSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Mitgliedsbeiträge</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Sätze, Beitragslauf und wer noch nicht gezahlt hat.
</p>
</div>
<Beitragsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,735 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AlertTriangle, ArrowLeft, Check, Mail, PlayCircle, Plus } from "lucide-react";
import { BeitragStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BEITRAG_STAND_TEXT, BEITRAG_STAND_VARIANTE, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Beitragsverwaltung.
*
* Der Ablauf, wie er im Januar wirklich stattfindet:
*
* 1. Sätze prüfen — sind sie noch aktuell?
* 2. Beitragslauf starten. Vorher steht da, wie viele Beiträge entstehen und
* wer keinen Satz hat.
* 3. Zahlungen abhaken. Jede erzeugt eine Buchung.
* 4. Im Herbst mahnen, was offen ist.
*
* Die Liste ohne Satz ist der interessante Teil der Vorschau: das sind die
* Leute, für die niemand entschieden hat, was sie zahlen.
*/
export function Beitragsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [jahr, setJahr] = React.useState(new Date().getFullYear());
const [stand, setStand] = React.useState<BeitragStand | "alle">("alle");
const [suche, setSuche] = React.useState("");
const [laufOffen, setLaufOffen] = React.useState(false);
const [satzOffen, setSatzOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{ id: string; name: string; betrag: string } | null>(
null,
);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<string>>(new Set());
const saetze = trpc.beitrag.saetze.useQuery();
const beitraege = trpc.beitrag.liste.useQuery({
jahr,
stand: stand === "alle" ? undefined : stand,
suche: suche.trim() || undefined,
});
const rueckstaende = trpc.beitrag.rueckstaende.useQuery();
const mahnen = trpc.beitrag.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Beiträge gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort ist die fünfte Mahnstufe erreicht, und was danach kommt, entscheidet die Kommandantschaft.`
: ""),
);
setGewaehlt(new Set());
void werkzeuge.beitrag.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const erlassen = trpc.beitrag.erlassen.useMutation({
onSuccess: () => void werkzeuge.beitrag.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = beitraege.data?.beitraege ?? [];
return (
<div className="space-y-6">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="beitragsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Kennzahl titel="Sollstellung" wert={beitraege.data?.kennzahlen.gesamt} />
<Kennzahl titel="Bezahlt" wert={beitraege.data?.kennzahlen.bezahlt} />
<Kennzahl
titel="Offen"
wert={beitraege.data?.kennzahlen.offen}
hinweis={`${beitraege.data?.kennzahlen.anzahlOffen ?? 0} Mitglieder`}
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Kennzahl titel="Erlassen" wert={beitraege.data?.kennzahlen.erlassen} />
</div>
{/* Rückstände über alle Jahre — die Liste für die Jahreshauptversammlung */}
{(rueckstaende.data?.zeilen.length ?? 0) > 0 && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="size-4" aria-hidden />
Rückstände über alle Jahre
</CardTitle>
<CardDescription>
{rueckstaende.data?.anzahl} offene Beiträge über{" "}
{alsBetrag(rueckstaende.data?.gesamt)}. Wer über mehrere Jahre offen steht, fällt
hier auf — und sonst nirgends.
</CardDescription>
</CardHeader>
<CardContent>
<ul className="space-y-1 text-sm">
{rueckstaende.data?.zeilen.slice(0, 10).map((zeile) => (
<li key={zeile.person.id} className="flex flex-wrap justify-between gap-2">
<span>
{zeile.person.nachname}, {zeile.person.vorname}
<span className="text-[var(--muted-foreground)]">
{" "}
· {zeile.jahre.join(", ")}
{zeile.hoechsteMahnstufe > 0 && ` · ${zeile.hoechsteMahnstufe}. Mahnung`}
</span>
</span>
<span className="tabular-nums font-medium">{alsBetrag(zeile.summe)}</span>
</li>
))}
</ul>
</CardContent>
</Card>
)}
{/* Sätze */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Beitragssätze</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setSatzOffen(true)}>
<Plus aria-hidden />
Satz anlegen
</Button>
)}
</div>
{(saetze.data?.length ?? 0) === 0 ? (
<NichtsGefunden
titel="Noch keine Sätze"
hinweis="Ohne Satz weiß FLORIAN nicht, was ein Mitglied zahlt — der Beitragslauf legt dann nichts an."
/>
) : (
<div className="flex flex-wrap gap-2">
{saetze.data?.map((satz) => (
<div
key={satz.id}
className="rounded-lg border border-[var(--border)] bg-[var(--card)] px-3 py-2 text-sm"
>
<span className="font-medium">{satz.bezeichnung}</span>
<span className="text-[var(--muted-foreground)]">
{" "}
· {alsBetrag(satz.betrag)} ab {satz.abJahr}
{satz.abteilung ? ` · ${satz.abteilung}` : " · alle Abteilungen"}
</span>
</div>
))}
</div>
)}
</section>
{/* Liste */}
<section className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<Select value={String(jahr)} onValueChange={(wert) => setJahr(Number(wert))}>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as BeitragStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(BeitragStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{BEITRAG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Name oder Mitgliedsnummer"
className="min-w-48 flex-1"
aria-label="Mitglieder durchsuchen"
/>
{darfBearbeiten && (
<div className="ml-auto flex gap-2">
{gewaehlt.size > 0 && (
<Button
variant="outline"
onClick={() => mahnen.mutate({ ids: [...gewaehlt] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
{gewaehlt.size} mahnen
</Button>
)}
<Button onClick={() => setLaufOffen(true)}>
<PlayCircle aria-hidden />
Beitragslauf {jahr}
</Button>
</div>
)}
</div>
{!beitraege.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel={`Keine Beiträge für ${jahr}`}
hinweis={
darfBearbeiten
? "Der Beitragslauf legt sie für alle aktiven Mitglieder an."
: "Für dieses Jahr wurde noch nicht abgerechnet."
}
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
{darfBearbeiten && <th className="px-3 py-2" />}
<th className="px-3 py-2">Mitglied</th>
<th className="px-3 py-2">Abteilung</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2">Bezahlt</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((beitrag) => {
const offen =
beitrag.stand === BeitragStand.OFFEN || beitrag.stand === BeitragStand.GEMAHNT;
return (
<tr key={beitrag.id} className="border-b border-[var(--border)] last:border-0">
{darfBearbeiten && (
<td className="px-3 py-2">
{offen && (
<input
type="checkbox"
checked={gewaehlt.has(beitrag.id)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(beitrag.id);
else neu.delete(beitrag.id);
setGewaehlt(neu);
}}
aria-label={`${beitrag.person.nachname} auswählen`}
/>
)}
</td>
)}
<td className="px-3 py-2">
{beitrag.person.nachname}, {beitrag.person.vorname}
<span className="block text-xs text-[var(--muted-foreground)]">
{beitrag.person.mitgliedsnummer}
</span>
</td>
<td className="px-3 py-2">{beitrag.person.abteilung ?? "—"}</td>
<td className="px-3 py-2 text-right tabular-nums">
{alsBetrag(beitrag.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={BEITRAG_STAND_VARIANTE[beitrag.stand]}>
{BEITRAG_STAND_TEXT[beitrag.stand]}
</Badge>
{beitrag.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{beitrag.mahnstufe}.
</span>
)}
</td>
<td className="px-3 py-2 whitespace-nowrap">
{beitrag.bezahltAm ? alsDatum(beitrag.bezahltAm) : "—"}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: beitrag.id,
name: `${beitrag.person.nachname}, ${beitrag.person.vorname}`,
betrag: String(beitrag.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird der Beitrag erlassen? Ohne Begründung ist er in der Kassenprüfung eine offene Frage.",
);
if (grund?.trim()) {
erlassen.mutate({ id: beitrag.id, grund: grund.trim() });
}
}}
>
Erlassen
</Button>
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<>
<BeitragslaufDialog
offen={laufOffen}
jahr={jahr}
schliessen={() => setLaufOffen(false)}
fehler={setMeldung}
fertig={(angelegt) => {
setLaufOffen(false);
setHinweis(`${angelegt} Beiträge für ${jahr} angelegt.`);
void werkzeuge.beitrag.invalidate();
}}
/>
<SatzDialog
offen={satzOffen}
schliessen={() => setSatzOffen(false)}
fehler={setMeldung}
fertig={() => {
setSatzOffen(false);
void werkzeuge.beitrag.invalidate();
}}
/>
<BezahlenDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.beitrag.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
hervorheben?: boolean;
}) {
return (
<Card className={hervorheben ? "border-[var(--pruefung-faellig)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/** Der Beitragslauf mit Vorschau. */
function BeitragslaufDialog({
offen,
jahr,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
jahr: number;
schliessen: () => void;
fertig: (angelegt: number) => void;
fehler: (nachricht: string) => void;
}) {
const vorschau = trpc.beitrag.laufVorschau.useQuery({ jahr }, { enabled: offen });
const lauf = trpc.beitrag.lauf.useMutation({
onSuccess: (ergebnis) => fertig(ergebnis.angelegt),
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Beitragslauf {jahr}</DialogTitle>
<DialogDescription>
Legt an, was fehlt. Wiederholt ausgeführt entsteht nichts doppelt — der Lauf lässt
sich also im Februar noch einmal starten, wenn Neue dazugekommen sind.
</DialogDescription>
</DialogHeader>
{vorschau.isLoading && <p className="text-sm">Wird gerechnet …</p>}
{vorschau.data && (
<div className="space-y-4 text-sm">
<div className="rounded-md bg-[var(--muted)] p-3">
<p>
<strong>{vorschau.data.anzulegen.length} Beiträge</strong> werden angelegt, Summe{" "}
<strong>{alsBetrag(vorschau.data.summe)}</strong>.
</p>
{vorschau.data.bereitsVorhanden > 0 && (
<p className="text-[var(--muted-foreground)]">
{vorschau.data.bereitsVorhanden} Mitglieder haben für {jahr} bereits einen
Beitrag — die bleiben unberührt.
</p>
)}
</div>
{vorschau.data.ohneSatz.length > 0 && (
<div className="rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3">
<p className="font-medium">
{vorschau.data.ohneSatz.length} Mitglieder ohne passenden Satz
</p>
<p className="mt-1 text-[var(--muted-foreground)]">
Für sie entsteht kein Beitrag. Entweder fehlt ein Satz für ihre Abteilung, oder
es gibt keinen allgemeinen.
</p>
<ul className="mt-2 list-disc pl-5">
{vorschau.data.ohneSatz.slice(0, 8).map((eintrag) => (
<li key={eintrag.personId}>
{eintrag.name}
{eintrag.abteilung && ` (${eintrag.abteilung})`}
</li>
))}
</ul>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => lauf.mutate({ jahr })}
disabled={!vorschau.data?.anzulegen.length || lauf.isPending}
>
{lauf.isPending ? "Läuft …" : `${vorschau.data?.anzulegen.length ?? 0} anlegen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SatzDialog({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [betrag, setBetrag] = React.useState("");
const [abteilung, setAbteilung] = React.useState("");
const [abJahr, setAbJahr] = React.useState(String(new Date().getFullYear()));
const anlegen = trpc.beitrag.satzAnlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setBetrag("");
setAbteilung("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitragssatz anlegen</DialogTitle>
<DialogDescription>
Ein Satz gilt ab einem Jahr und wird nicht rückwirkend geändert — sonst stimmen die
Beiträge vergangener Jahre nicht mehr, und die sind bereits geprüft.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="satz-bezeichnung">Bezeichnung</Label>
<Input
id="satz-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Aktive Mitglieder"
/>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="satz-betrag">Betrag je Jahr</Label>
<Input
id="satz-betrag"
type="number"
step="0.01"
min="0"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-abteilung">Abteilung</Label>
<Input
id="satz-abteilung"
value={abteilung}
onChange={(ereignis) => setAbteilung(ereignis.target.value)}
placeholder="leer = alle"
/>
</div>
<div className="space-y-2">
<Label htmlFor="satz-jahr">Ab Jahr</Label>
<Input
id="satz-jahr"
type="number"
value={abJahr}
onChange={(ereignis) => setAbJahr(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
betrag: Number(betrag),
abteilung: abteilung.trim() || null,
abJahr: Number(abJahr),
aktiv: true,
})
}
disabled={!bezeichnung.trim() || !betrag || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang — erzeugt zugleich die Buchung. */
function BezahlenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; name: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.beitrag.bezahlen.useMutation({
onSuccess: fertig,
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
// Die Kategorie „Mitgliedsbeiträge" vorbelegen — sie ist fast immer die
// richtige und kommt aus dem Standardkontenrahmen.
if (!kategorieId && kategorien.data) {
const treffer = kategorien.data.find((eintrag) =>
eintrag.name.toLowerCase().includes("beitr"),
);
if (treffer) setKategorieId(treffer.id);
}
}, [kontoId, kategorieId, konten.data, kategorien.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Beitrag als bezahlt vermerken</DialogTitle>
<DialogDescription>
{eintrag?.name} · {alsBetrag(eintrag?.betrag)} — es entsteht zugleich die Buchung.
Ein Häkchen ohne Geld in der Kasse wäre eine Behauptung.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="bezahlt-datum">Bezahlt am</Label>
<Input
id="bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,929 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Lock, Plus, Search, Sparkles, Undo2, Upload } from "lucide-react";
import { BuchungsArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { BUCHUNGSART_TEXT, alsBetrag } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Das Kassenbuch.
*
* Oben die Summen über **alle** Treffer, nicht nur über die angezeigten —
* eine Summe unter einer auf hundert Zeilen begrenzten Liste bedeutet sonst
* nichts.
*
* Festgeschriebene Buchungen tragen ein Schloss. Sie lassen sich weder ändern
* noch löschen; wer sich vertan hat, bucht dagegen. Beide bleiben stehen, und
* im Kassenbuch ist zu sehen, dass etwas berichtigt wurde — genau das will ein
* Kassenprüfer sehen.
*/
export function BuchungsListe({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [suche, setSuche] = React.useState("");
const [kontoId, setKontoId] = React.useState<string | "alle">("alle");
const [art, setArt] = React.useState<BuchungsArt | "alle">("alle");
const [nurOffene, setNurOffene] = React.useState(false);
const [erfassenOffen, setErfassenOffen] = React.useState(false);
const [importOffen, setImportOffen] = React.useState(false);
const [gegenbuchen, setGegenbuchen] = React.useState<{ id: string; zweck: string } | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const buchungen = trpc.buchung.liste.useQuery({
suche: suche.trim() || undefined,
kontoId: kontoId === "alle" ? undefined : kontoId,
art: art === "alle" ? undefined : art,
nurOffene,
anzahl: 200,
});
const festschreiben = trpc.buchung.festschreiben.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
ergebnis.festgeschrieben === 0
? "Es war nichts festzuschreiben."
: `${ergebnis.festgeschrieben} Buchungen festgeschrieben. Sie sind ab jetzt unveränderbar.`,
);
void werkzeuge.buchung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const loeschen = trpc.buchung.loeschen.useMutation({
onSuccess: () => void werkzeuge.buchung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const eintraege = buchungen.data?.buchungen ?? [];
const offene = eintraege.filter((eintrag) => !eintrag.festgeschriebenAm).length;
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="buchungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
{/* Summen über alle Treffer */}
<div className="grid gap-3 sm:grid-cols-3">
<Summe titel="Einnahmen" wert={buchungen.data?.summe.einnahmen} />
<Summe titel="Ausgaben" wert={buchungen.data?.summe.ausgaben} />
<Summe titel="Saldo" wert={buchungen.data?.summe.saldo} hervorheben />
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative min-w-56 flex-1">
<Search
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[var(--muted-foreground)]"
aria-hidden
/>
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Verwendungszweck, Lieferant, Belegnummer …"
className="pl-9"
aria-label="Buchungen durchsuchen"
/>
</div>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger className="w-52" aria-label="Konto">
<SelectValue placeholder="Alle Konten" />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Konten</SelectItem>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={art} onValueChange={(wert) => setArt(wert as BuchungsArt | "alle")}>
<SelectTrigger className="w-40" aria-label="Art">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle</SelectItem>
{Object.values(BuchungsArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{BUCHUNGSART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant={nurOffene ? "default" : "outline"}
size="sm"
onClick={() => setNurOffene((wert) => !wert)}
>
Nur Entwürfe
</Button>
{darfBearbeiten && (
<div className="ml-auto flex flex-wrap gap-2">
<Button variant="outline" onClick={() => setImportOffen(true)}>
<Upload aria-hidden />
Kontoauszug
</Button>
<Button onClick={() => setErfassenOffen(true)}>
<Plus aria-hidden />
Buchung erfassen
</Button>
</div>
)}
</div>
{darfBearbeiten && offene > 0 && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-[var(--pruefung-faellig)]/40 bg-[var(--pruefung-faellig)]/10 p-3 text-sm">
<span className="flex-1">
{offene} {offene === 1 ? "Buchung ist" : "Buchungen sind"} noch nicht festgeschrieben.
Festgeschriebene lassen sich nicht mehr ändern — das ist der Zustand, den eine
Kassenprüfung erwartet.
</span>
<Button
size="sm"
variant="outline"
onClick={() => festschreiben.mutate({ bisAm: new Date() })}
disabled={festschreiben.isPending}
>
<Lock aria-hidden />
Alles bis heute festschreiben
</Button>
</div>
)}
{!buchungen.isLoading && eintraege.length === 0 && (
<NichtsGefunden
titel="Keine Buchungen"
hinweis={
suche || kontoId !== "alle" || art !== "alle" || nurOffene
? "Zu dieser Auswahl steht nichts im Kassenbuch."
: "Die erste Buchung entsteht über den Knopf oben rechts."
}
/>
)}
{eintraege.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{eintraege.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span>{buchung.verwendungszweck}</span>
{buchung.festgeschriebenAm && (
<Lock
className="size-3.5 text-[var(--muted-foreground)]"
aria-label="festgeschrieben"
/>
)}
{buchung.storniertVonId && (
<Badge variant="secondary">gegengebucht</Badge>
)}
</div>
{(buchung.lieferant || buchung.person) && (
<span className="text-xs text-[var(--muted-foreground)]">
{buchung.person
? `${buchung.person.nachname}, ${buchung.person.vorname}`
: buchung.lieferant}
</span>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === BuchungsArt.AUSGABE ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{darfBearbeiten && !buchung.storniertVonId && (
<>
{buchung.festgeschriebenAm ? (
<Button
size="sm"
variant="ghost"
onClick={() =>
setGegenbuchen({
id: buchung.id,
zweck: buchung.verwendungszweck,
})
}
>
<Undo2 aria-hidden />
Gegenbuchen
</Button>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => loeschen.mutate({ id: buchung.id })}
>
Verwerfen
</Button>
)}
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<BuchungErfassen
offen={erfassenOffen}
schliessen={() => setErfassenOffen(false)}
fehler={setMeldung}
fertig={() => {
setErfassenOffen(false);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
<Kontoauszug
offen={importOffen}
schliessen={() => setImportOffen(false)}
fehler={setMeldung}
fertig={(anzahl) => {
setImportOffen(false);
setHinweis(`${anzahl} Buchungen als Entwurf übernommen. Bitte durchsehen.`);
void werkzeuge.buchung.invalidate();
}}
/>
<GegenbuchenDialog
eintrag={gegenbuchen}
schliessen={() => setGegenbuchen(null)}
fehler={setMeldung}
fertig={() => {
setGegenbuchen(null);
void werkzeuge.buchung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Summe({
titel,
wert,
hervorheben = false,
}: {
titel: string;
wert: string | undefined;
hervorheben?: boolean;
}) {
const zahl = Number(wert ?? 0);
return (
<Card className={hervorheben ? "border-[var(--primary)]" : undefined}>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p
className={`text-xl font-semibold tabular-nums ${
hervorheben && zahl < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(wert)}
</p>
</CardContent>
</Card>
);
}
/** Datum von heute im Format, das ein date-Feld erwartet. */
function heute(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* Die Buchungsmaske.
*
* Der Kategorievorschlag ist die eine Bequemlichkeit, die hier wirklich etwas
* bringt: nicht wegen der gesparten Sekunden, sondern weil dieselbe Sache
* dadurch immer unter derselben Kategorie landet — und erst das macht die
* Jahresauswertung brauchbar.
*/
function BuchungErfassen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [art, setArt] = React.useState<BuchungsArt>(BuchungsArt.AUSGABE);
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(heute());
const [betrag, setBetrag] = React.useState("");
const [zweck, setZweck] = React.useState("");
const [lieferant, setLieferant] = React.useState("");
const [belegnummer, setBelegnummer] = React.useState("");
const [vorschlagUebernommen, setVorschlagUebernommen] = React.useState(false);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art });
const vorschlag = trpc.buchung.vorschlag.useQuery(
{ verwendungszweck: zweck, art },
{ enabled: offen && zweck.trim().length >= 3 },
);
const anlegen = trpc.buchung.anlegen.useMutation({
onSuccess: () => {
setBetrag("");
setZweck("");
setLieferant("");
setBelegnummer("");
setVorschlagUebernommen(false);
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
// Das erste Konto vorbelegen — die meisten Wehren buchen fast alles auf
// dasselbe.
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
// Beim Wechsel der Richtung passt die gewählte Kategorie nicht mehr.
React.useEffect(() => {
setKategorieId("");
setVorschlagUebernommen(false);
}, [art]);
const passenderVorschlag =
vorschlag.data && !kategorieId && !vorschlagUebernommen ? vorschlag.data : null;
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Buchung erfassen</DialogTitle>
<DialogDescription>
Der Betrag ist immer positiv — die Richtung steht daneben. Die Buchung ist zunächst
ein Entwurf und lässt sich noch ändern.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex gap-2">
{Object.values(BuchungsArt).map((wert) => (
<Button
key={wert}
type="button"
variant={art === wert ? "default" : "outline"}
className="flex-1"
onClick={() => setArt(wert)}
>
{BUCHUNGSART_TEXT[wert]}
</Button>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-datum">Buchungsdatum</Label>
<Input
id="buchung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-betrag">Betrag</Label>
<Input
id="buchung-betrag"
type="number"
step="0.01"
min="0.01"
value={betrag}
onChange={(ereignis) => setBetrag(ereignis.target.value)}
placeholder="0,00"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-zweck">Verwendungszweck</Label>
<Textarea
id="buchung-zweck"
rows={2}
value={zweck}
onChange={(ereignis) => setZweck(ereignis.target.value)}
placeholder="Tankstelle Musterhausen, Diesel LF 20"
/>
</div>
{passenderVorschlag && (
<button
type="button"
onClick={() => {
setKategorieId(passenderVorschlag.kategorieId);
setVorschlagUebernommen(true);
}}
className="flex w-full items-center gap-2 rounded-md border border-[var(--primary)] bg-[var(--primary)]/5 p-3 text-left text-sm hover:bg-[var(--primary)]/10"
>
<Sparkles className="size-4 shrink-0" aria-hidden />
<span>
Vorschlag: <strong>{passenderVorschlag.name}</strong>
<span className="block text-xs text-[var(--muted-foreground)]">
{passenderVorschlag.grund}
</span>
</span>
</button>
)}
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="buchung-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="buchung-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.oberkategorie
? `${kategorie.oberkategorie.name} › ${kategorie.name}`
: kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-konto">Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="buchung-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-lieferant">
{art === BuchungsArt.AUSGABE ? "Empfänger" : "Einzahler"}
</Label>
<Input
id="buchung-lieferant"
value={lieferant}
onChange={(ereignis) => setLieferant(ereignis.target.value)}
placeholder="Werkstatt Müller GmbH"
/>
</div>
<div className="space-y-2">
<Label htmlFor="buchung-beleg">Belegnummer</Label>
<Input
id="buchung-beleg"
value={belegnummer}
onChange={(ereignis) => setBelegnummer(ereignis.target.value)}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
kontoId,
kategorieId,
datum: new Date(datum),
art,
betrag: Number(betrag),
verwendungszweck: zweck,
lieferant: lieferant || null,
belegnummer: belegnummer || null,
})
}
disabled={
!kontoId || !kategorieId || !zweck.trim() || !(Number(betrag) > 0) || anlegen.isPending
}
>
{anlegen.isPending ? "Wird gebucht …" : "Buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/**
* Kontoauszug einlesen.
*
* Der eigentliche Wert ist die Spalte „schon da": ein zweimal eingelesener
* Auszug wäre sonst ein doppelt gebuchter Monat, und das fällt erst beim
* Kassenabschluss auf.
*/
function Kontoauszug({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (anzahl: number) => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [gelesen, setGelesen] = React.useState<{
format: string;
konto: string | null;
fehler: Array<{ stelle: string; grund: string }>;
posten: Array<{
datum: Date;
art: BuchungsArt;
betrag: string;
verwendungszweck: string;
gegenseite: string | null;
schonDa: boolean;
}>;
bekannt: number;
} | null>(null);
const [gewaehlt, setGewaehlt] = React.useState<Set<number>>(new Set());
const [kategorieId, setKategorieId] = React.useState("");
const dateiRef = React.useRef<HTMLInputElement>(null);
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery();
const lesen = trpc.buchung.auszugLesen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(ergebnis);
// Vorausgewählt ist, was noch nicht da ist — das ist fast immer das,
// was jemand übernehmen will.
setGewaehlt(
new Set(
ergebnis.posten
.map((posten, stelle) => (posten.schonDa ? -1 : stelle))
.filter((stelle) => stelle >= 0),
),
);
},
onError: (aufruf) => fehler(aufruf.message),
});
const uebernehmen = trpc.buchung.auszugUebernehmen.useMutation({
onSuccess: (ergebnis) => {
setGelesen(null);
setGewaehlt(new Set());
fertig(ergebnis.uebernommen);
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
async function dateiWaehlen(datei: File) {
const inhalt = await datei.text();
lesen.mutate({ kontoId, inhalt });
}
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>Kontoauszug einlesen</DialogTitle>
<DialogDescription>
CAMT.053 (XML) oder MT940 — beides bietet das Online-Banking unter „Umsatzexport“ an.
Es entstehen <strong>Entwürfe</strong>, keine fertigen Buchungen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex flex-wrap items-end gap-3">
<div className="min-w-52 flex-1 space-y-2">
<Label htmlFor="auszug-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="auszug-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<input
ref={dateiRef}
type="file"
accept=".xml,.camt,.txt,.sta,.mt940,text/xml,text/plain"
className="hidden"
onChange={(ereignis) => {
const datei = ereignis.target.files?.[0];
if (datei) void dateiWaehlen(datei);
ereignis.target.value = "";
}}
/>
<Button
onClick={() => dateiRef.current?.click()}
disabled={!kontoId || lesen.isPending}
>
<Upload aria-hidden />
{lesen.isPending ? "Wird gelesen …" : "Datei wählen"}
</Button>
</div>
{gelesen && (
<>
<p className="text-sm">
Format <strong>{gelesen.format}</strong>
{gelesen.konto && ` · Konto ${gelesen.konto}`} · {gelesen.posten.length} Umsätze
{gelesen.bekannt > 0 && ` · ${gelesen.bekannt} davon stehen schon in der Kasse`}
</p>
{gelesen.fehler.length > 0 && (
<div className="rounded-md border border-[var(--destructive)]/40 bg-[var(--destructive)]/10 p-3 text-sm">
<p className="font-medium">Nicht gelesen werden konnte:</p>
<ul className="mt-1 list-disc pl-5">
{gelesen.fehler.slice(0, 8).map((eintrag, stelle) => (
<li key={stelle}>
{eintrag.stelle}: {eintrag.grund}
</li>
))}
</ul>
</div>
)}
{gelesen.posten.length > 0 && (
<>
<div className="space-y-2">
<Label htmlFor="auszug-kategorie">Kategorie für alle übernommenen</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="auszug-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{BUCHUNGSART_TEXT[kategorie.art]} · {kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-[var(--muted-foreground)]">
Sie lässt sich danach je Buchung ändern — die Entwürfe sind dafür da.
</p>
</div>
<div className="max-h-72 overflow-y-auto rounded-md border border-[var(--border)]">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--card)]">
<tr className="border-b border-[var(--border)] text-left">
<th className="px-2 py-1.5" />
<th className="px-2 py-1.5">Datum</th>
<th className="px-2 py-1.5">Verwendungszweck</th>
<th className="px-2 py-1.5 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{gelesen.posten.map((posten, stelle) => (
<tr
key={stelle}
className={`border-b border-[var(--border)] last:border-0 ${
posten.schonDa ? "opacity-50" : ""
}`}
>
<td className="px-2 py-1.5">
<input
type="checkbox"
checked={gewaehlt.has(stelle)}
onChange={(ereignis) => {
const neu = new Set(gewaehlt);
if (ereignis.target.checked) neu.add(stelle);
else neu.delete(stelle);
setGewaehlt(neu);
}}
aria-label={`Umsatz ${stelle + 1} übernehmen`}
/>
</td>
<td className="px-2 py-1.5 whitespace-nowrap">
{alsDatum(posten.datum)}
</td>
<td className="px-2 py-1.5">
{posten.verwendungszweck.slice(0, 80)}
{posten.gegenseite && (
<span className="block text-xs text-[var(--muted-foreground)]">
{posten.gegenseite}
</span>
)}
{posten.schonDa && (
<Badge variant="secondary" className="mt-1">
steht schon in der Kasse
</Badge>
)}
</td>
<td
className={`px-2 py-1.5 text-right tabular-nums whitespace-nowrap ${
posten.art === BuchungsArt.AUSGABE
? "text-[var(--destructive)]"
: ""
}`}
>
{posten.art === BuchungsArt.AUSGABE ? "−" : "+"}
{alsBetrag(posten.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)}
</>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
uebernehmen.mutate({
kontoId,
posten: [...gewaehlt].map((stelle) => {
const posten = gelesen!.posten[stelle]!;
return {
datum: posten.datum,
art: posten.art,
betrag: Number(posten.betrag),
verwendungszweck: posten.verwendungszweck.slice(0, 300),
gegenseite: posten.gegenseite,
kategorieId,
};
}),
})
}
disabled={gewaehlt.size === 0 || !kategorieId || uebernehmen.isPending}
>
{uebernehmen.isPending
? "Wird übernommen …"
: `${gewaehlt.size} übernehmen`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Gegenbuchen — mit Grund, denn er steht später im Verwendungszweck. */
function GegenbuchenDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; zweck: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [grund, setGrund] = React.useState("");
const gegenbuchen = trpc.buchung.gegenbuchen.useMutation({
onSuccess: () => {
setGrund("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Gegenbuchen</DialogTitle>
<DialogDescription>
Es entsteht eine zweite Buchung mit derselben Summe in die andere Richtung, mit
Verweis auf die erste. Beide bleiben stehen — genau das will ein Kassenprüfer sehen.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-[var(--muted)] p-3 text-sm">{eintrag?.zweck}</div>
<div className="space-y-2">
<Label htmlFor="gegenbuchung-grund">Warum?</Label>
<Input
id="gegenbuchung-grund"
value={grund}
onChange={(ereignis) => setGrund(ereignis.target.value)}
placeholder="Betrag falsch erfasst"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Der Grund steht im Verwendungszweck der Gegenbuchung. Das Buchungsdatum ist heute —
ein geprüfter Zeitraum bleibt zu.
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() => eintrag && gegenbuchen.mutate({ id: eintrag.id, grund })}
disabled={grund.trim().length < 3 || gegenbuchen.isPending}
>
{gegenbuchen.isPending ? "Wird gebucht …" : "Gegenbuchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { BuchungsListe } from "@/app/(app)/kasse/buchungen/buchungs-liste";
export const metadata = { title: "Buchungen" };
export default async function BuchungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Buchungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Das Kassenbuch. Festgeschriebene Buchungen sind unveränderbar — Korrekturen laufen über
eine Gegenbuchung.
</p>
</div>
<BuchungsListe darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { KassenUebersicht } from "@/app/(app)/kasse/uebersicht";
export const metadata = { title: "Kasse" };
/**
* Die Kasse der Wehr.
*
* Oben die Konten mit ihrem Bestand — die Zahl, die der Kassier zuerst sehen
* will. Darunter, was zu tun ist: Entwürfe, offene Beiträge, überfällige
* Rechnungen.
*/
export default async function KasseSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Kasse</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Konten, Buchungen, Beiträge und der Kassenabschluss.
</p>
</div>
<KassenUebersicht
darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")}
darfAbschluss={hatRecht(sitzung, "kasse.abschluss")}
/>
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { redirect } from "next/navigation";
import { aktuelleSitzung } from "@/lib/auth";
import { hatRecht } from "@/lib/authz";
import { Rechnungsverwaltung } from "@/app/(app)/kasse/rechnungen/verwaltung";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite() {
const sitzung = await aktuelleSitzung();
if (!sitzung) redirect("/anmelden");
if (!hatRecht(sitzung, "kasse.lesen")) redirect("/");
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Rechnungen</h1>
<p className="text-sm text-[var(--muted-foreground)]">
Kostenersatz für Fehlalarme und Öleinsätze, Vermietungen, Veranstaltungen.
</p>
</div>
<Rechnungsverwaltung darfBearbeiten={hatRecht(sitzung, "kasse.bearbeiten")} />
</div>
);
}
@@ -0,0 +1,824 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { ArrowLeft, Check, Download, FileText, Mail, Plus, Send, Trash2, X } from "lucide-react";
import { RechnungsAnlass, RechnungStand } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import {
ANLASS_TEXT,
RECHNUNG_STAND_TEXT,
RECHNUNG_STAND_VARIANTE,
alsBetrag,
} from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
type Position = {
bezeichnung: string;
menge: string;
einheit: string;
einzelpreis: string;
};
const LEERE_POSITION: Position = { bezeichnung: "", menge: "1", einheit: "", einzelpreis: "" };
/**
* Rechnungen.
*
* Eine Rechnung ist bis zum Versenden ein Entwurf und danach nicht mehr
* änderbar. Der Grund ist derselbe wie beim Kassenbuch: was beim Empfänger
* liegt, muss dem entsprechen, was hier steht. Korrigiert wird über Storno
* und eine neue Rechnung — mit eigener Nummer, damit keine Lücke entsteht.
*/
export function Rechnungsverwaltung({ darfBearbeiten }: { darfBearbeiten: boolean }) {
const werkzeuge = trpc.useUtils();
const [stand, setStand] = React.useState<RechnungStand | "alle">("alle");
const [jahr, setJahr] = React.useState<number | "alle">(new Date().getFullYear());
const [suche, setSuche] = React.useState("");
const [anlegenOffen, setAnlegenOffen] = React.useState(false);
const [bezahlen, setBezahlen] = React.useState<{
id: string;
nummer: string;
betrag: string;
} | null>(null);
const [meldung, setMeldung] = React.useState<string | null>(null);
const [hinweis, setHinweis] = React.useState<string | null>(null);
const rechnungen = trpc.rechnung.liste.useQuery({
stand: stand === "alle" ? undefined : stand,
jahr: jahr === "alle" ? undefined : jahr,
suche: suche.trim() || undefined,
});
const versenden = trpc.rechnung.versenden.useMutation({
onSuccess: () => {
setHinweis("Versendet. Das PDF entsteht im Hintergrund und steht gleich zum Herunterladen.");
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const mahnen = trpc.rechnung.mahnen.useMutation({
onSuccess: (ergebnis) => {
setHinweis(
`${ergebnis.gemahnt} Rechnungen gemahnt.` +
(ergebnis.uebersprungen > 0
? ` ${ergebnis.uebersprungen} übersprungen — dort steht die dritte Mahnung, danach ist es eine Frage für die Kommandantschaft.`
: ""),
);
void werkzeuge.rechnung.invalidate();
},
onError: (fehler) => setMeldung(fehler.message),
});
const stornieren = trpc.rechnung.stornieren.useMutation({
onSuccess: () => void werkzeuge.rechnung.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const pdfErzeugen = trpc.rechnung.pdfErzeugen.useMutation({
onSuccess: (ergebnis) => setHinweis(ergebnis.hinweis),
onError: (fehler) => setMeldung(fehler.message),
});
const jahre = Array.from({ length: 6 }, (_, stelle) => new Date().getFullYear() - stelle);
const zeilen = rechnungen.data?.rechnungen ?? [];
return (
<div className="space-y-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse">
<ArrowLeft aria-hidden />
Zurück zur Kasse
</Link>
</Button>
{meldung && <Fehlermeldung meldung={meldung} kennung="rechnungsfehler" />}
{hinweis && (
<div className="rounded-md border border-[var(--border)] bg-[var(--muted)] p-3 text-sm">
{hinweis}
</div>
)}
<div className="grid gap-3 sm:grid-cols-3">
<Kennzahl titel="Gestellt" wert={rechnungen.data?.kennzahlen.gesamt} />
<Kennzahl
titel="Offen"
wert={rechnungen.data?.kennzahlen.offen}
hinweis={`${rechnungen.data?.kennzahlen.anzahlOffen ?? 0} Rechnungen`}
/>
<Card
className={
(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0
? "border-[var(--gesperrt)]"
: undefined
}
>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
Überfällig
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">
{rechnungen.data?.kennzahlen.ueberfaellig ?? 0}
</p>
<p className="text-xs text-[var(--muted-foreground)]">Zahlungsziel verstrichen</p>
</CardContent>
</Card>
</div>
<div className="flex flex-wrap items-center gap-3">
<Input
value={suche}
onChange={(ereignis) => setSuche(ereignis.target.value)}
placeholder="Empfänger oder Rechnungsnummer"
className="min-w-52 flex-1"
aria-label="Rechnungen durchsuchen"
/>
<Select
value={String(jahr)}
onValueChange={(wert) => setJahr(wert === "alle" ? "alle" : Number(wert))}
>
<SelectTrigger className="w-32" aria-label="Jahr">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Jahre</SelectItem>
{jahre.map((wert) => (
<SelectItem key={wert} value={String(wert)}>
{wert}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={stand} onValueChange={(wert) => setStand(wert as RechnungStand | "alle")}>
<SelectTrigger className="w-44" aria-label="Stand">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="alle">Alle Stände</SelectItem>
{Object.values(RechnungStand).map((wert) => (
<SelectItem key={wert} value={wert}>
{RECHNUNG_STAND_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
{darfBearbeiten && (
<Button className="ml-auto" onClick={() => setAnlegenOffen(true)}>
<Plus aria-hidden />
Rechnung schreiben
</Button>
)}
</div>
{!rechnungen.isLoading && zeilen.length === 0 && (
<NichtsGefunden
titel="Keine Rechnungen"
hinweis="Kostenersatz nach Art. 28 BayFwG wird hier geschrieben — Fehlalarme, Öleinsätze, Vermietungen."
/>
)}
{zeilen.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Nummer</th>
<th className="px-3 py-2">Empfänger</th>
<th className="px-3 py-2">Anlass</th>
<th className="px-3 py-2">Fällig</th>
<th className="px-3 py-2 text-right">Betrag</th>
<th className="px-3 py-2">Stand</th>
<th className="px-3 py-2" />
</tr>
</thead>
<tbody>
{zeilen.map((rechnung) => {
const offen =
rechnung.stand === RechnungStand.VERSENDET ||
rechnung.stand === RechnungStand.GEMAHNT;
const ueberfaellig = offen && new Date(rechnung.faelligAm) < new Date();
return (
<tr key={rechnung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap font-medium">{rechnung.nummer}</td>
<td className="px-3 py-2">
{rechnung.empfaenger}
{rechnung.ort && (
<span className="block text-xs text-[var(--muted-foreground)]">
{rechnung.plz} {rechnung.ort}
</span>
)}
</td>
<td className="px-3 py-2">{ANLASS_TEXT[rechnung.anlass]}</td>
<td
className={`px-3 py-2 whitespace-nowrap ${
ueberfaellig ? "text-[var(--destructive)]" : ""
}`}
>
{alsDatum(rechnung.faelligAm)}
</td>
<td className="px-3 py-2 text-right tabular-nums whitespace-nowrap">
{alsBetrag(rechnung.betrag)}
</td>
<td className="px-3 py-2">
<Badge variant={RECHNUNG_STAND_VARIANTE[rechnung.stand]}>
{RECHNUNG_STAND_TEXT[rechnung.stand]}
</Badge>
{rechnung.mahnstufe > 0 && (
<span className="ml-1 text-xs text-[var(--muted-foreground)]">
{rechnung.mahnstufe}. Mahnung
</span>
)}
</td>
<td className="px-3 py-2 text-right whitespace-nowrap">
{rechnung.pdfDateiId && (
<Button size="sm" variant="ghost" asChild>
<a href={`/api/dateien/${rechnung.pdfDateiId}`} target="_blank">
<Download aria-hidden />
<span className="sr-only">PDF herunterladen</span>
</a>
</Button>
)}
{darfBearbeiten && rechnung.stand === RechnungStand.ENTWURF && (
<Button
size="sm"
variant="ghost"
onClick={() => versenden.mutate({ id: rechnung.id })}
disabled={versenden.isPending}
>
<Send aria-hidden />
Versenden
</Button>
)}
{darfBearbeiten && offen && (
<>
<Button
size="sm"
variant="ghost"
onClick={() =>
setBezahlen({
id: rechnung.id,
nummer: rechnung.nummer,
betrag: String(rechnung.betrag),
})
}
>
<Check aria-hidden />
Bezahlt
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => mahnen.mutate({ ids: [rechnung.id] })}
disabled={mahnen.isPending}
>
<Mail aria-hidden />
Mahnen
</Button>
</>
)}
{darfBearbeiten && rechnung.stand !== RechnungStand.STORNIERT && (
<>
{rechnung.stand !== RechnungStand.ENTWURF && !rechnung.pdfDateiId && (
<Button
size="sm"
variant="ghost"
onClick={() => pdfErzeugen.mutate({ id: rechnung.id })}
>
<FileText aria-hidden />
<span className="sr-only">PDF erzeugen</span>
</Button>
)}
{rechnung.stand !== RechnungStand.BEZAHLT && (
<Button
size="sm"
variant="ghost"
onClick={() => {
const grund = window.prompt(
"Warum wird storniert? Der Grund steht später in der Bemerkung — die Rechnungsnummer bleibt vergeben.",
);
if (grund?.trim()) {
stornieren.mutate({ id: rechnung.id, grund: grund.trim() });
}
}}
>
<X aria-hidden />
<span className="sr-only">Stornieren</span>
</Button>
)}
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{darfBearbeiten && (
<>
<RechnungAnlegen
offen={anlegenOffen}
schliessen={() => setAnlegenOffen(false)}
fehler={setMeldung}
fertig={(nummer) => {
setAnlegenOffen(false);
setHinweis(`Rechnung ${nummer} als Entwurf angelegt. Sie ist noch änderbar.`);
void werkzeuge.rechnung.invalidate();
}}
/>
<BezahltDialog
eintrag={bezahlen}
schliessen={() => setBezahlen(null)}
fehler={setMeldung}
fertig={() => {
setBezahlen(null);
void werkzeuge.rechnung.invalidate();
void werkzeuge.kassenkonto.invalidate();
}}
/>
</>
)}
</div>
);
}
function Kennzahl({
titel,
wert,
hinweis,
}: {
titel: string;
wert: string | undefined;
hinweis?: string;
}) {
return (
<Card>
<CardHeader className="pb-1">
<CardTitle className="text-sm font-medium text-[var(--muted-foreground)]">
{titel}
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold tabular-nums">{alsBetrag(wert)}</p>
{hinweis && <p className="text-xs text-[var(--muted-foreground)]">{hinweis}</p>}
</CardContent>
</Card>
);
}
/**
* Die Rechnungsmaske.
*
* Der Gesamtbetrag wird nicht eingegeben, sondern aus den Positionen
* gerechnet — hier zur Anzeige, auf dem Server noch einmal verbindlich. Ein
* eingetippter Betrag, der nicht zur Summe der Positionen passt, ist der
* Fehler, den niemand bemerkt, bis der Empfänger anruft.
*/
function RechnungAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: (nummer: string) => void;
fehler: (nachricht: string) => void;
}) {
const heute = new Date();
const inVierWochen = new Date(heute.getTime() + 28 * 24 * 60 * 60 * 1000);
const [anlass, setAnlass] = React.useState<RechnungsAnlass>(RechnungsAnlass.FEHLALARM);
const [empfaenger, setEmpfaenger] = React.useState("");
const [zusatz, setZusatz] = React.useState("");
const [strasse, setStrasse] = React.useState("");
const [plz, setPlz] = React.useState("");
const [ort, setOrt] = React.useState("");
const [datum, setDatum] = React.useState(heute.toISOString().slice(0, 10));
const [faellig, setFaellig] = React.useState(inVierWochen.toISOString().slice(0, 10));
const [bemerkung, setBemerkung] = React.useState("");
const [positionen, setPositionen] = React.useState<Position[]>([{ ...LEERE_POSITION }]);
const anlegen = trpc.rechnung.anlegen.useMutation({
onSuccess: (rechnung) => {
setEmpfaenger("");
setZusatz("");
setStrasse("");
setPlz("");
setOrt("");
setBemerkung("");
setPositionen([{ ...LEERE_POSITION }]);
fertig(rechnung.nummer);
},
onError: (aufruf) => fehler(aufruf.message),
});
function aendern(stelle: number, feld: keyof Position, wert: string) {
setPositionen((vorher) =>
vorher.map((position, index) => (index === stelle ? { ...position, [feld]: wert } : position)),
);
}
const summe = positionen.reduce(
(gesamt, position) => gesamt + Number(position.menge || 0) * Number(position.einzelpreis || 0),
0,
);
const vollstaendig =
empfaenger.trim().length > 0 &&
positionen.every(
(position) => position.bezeichnung.trim().length > 0 && Number(position.einzelpreis) >= 0,
);
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Rechnung schreiben</DialogTitle>
<DialogDescription>
Die Rechnungsnummer vergibt FLORIAN fortlaufend je Jahr — lückenlos, weil eine Lücke
in der Kassenprüfung erklärt werden muss.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-anlass">Anlass</Label>
<Select value={anlass} onValueChange={(wert) => setAnlass(wert as RechnungsAnlass)}>
<SelectTrigger id="rechnung-anlass">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(RechnungsAnlass).map((wert) => (
<SelectItem key={wert} value={wert}>
{ANLASS_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-empfaenger">Empfänger</Label>
<Input
id="rechnung-empfaenger"
value={empfaenger}
onChange={(ereignis) => setEmpfaenger(ereignis.target.value)}
placeholder="Firma Muster GmbH"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-zusatz">Zusatz</Label>
<Input
id="rechnung-zusatz"
value={zusatz}
onChange={(ereignis) => setZusatz(ereignis.target.value)}
placeholder="z. Hd. Herrn Meier"
/>
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="rechnung-strasse">Straße</Label>
<Input
id="rechnung-strasse"
value={strasse}
onChange={(ereignis) => setStrasse(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-plz">PLZ</Label>
<Input
id="rechnung-plz"
value={plz}
onChange={(ereignis) => setPlz(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-ort">Ort</Label>
<Input
id="rechnung-ort"
value={ort}
onChange={(ereignis) => setOrt(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-datum">Rechnungsdatum</Label>
<Input
id="rechnung-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-faellig">Zahlbar bis</Label>
<Input
id="rechnung-faellig"
type="date"
value={faellig}
onChange={(ereignis) => setFaellig(ereignis.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Positionen</Label>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => setPositionen((vorher) => [...vorher, { ...LEERE_POSITION }])}
>
<Plus aria-hidden />
Position
</Button>
</div>
<div className="space-y-2">
{positionen.map((position, stelle) => (
<div key={stelle} className="flex flex-wrap items-end gap-2">
<div className="min-w-40 flex-1 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-bezeichnung`}>
Bezeichnung
</Label>
<Input
id={`position-${stelle}-bezeichnung`}
value={position.bezeichnung}
onChange={(ereignis) => aendern(stelle, "bezeichnung", ereignis.target.value)}
placeholder="Einsatz LF 20, angefangene Stunde"
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-menge`}>
Menge
</Label>
<Input
id={`position-${stelle}-menge`}
type="number"
step="0.01"
min="0.01"
value={position.menge}
onChange={(ereignis) => aendern(stelle, "menge", ereignis.target.value)}
/>
</div>
<div className="w-20 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-einheit`}>
Einheit
</Label>
<Input
id={`position-${stelle}-einheit`}
value={position.einheit}
onChange={(ereignis) => aendern(stelle, "einheit", ereignis.target.value)}
placeholder="Std."
/>
</div>
<div className="w-28 space-y-1">
<Label className="text-xs" htmlFor={`position-${stelle}-preis`}>
Einzelpreis
</Label>
<Input
id={`position-${stelle}-preis`}
type="number"
step="0.01"
min="0"
value={position.einzelpreis}
onChange={(ereignis) => aendern(stelle, "einzelpreis", ereignis.target.value)}
/>
</div>
<Button
type="button"
size="icon"
variant="ghost"
disabled={positionen.length === 1}
onClick={() =>
setPositionen((vorher) => vorher.filter((_, index) => index !== stelle))
}
>
<Trash2 aria-hidden />
<span className="sr-only">Position entfernen</span>
</Button>
</div>
))}
</div>
<p className="text-right text-sm">
Summe <strong className="tabular-nums">{alsBetrag(summe.toFixed(2))}</strong>
</p>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bemerkung">Bemerkung</Label>
<Textarea
id="rechnung-bemerkung"
rows={2}
value={bemerkung}
onChange={(ereignis) => setBemerkung(ereignis.target.value)}
placeholder="Einsatz vom 14.03. — Fehlalarm durch Brandmeldeanlage"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
anlass,
empfaenger,
empfaengerZusatz: zusatz || null,
strasse: strasse || null,
plz: plz || null,
ort: ort || null,
datum: new Date(datum),
faelligAm: new Date(faellig),
bemerkung: bemerkung || null,
positionen: positionen.map((position) => ({
bezeichnung: position.bezeichnung,
menge: Number(position.menge || 1),
einheit: position.einheit || null,
einzelpreis: Number(position.einzelpreis || 0),
})),
})
}
disabled={!vollstaendig || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Als Entwurf anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
/** Zahlungseingang zu einer Rechnung — erzeugt die Buchung. */
function BezahltDialog({
eintrag,
schliessen,
fertig,
fehler,
}: {
eintrag: { id: string; nummer: string; betrag: string } | null;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [kontoId, setKontoId] = React.useState("");
const [kategorieId, setKategorieId] = React.useState("");
const [datum, setDatum] = React.useState(new Date().toISOString().slice(0, 10));
const [teilbetrag, setTeilbetrag] = React.useState("");
const konten = trpc.kassenkonto.liste.useQuery();
const kategorien = trpc.buchungskategorie.liste.useQuery({ art: "EINNAHME" });
const bezahlen = trpc.rechnung.bezahlen.useMutation({
onSuccess: () => {
setTeilbetrag("");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
React.useEffect(() => {
if (!kontoId && konten.data?.[0]) setKontoId(konten.data[0].id);
}, [kontoId, konten.data]);
return (
<Dialog open={eintrag !== null} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Rechnung {eintrag?.nummer} bezahlt</DialogTitle>
<DialogDescription>
Über {alsBetrag(eintrag?.betrag)}. Es entsteht zugleich die Buchung im Kassenbuch.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-datum">Eingegangen am</Label>
<Input
id="rechnung-bezahlt-datum"
type="date"
value={datum}
onChange={(ereignis) => setDatum(ereignis.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-betrag">Abweichender Betrag</Label>
<Input
id="rechnung-bezahlt-betrag"
type="number"
step="0.01"
min="0.01"
value={teilbetrag}
onChange={(ereignis) => setTeilbetrag(ereignis.target.value)}
placeholder={eintrag?.betrag}
/>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-konto">Auf welches Konto</Label>
<Select value={kontoId} onValueChange={setKontoId}>
<SelectTrigger id="rechnung-bezahlt-konto">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{konten.data?.map((konto) => (
<SelectItem key={konto.id} value={konto.id}>
{konto.bezeichnung}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rechnung-bezahlt-kategorie">Kategorie</Label>
<Select value={kategorieId} onValueChange={setKategorieId}>
<SelectTrigger id="rechnung-bezahlt-kategorie">
<SelectValue placeholder="wählen …" />
</SelectTrigger>
<SelectContent>
{kategorien.data?.map((kategorie) => (
<SelectItem key={kategorie.id} value={kategorie.id}>
{kategorie.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
eintrag &&
bezahlen.mutate({
id: eintrag.id,
kontoId,
kategorieId,
bezahltAm: new Date(datum),
betrag: teilbetrag ? Number(teilbetrag) : undefined,
})
}
disabled={!kontoId || !kategorieId || bezahlen.isPending}
>
{bezahlen.isPending ? "Wird gebucht …" : "Bezahlt und buchen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+457
View File
@@ -0,0 +1,457 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BanknoteArrowUp,
ClipboardCheck,
FileText,
Landmark,
Plus,
Users,
Wallet,
} from "lucide-react";
import { KontoArt } from "@prisma/client";
import { trpc } from "@/lib/trpc/client";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Fehlermeldung, NichtsGefunden } from "@/components/stammdaten/anzeige";
import { KONTOART_TEXT, alsBetrag, ibanKurz } from "@/lib/kasse/texte";
import { alsDatum } from "@/lib/utils";
/**
* Die Kassenübersicht.
*
* Zuerst die Konten mit ihrem Bestand — die Zahl, wegen der jemand hierher
* kommt. Der Bestand ist **gerechnet**, nicht gespeichert: Anfangsbestand plus
* die Wirkung aller Buchungen. Ein gespeicherter Bestand neben einer
* nachgetragenen Buchung wäre falsch, ohne falsch auszusehen.
*
* Darunter die Arbeit, die ansteht. Nicht als Zahlenwand, sondern als vier
* Wege in die vier Bereiche — wer die Kasse führt, hat drei bis fünf Dinge zu
* tun, und die sollen mit einem Klick erreichbar sein.
*/
export function KassenUebersicht({
darfBearbeiten,
darfAbschluss,
}: {
darfBearbeiten: boolean;
darfAbschluss: boolean;
}) {
const werkzeuge = trpc.useUtils();
const jahr = new Date().getFullYear();
const [kontoOffen, setKontoOffen] = React.useState(false);
const [meldung, setMeldung] = React.useState<string | null>(null);
const konten = trpc.kassenkonto.liste.useQuery({ mitInaktiven: false });
const kategorien = trpc.buchungskategorie.liste.useQuery();
const offeneBuchungen = trpc.buchung.liste.useQuery({ nurOffene: true, anzahl: 1 });
const beitraege = trpc.beitrag.liste.useQuery({ jahr });
const rechnungen = trpc.rechnung.liste.useQuery({ jahr });
const letzte = trpc.buchung.liste.useQuery({ anzahl: 8 });
const standard = trpc.buchungskategorie.standardAnlegen.useMutation({
onSuccess: () => void werkzeuge.buchungskategorie.invalidate(),
onError: (fehler) => setMeldung(fehler.message),
});
const gesamt = (konten.data ?? []).reduce(
(bisher, konto) => bisher + Number(konto.bestand),
0,
);
const keineKategorien = (kategorien.data?.length ?? 0) === 0;
return (
<div className="space-y-6">
{meldung && <Fehlermeldung meldung={meldung} kennung="kassenfehler" />}
{/* Der Einstieg für eine Wehr, die gerade anfängt. */}
{keineKategorien && darfBearbeiten && !kategorien.isLoading && (
<Card className="border-[var(--pruefung-faellig)]">
<CardHeader>
<CardTitle className="text-base">Die Kasse ist noch leer</CardTitle>
<CardDescription>
Es fehlen die Kategorien, unter denen gebucht wird. FLORIAN bringt einen
Kontenrahmen mit, wie ihn eine Feuerwehr braucht — er lässt sich danach
erweitern und umbenennen.
</CardDescription>
</CardHeader>
<CardContent>
<Button onClick={() => standard.mutate()} disabled={standard.isPending}>
{standard.isPending ? "Wird angelegt …" : "Kontenrahmen anlegen"}
</Button>
</CardContent>
</Card>
)}
{/* Konten */}
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Konten</h2>
{darfBearbeiten && (
<Button variant="outline" size="sm" onClick={() => setKontoOffen(true)}>
<Plus aria-hidden />
Konto anlegen
</Button>
)}
</div>
{konten.isLoading && (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 3 }).map((_, zeile) => (
<div
key={zeile}
className="h-28 animate-pulse rounded-lg border border-[var(--border)] bg-[var(--muted)]"
aria-hidden
/>
))}
</div>
)}
{!konten.isLoading && (konten.data?.length ?? 0) === 0 && (
<NichtsGefunden
titel="Noch keine Konten"
hinweis="Eine Wehr führt meistens drei: die Barkasse im Gerätehaus, ein Bankkonto und eine Rücklage."
/>
)}
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{konten.data?.map((konto) => (
<Card key={konto.id}>
<CardHeader className="pb-2">
<div className="flex items-center gap-2">
{konto.art === KontoArt.BAR ? (
<Wallet className="size-4 text-[var(--muted-foreground)]" aria-hidden />
) : (
<Landmark className="size-4 text-[var(--muted-foreground)]" aria-hidden />
)}
<CardTitle className="text-sm font-medium">{konto.bezeichnung}</CardTitle>
</div>
<CardDescription>
{KONTOART_TEXT[konto.art]}
{konto.iban && ` · ${ibanKurz(konto.iban)}`}
</CardDescription>
</CardHeader>
<CardContent>
<p
className={`text-2xl font-semibold tabular-nums ${
Number(konto.bestand) < 0 ? "text-[var(--destructive)]" : ""
}`}
>
{alsBetrag(konto.bestand)}
</p>
</CardContent>
</Card>
))}
{(konten.data?.length ?? 0) > 1 && (
<Card className="border-[var(--primary)]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Gesamt</CardTitle>
<CardDescription>über alle Konten</CardDescription>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">
{alsBetrag(gesamt.toFixed(2))}
</p>
</CardContent>
</Card>
)}
</div>
</section>
{/* Was ansteht */}
<section className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Aufgabe
titel="Buchungen"
symbol={<BanknoteArrowUp className="size-4" aria-hidden />}
wert={offeneBuchungen.data?.buchungen.length ? "Entwürfe offen" : "alles erfasst"}
hinweis={`Saldo ${alsBetrag(letzte.data?.summe.saldo)}`}
pfad="/kasse/buchungen"
hervorheben={Boolean(offeneBuchungen.data?.buchungen.length)}
/>
<Aufgabe
titel={`Beiträge ${jahr}`}
symbol={<Users className="size-4" aria-hidden />}
wert={
beitraege.data
? `${beitraege.data.kennzahlen.anzahlOffen} offen`
: "—"
}
hinweis={beitraege.data ? `${alsBetrag(beitraege.data.kennzahlen.offen)} ausstehend` : ""}
pfad="/kasse/beitraege"
hervorheben={(beitraege.data?.kennzahlen.anzahlOffen ?? 0) > 0}
/>
<Aufgabe
titel="Rechnungen"
symbol={<FileText className="size-4" aria-hidden />}
wert={rechnungen.data ? `${rechnungen.data.kennzahlen.anzahlOffen} offen` : "—"}
hinweis={
rechnungen.data?.kennzahlen.ueberfaellig
? `${rechnungen.data.kennzahlen.ueberfaellig} überfällig`
: rechnungen.data
? `${alsBetrag(rechnungen.data.kennzahlen.offen)} ausstehend`
: ""
}
pfad="/kasse/rechnungen"
hervorheben={(rechnungen.data?.kennzahlen.ueberfaellig ?? 0) > 0}
/>
<Aufgabe
titel="Abschluss"
symbol={<ClipboardCheck className="size-4" aria-hidden />}
wert={darfAbschluss ? "Kassenprüfung" : "nur ansehen"}
hinweis="Bestände zählen und festschreiben"
pfad="/kasse/abschluss"
/>
</section>
{/* Die letzten Buchungen */}
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Zuletzt gebucht</h2>
<Button variant="ghost" size="sm" asChild>
<Link href="/kasse/buchungen">Alle Buchungen</Link>
</Button>
</div>
{(letzte.data?.buchungen.length ?? 0) === 0 && !letzte.isLoading ? (
<NichtsGefunden
titel="Noch nichts gebucht"
hinweis="Die erste Buchung entsteht unter „Buchungen“."
/>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--border)]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-left">
<th className="px-3 py-2">Datum</th>
<th className="px-3 py-2">Verwendungszweck</th>
<th className="px-3 py-2">Kategorie</th>
<th className="px-3 py-2">Konto</th>
<th className="px-3 py-2 text-right">Betrag</th>
</tr>
</thead>
<tbody>
{letzte.data?.buchungen.map((buchung) => (
<tr key={buchung.id} className="border-b border-[var(--border)] last:border-0">
<td className="px-3 py-2 whitespace-nowrap">{alsDatum(buchung.datum)}</td>
<td className="px-3 py-2">
{buchung.verwendungszweck}
{buchung.storniertVonId && (
<Badge variant="secondary" className="ml-2">
gegengebucht
</Badge>
)}
</td>
<td className="px-3 py-2">{buchung.kategorie.name}</td>
<td className="px-3 py-2">{buchung.konto.bezeichnung}</td>
<td
className={`px-3 py-2 text-right tabular-nums whitespace-nowrap ${
buchung.art === "AUSGABE" ? "text-[var(--destructive)]" : ""
}`}
>
{buchung.art === "AUSGABE" ? "−" : "+"}
{alsBetrag(buchung.betrag)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{darfBearbeiten && (
<KontoAnlegen
offen={kontoOffen}
schliessen={() => setKontoOffen(false)}
fehler={setMeldung}
fertig={() => {
setKontoOffen(false);
void werkzeuge.kassenkonto.invalidate();
}}
/>
)}
</div>
);
}
function Aufgabe({
titel,
symbol,
wert,
hinweis,
pfad,
hervorheben = false,
}: {
titel: string;
symbol: React.ReactNode;
wert: string;
hinweis: string;
pfad: string;
hervorheben?: boolean;
}) {
return (
<Link href={pfad} className="block">
<Card
className={`h-full transition-colors hover:bg-[var(--accent)] ${
hervorheben ? "border-[var(--pruefung-faellig)]" : ""
}`}
>
<CardHeader className="pb-2">
<div className="flex items-center gap-2 text-[var(--muted-foreground)]">
{symbol}
<CardTitle className="text-sm font-medium">{titel}</CardTitle>
</div>
</CardHeader>
<CardContent>
<p className="font-medium">{wert}</p>
<p className="text-sm text-[var(--muted-foreground)]">{hinweis}</p>
</CardContent>
</Card>
</Link>
);
}
/** Ein Konto anlegen. */
function KontoAnlegen({
offen,
schliessen,
fertig,
fehler,
}: {
offen: boolean;
schliessen: () => void;
fertig: () => void;
fehler: (nachricht: string) => void;
}) {
const [bezeichnung, setBezeichnung] = React.useState("");
const [art, setArt] = React.useState<KontoArt>(KontoArt.BAR);
const [iban, setIban] = React.useState("");
const [anfangsbestand, setAnfangsbestand] = React.useState("0");
const anlegen = trpc.kassenkonto.anlegen.useMutation({
onSuccess: () => {
setBezeichnung("");
setIban("");
setAnfangsbestand("0");
fertig();
},
onError: (aufruf) => fehler(aufruf.message),
});
return (
<Dialog open={offen} onOpenChange={(zustand) => !zustand && schliessen()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Konto anlegen</DialogTitle>
<DialogDescription>
Der Anfangsbestand ist der Ausgangspunkt aller Bestände. Er lässt sich später nicht
mehr ändern, sobald darauf gebucht wurde — eine Korrektur läuft dann über eine
Buchung.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="konto-bezeichnung">Bezeichnung</Label>
<Input
id="konto-bezeichnung"
value={bezeichnung}
onChange={(ereignis) => setBezeichnung(ereignis.target.value)}
placeholder="Barkasse Gerätehaus"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="konto-art">Art</Label>
<Select value={art} onValueChange={(wert) => setArt(wert as KontoArt)}>
<SelectTrigger id="konto-art">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.values(KontoArt).map((wert) => (
<SelectItem key={wert} value={wert}>
{KONTOART_TEXT[wert]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="konto-bestand">Anfangsbestand</Label>
<Input
id="konto-bestand"
type="number"
step="0.01"
value={anfangsbestand}
onChange={(ereignis) => setAnfangsbestand(ereignis.target.value)}
/>
</div>
</div>
{art !== KontoArt.BAR && (
<div className="space-y-2">
<Label htmlFor="konto-iban">IBAN</Label>
<Input
id="konto-iban"
value={iban}
onChange={(ereignis) => setIban(ereignis.target.value.toUpperCase())}
placeholder="DE00 0000 0000 0000 0000 00"
className="font-mono"
/>
<p className="text-xs text-[var(--muted-foreground)]">
Steht auf der Rechnung im Zahlungshinweis. In Listen wird sie gekürzt angezeigt.
</p>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={schliessen}>
Abbrechen
</Button>
<Button
onClick={() =>
anlegen.mutate({
bezeichnung,
art,
iban: iban.replace(/\s/g, "") || null,
anfangsbestand: Number(anfangsbestand) || 0,
aktiv: true,
})
}
disabled={!bezeichnung.trim() || anlegen.isPending}
>
{anlegen.isPending ? "Wird angelegt …" : "Anlegen"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}