feat(rechnungen): Laufzeiten bestaetigen, Rechnungen erfassen und Status setzen (#008)

Rechnungen mit Positionen: gleiche Nummer ergibt eine Sammelrechnung ueber mehrere
Laufzeiten. PDF-Upload mit Inhaltspruefung und Ablage unter der Pruefsumme,
Haekchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr. 78 Tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 21:54:45 +02:00
co-authored by Claude Opus 5
parent 1e1d6c4418
commit 558dfea34c
23 changed files with 2015 additions and 86 deletions
@@ -0,0 +1,345 @@
"use client";
import { useActionState } from "react";
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
const LEER: AktionsErgebnis = {};
function isoDatum(wert: string | null | undefined) {
return wert ? wert.slice(0, 10) : "";
}
function Meldung({ ergebnis }: { ergebnis: AktionsErgebnis }) {
if (ergebnis.fehler) return <div className="alert alert-danger py-2">{ergebnis.fehler}</div>;
if (ergebnis.erfolg) return <div className="alert alert-success py-2">{ergebnis.erfolg}</div>;
return null;
}
export interface ProxmoxWerte {
nummer: string | null;
datum: string | null;
faelligAm: string | null;
nettoBetrag: number | null;
ustSatz: number;
rabatt: number | null;
zahlungsart: string | null;
bezahlt: boolean;
bezahltAm: string | null;
anhangId: string | null;
anhangName: string | null;
}
/** Proxmox-Rechnung (Einkauf). Gleiche Rechnungsnummer heißt: Position an derselben Rechnung. */
export function ProxmoxRechnungFormular({
laufzeitId,
hostId,
werte,
}: {
laufzeitId: string;
hostId: string;
werte: ProxmoxWerte;
}) {
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
return (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-6">
<label className="form-label" htmlFor="px-nummer">
Rechnungsnummer *
</label>
<input
id="px-nummer"
name="nummer"
className="form-control pl-mono"
defaultValue={werte.nummer ?? ""}
placeholder="PX-2026-14790"
required
/>
<div className="form-text">
Eine bereits vorhandene Nummer ergänzt die Sammelrechnung um diese Laufzeit.
</div>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-datum">
Rechnungsdatum *
</label>
<input
id="px-datum"
name="datum"
type="date"
className="form-control"
defaultValue={isoDatum(werte.datum)}
required
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-faellig">
Fällig am
</label>
<input
id="px-faellig"
name="faelligAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.faelligAm)}
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-netto">
Netto (€) *
</label>
<input
id="px-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
required
/>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="px-ust">
USt (%)
</label>
<input
id="px-ust"
name="ustSatz"
className="form-control"
inputMode="decimal"
defaultValue={werte.ustSatz}
/>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="px-rabatt">
Rabatt (€)
</label>
<input
id="px-rabatt"
name="rabatt"
className="form-control"
inputMode="decimal"
defaultValue={werte.rabatt ?? ""}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-zahlungsart">
Zahlungsart
</label>
<input
id="px-zahlungsart"
name="zahlungsart"
className="form-control"
defaultValue={werte.zahlungsart ?? ""}
placeholder="Überweisung"
/>
</div>
<div className="col-md-4 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="px-bezahlt"
name="bezahlt"
defaultChecked={werte.bezahlt}
/>
<label className="form-check-label" htmlFor="px-bezahlt">
bezahlt
</label>
</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-bezahltAm">
Bezahlt am
</label>
<input
id="px-bezahltAm"
name="bezahltAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.bezahltAm)}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="px-pdf">
PDF (max. 10 MB)
</label>
<input id="px-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
{werte.anhangId ? (
<div className="form-text">
Hinterlegt:{" "}
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
{werte.anhangName ?? "PDF ansehen"}
</a>
</div>
) : null}
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Proxmox-Rechnung speichern"}
</button>
</form>
);
}
export interface KundenWerte {
erstellt: boolean;
nummer: string | null;
datum: string | null;
nettoBetrag: number | null;
ustSatz: number;
bezahlt: boolean;
bezahltAm: string | null;
anhangId: string | null;
anhangName: string | null;
}
/** Kundenrechnung (Verkauf). */
export function KundenrechnungFormular({
laufzeitId,
hostId,
werte,
kundeVorhanden,
}: {
laufzeitId: string;
hostId: string;
werte: KundenWerte;
kundeVorhanden: boolean;
}) {
const [ergebnis, aktion, laeuft] = useActionState(kundenrechnungSpeichern, LEER);
if (!kundeVorhanden) {
return (
<p className="mb-0 text-body-secondary">
Dieser Host ist keinem Kunden zugewiesen. Ohne Kunde lässt sich keine Kundenrechnung erfassen.
</p>
);
}
return (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<Meldung ergebnis={ergebnis} />
<div className="row g-3">
<div className="col-md-4 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="kr-erstellt"
name="erstellt"
defaultChecked={werte.erstellt}
/>
<label className="form-check-label" htmlFor="kr-erstellt">
Rechnung ist erstellt
</label>
</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-nummer">
Rechnungsnummer
</label>
<input
id="kr-nummer"
name="nummer"
className="form-control pl-mono"
defaultValue={werte.nummer ?? ""}
placeholder="RE-2026-0412"
/>
<div className="form-text">Gleiche Nummer = eine Rechnung über mehrere Laufzeiten.</div>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-datum">
Rechnungsdatum
</label>
<input
id="kr-datum"
name="datum"
type="date"
className="form-control"
defaultValue={isoDatum(werte.datum)}
/>
</div>
<div className="col-md-4">
<label className="form-label" htmlFor="kr-netto">
Netto (€)
</label>
<input
id="kr-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
/>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="kr-ust">
USt (%)
</label>
<input
id="kr-ust"
name="ustSatz"
className="form-control"
inputMode="decimal"
defaultValue={werte.ustSatz}
/>
</div>
<div className="col-md-3 d-flex align-items-end">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="kr-bezahlt"
name="bezahlt"
defaultChecked={werte.bezahlt}
/>
<label className="form-check-label" htmlFor="kr-bezahlt">
vom Kunden bezahlt
</label>
</div>
</div>
<div className="col-md-3">
<label className="form-label" htmlFor="kr-bezahltAm">
Bezahlt am
</label>
<input
id="kr-bezahltAm"
name="bezahltAm"
type="date"
className="form-control"
defaultValue={isoDatum(werte.bezahltAm)}
/>
</div>
<div className="col-md-6">
<label className="form-label" htmlFor="kr-pdf">
PDF (max. 10 MB)
</label>
<input id="kr-pdf" name="pdf" type="file" accept="application/pdf" className="form-control" />
{werte.anhangId ? (
<div className="form-text">
Hinterlegt:{" "}
<a href={`/anhang/${werte.anhangId}`} target="_blank" rel="noreferrer">
{werte.anhangName ?? "PDF ansehen"}
</a>
</div>
) : null}
</div>
</div>
<button type="submit" className="btn btn-primary mt-3 pl-touch-target" disabled={laeuft}>
{laeuft ? "Speichert …" : "Kundenrechnung speichern"}
</button>
</form>
);
}
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useTransition } from "react";
import { statusUmschalten } from "@/server/actions/rechnungen";
/**
* Häkchen, das den Rechnungsstatus direkt in der Tabelle setzt.
* Ohne erfasste Rechnung bleibt es deaktiviert – außer bei „Kundenrechnung erstellt“,
* denn dort legt das Häkchen eine Rechnung ohne Nummer an, die später ergänzt wird.
*/
export function StatusHaken({
laufzeitId,
hostId,
feld,
wert,
deaktiviert = false,
titel,
}: {
laufzeitId: string;
hostId: string;
feld: "einkaufBezahlt" | "verkaufErstellt" | "verkaufBezahlt";
wert: boolean;
deaktiviert?: boolean;
titel: string;
}) {
const [laeuft, starte] = useTransition();
function umschalten(neu: boolean) {
const daten = new FormData();
daten.set("laufzeitId", laufzeitId);
daten.set("hostId", hostId);
daten.set("feld", feld);
daten.set("wert", String(neu));
starte(async () => {
await statusUmschalten(daten);
});
}
return (
<span className="d-inline-flex align-items-center justify-content-center">
{laeuft ? (
<span
className="spinner-border spinner-border-sm text-body-secondary"
role="status"
aria-hidden="true"
/>
) : (
<input
type="checkbox"
className="form-check-input pl-touch-target"
checked={wert}
disabled={deaktiviert}
onChange={(e) => umschalten(e.target.checked)}
aria-label={titel}
title={deaktiviert ? "Erst eine Rechnung erfassen" : titel}
/>
)}
</span>
);
}
+36 -9
View File
@@ -1,7 +1,8 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { useState, useTransition } from "react";
import { statusUmschalten } from "@/server/actions/rechnungen";
import { Badge } from "./Badges";
import type { Farbton } from "./SmallBox";
@@ -12,6 +13,12 @@ export interface Aufgabe {
dringlichkeit: "gefahr" | "warnung" | "info";
badgeText: string;
href: string;
/** Gesetzt, wenn das Abhaken direkt einen Rechnungsstatus setzt. */
aktion?: {
laufzeitId: string;
hostId: string;
feld: "einkaufBezahlt" | "verkaufErstellt";
};
}
const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
@@ -21,14 +28,30 @@ const BADGE_FARBE: Record<Aufgabe["dringlichkeit"], Farbton> = {
};
/**
* Aufgabenliste im AdminLTE-Todo-Stil. Im Prototyp merkt sich das Abhaken den Zustand nur lokal;
* ab #008 setzt es den Rechnungsstatus in der Datenbank.
* Aufgabenliste im AdminLTE-Todo-Stil. Aufgaben mit `aktion` setzen beim Abhaken den
* Rechnungsstatus in der Datenbank; die übrigen sind Merkposten und verschwinden erst,
* wenn die Ursache behoben ist (z. B. ein Host einem Kunden zugewiesen wurde).
*/
export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
const [erledigt, setErledigt] = useState<string[]>([]);
const [oertlichErledigt, setOertlichErledigt] = useState<string[]>([]);
const [laeuft, starte] = useTransition();
function umschalten(id: string) {
setErledigt((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
function umschalten(aufgabe: Aufgabe) {
if (!aufgabe.aktion) {
setOertlichErledigt((alt) =>
alt.includes(aufgabe.id) ? alt.filter((x) => x !== aufgabe.id) : [...alt, aufgabe.id],
);
return;
}
const daten = new FormData();
daten.set("laufzeitId", aufgabe.aktion.laufzeitId);
daten.set("hostId", aufgabe.aktion.hostId);
daten.set("feld", aufgabe.aktion.feld);
daten.set("wert", "true");
starte(async () => {
await statusUmschalten(daten);
});
}
if (aufgaben.length === 0) {
@@ -38,7 +61,7 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
return (
<ul className="pl-todo-list">
{aufgaben.map((aufgabe) => {
const istErledigt = erledigt.includes(aufgabe.id);
const istErledigt = oertlichErledigt.includes(aufgabe.id);
return (
<li
key={aufgabe.id}
@@ -48,8 +71,12 @@ export function TodoList({ aufgaben }: { aufgaben: Aufgabe[] }) {
type="checkbox"
className="form-check-input mt-1 pl-touch-target"
checked={istErledigt}
onChange={() => umschalten(aufgabe.id)}
aria-label={`${aufgabe.titel} erledigt`}
disabled={laeuft}
onChange={() => umschalten(aufgabe)}
aria-label={
aufgabe.aktion ? `${aufgabe.titel} als erledigt eintragen` : `${aufgabe.titel} abhaken`
}
title={aufgabe.aktion ? "Setzt den Rechnungsstatus" : "Nur zur eigenen Übersicht"}
/>
<div className="flex-grow-1 pl-todo-text">
<Link href={aufgabe.href} className="fw-semibold text-decoration-none">