Phase 9: Kassenverwaltung (#14)
This commit was merged in pull request #14.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user