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
+1
View File
@@ -39,6 +39,7 @@ export default async function DashboardSeite() {
dringlichkeit: aufgabe.dringlichkeit,
badgeText: aufgabe.badgeText,
href: aufgabe.href,
aktion: aufgabe.aktion,
})),
...ohneKunde.map((host) => ({
id: `zuweisen-${host.id}`,
@@ -0,0 +1,197 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { KundenrechnungFormular, ProxmoxRechnungFormular } from "@/components/rechnungen/RechnungsFormulare";
import { Badge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
return { title: laufzeit ? `Laufzeit ${laufzeit.host.hostname}` : "Laufzeit" };
}
export default async function LaufzeitSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const laufzeit = await laufzeitDetail(id);
if (!laufzeit) notFound();
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
<>
<ContentHeader
titel={`Laufzeit ${zeitraum}`}
brotkrumen={[
{ titel: "Rechnungen", href: "/rechnungen" },
{ titel: laufzeit.host.hostname, href: `/hosts/${laufzeit.host.id}` },
{ titel: zeitraum },
]}
aktion={laufzeit.status === "VORSCHLAG" ? <Badge farbe="info">Vorschlag</Badge> : null}
/>
<div className="app-content">
<div className="container-fluid">
{laufzeit.status === "VORSCHLAG" ? (
<Callout
titel="Vom Agent vorgeschlagen"
farbe="info"
aktion={
<div className="d-flex gap-2">
<form action={laufzeitBestaetigenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-primary pl-touch-target">
Bestätigen
</button>
</form>
<form action={laufzeitVerwerfenAktion}>
<input type="hidden" name="laufzeitId" value={laufzeit.id} />
<input type="hidden" name="hostId" value={laufzeit.host.id} />
<button type="submit" className="btn btn-outline-danger pl-touch-target">
Verwerfen
</button>
</form>
</div>
}
>
Der Host hat ein neues Ablaufdatum gemeldet. Mit dem Erfassen einer Proxmox-Rechnung gilt die
Laufzeit automatisch als bestätigt.
</Callout>
) : null}
<div className="row">
<div className="col-lg-4">
<Card titel="Laufzeit" rahmen="primary">
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Host</dt>
<dd className="col-7">
<Link href={`/hosts/${laufzeit.host.id}`}>{laufzeit.host.hostname}</Link>
</dd>
<dt className="col-5 fw-normal text-body-secondary">Kunde</dt>
<dd className="col-7">
{laufzeit.host.kunde ? (
<Link href={`/kunden/${laufzeit.host.kunde.id}`}>{laufzeit.host.kunde.name}</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Produkt</dt>
<dd className="col-7">{PRODUKT_TEXT[laufzeit.host.produkt]}</dd>
<dt className="col-5 fw-normal text-body-secondary">Key</dt>
<dd className="col-7">
<CopyableCode wert={laufzeit.key} />
</dd>
<dt className="col-5 fw-normal text-body-secondary">Beginn</dt>
<dd className="col-7">{formatDatum(laufzeit.start)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Ende</dt>
<dd className="col-7">{formatDatum(laufzeit.ende)}</dd>
</dl>
<hr />
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
<dd className={`col-7 ${(marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}>
{marge === null ? "–" : formatEuro(marge)}
</dd>
</dl>
</Card>
{einkauf && einkauf.positionen.length > 1 ? (
<Card titel="Sammelrechnung" rahmen="info">
<p className="small">
Die Proxmox-Rechnung <span className="pl-mono">{einkauf.nummer}</span> deckt mehrere
Laufzeiten ab:
</p>
<ul className="list-unstyled mb-0 small">
{einkauf.positionen.map((position) => (
<li key={position.id} className="mb-1">
{position.laufzeit ? (
<Link href={`/rechnungen/${position.laufzeit.id}`}>
{position.laufzeit.host.hostname}
</Link>
) : (
position.bezeichnung
)}{" "}
· {formatEuro(Number(position.nettoBetrag))}
</li>
))}
</ul>
</Card>
) : null}
</div>
<div className="col-lg-8">
<Card
titel="Proxmox-Rechnung (Einkauf)"
rahmen={einkauf?.bezahlt ? "info" : "danger"}
werkzeuge={<EinkaufBadge bezahlt={einkauf ? einkauf.bezahlt : null} />}
>
<ProxmoxRechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
werte={{
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? null,
nettoBetrag: einkaufBetrag,
ustSatz: Number(einkauf?.ustSatz ?? 19),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
? null
: Number(einkauf.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
anhangId: einkauf?.anhangId ?? null,
anhangName: einkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
<Card
titel="Kundenrechnung (Verkauf)"
rahmen={verkauf?.erstellt ? "info" : "warning"}
werkzeuge={
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
}
>
<KundenrechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
kundeVorhanden={Boolean(laufzeit.host.kunde)}
werte={{
erstellt: verkauf?.erstellt ?? false,
nummer: verkauf?.nummer ?? null,
datum: verkauf?.datum?.toISOString() ?? null,
nettoBetrag: verkaufBetrag,
ustSatz: Number(verkauf?.ustSatz ?? 19),
bezahlt: verkauf?.bezahlt ?? false,
bezahltAm: verkauf?.bezahltAm?.toISOString() ?? null,
anhangId: verkauf?.anhangId ?? null,
anhangName: verkauf?.anhang?.dateiname ?? null,
}}
/>
</Card>
</div>
</div>
</div>
</div>
</>
);
}
+214 -54
View File
@@ -1,26 +1,55 @@
import Link from "next/link";
import { ContentHeader } from "@/components/layout/ContentHeader";
import { StatusHaken } from "@/components/rechnungen/StatusHaken";
import { Badge } from "@/components/ui/Badges";
import { Callout } from "@/components/ui/Callout";
import { Card } from "@/components/ui/Card";
import { CopyableCode } from "@/components/ui/CopyableCode";
import { formatDatum, formatEuro } from "@/lib/format";
import { hosts, laufzeiten } from "@/mock/data";
import { getKunde, istAbgeschlossen } from "@/mock/queries";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import {
anzahlVorschlaege,
laufzeitJahre,
rechnungsListe,
type RechnungsFilter,
} from "@/server/queries/rechnungen";
export const metadata = { title: "Rechnungen" };
export default async function RechnungenSeite({
searchParams,
}: {
searchParams: Promise<{ filter?: string }>;
}) {
const { filter = "offen" } = await searchParams;
const alle = laufzeiten.filter((l) => !l.vorschlag);
const liste =
filter === "erledigt"
? alle.filter(istAbgeschlossen)
: filter === "alle"
? alle
: alle.filter((l) => !istAbgeschlossen(l));
type SuchParameter = Promise<Record<string, string | string[] | undefined>>;
function einzeln(wert: string | string[] | undefined): string | undefined {
const text = Array.isArray(wert) ? wert[0] : wert;
return text && text.length > 0 ? text : undefined;
}
export default async function RechnungenSeite({ searchParams }: { searchParams: SuchParameter }) {
const roh = await searchParams;
const filter: RechnungsFilter = {
status: (einzeln(roh.status) as RechnungsFilter["status"]) ?? "offen",
kunde: einzeln(roh.kunde),
jahr: einzeln(roh.jahr),
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
]);
const heute = new Date();
function adresse(aenderung: Record<string, string | undefined>) {
const params = new URLSearchParams();
for (const [schluessel, wert] of Object.entries({ ...filter, ...aenderung })) {
if (wert) params.set(schluessel, String(wert));
}
const text = params.toString();
return text ? `/rechnungen?${text}` : "/rechnungen";
}
return (
<>
@@ -28,21 +57,73 @@ export default async function RechnungenSeite({
<div className="app-content">
<div className="container-fluid">
{vorschlaege > 0 && filter.status !== "vorschlaege" ? (
<Callout
titel={`${vorschlaege} vorgeschlagene Laufzeit${vorschlaege === 1 ? "" : "en"}`}
farbe="info"
aktion={
<Link href={adresse({ status: "vorschlaege" })} className="btn btn-primary pl-touch-target">
Ansehen
</Link>
}
>
Die Agents haben neue Ablaufdaten gemeldet. Bestätige die Laufzeit und trage die Rechnungsdaten
ein.
</Callout>
) : null}
<p className="small text-body-secondary mb-2">
Summen beziehen sich auf die aktuelle Auswahl ({zeilen.length} Laufzeiten).
</p>
<div className="row">
<div className="col-6 col-lg-3">
<InfoBox
text="Einkauf netto"
wert={formatEuro(summen.einkauf)}
icon="bi-cart"
farbe="neutral"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Verkauf netto"
wert={formatEuro(summen.verkauf)}
icon="bi-receipt"
farbe="primary"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox
text="Marge"
wert={formatEuro(summen.marge)}
icon="bi-graph-up-arrow"
farbe="success"
/>
</div>
<div className="col-6 col-lg-3">
<InfoBox text="Laufzeiten" wert={String(zeilen.length)} icon="bi-calendar-range" farbe="info" />
</div>
</div>
<Card
titel={`${liste.length} Laufzeiten`}
titel="Laufzeiten und Rechnungen"
rahmen="primary"
tabelle
werkzeuge={
<div className="btn-group btn-group-sm" role="group" aria-label="Filter">
{[
["offen", "Offen"],
["erledigt", "Erledigt"],
["alle", "Alle"],
].map(([wert, text]) => (
{(
[
["offen", "Offen"],
["erledigt", "Erledigt"],
["alle", "Alle"],
["vorschlaege", `Vorschläge${vorschlaege ? ` (${vorschlaege})` : ""}`],
] as const
).map(([wert, text]) => (
<Link
key={wert}
href={`/rechnungen?filter=${wert}`}
className={`btn ${filter === wert ? "btn-primary" : "btn-outline-primary"}`}
href={adresse({ status: wert })}
className={`btn ${filter.status === wert ? "btn-primary" : "btn-outline-primary"}`}
>
{text}
</Link>
@@ -50,6 +131,55 @@ export default async function RechnungenSeite({
</div>
}
>
<form className="row g-2 p-3 border-bottom" role="search">
<input type="hidden" name="status" value={filter.status} />
<div className="col-md-4">
<input
name="q"
type="search"
defaultValue={filter.q ?? ""}
className="form-control"
placeholder="Rechnungsnummer, Key oder Host …"
aria-label="Suche"
/>
</div>
<div className="col-6 col-md-3">
<select
name="kunde"
defaultValue={filter.kunde ?? ""}
className="form-select"
aria-label="Kunde"
>
<option value="">Alle Kunden</option>
{kunden.map((kunde) => (
<option key={kunde.id} value={kunde.id}>
{kunde.name}
</option>
))}
</select>
</div>
<div className="col-6 col-md-2">
<select
name="jahr"
defaultValue={filter.jahr ?? ""}
className="form-select"
aria-label="Jahr"
>
<option value="">Alle Jahre</option>
{jahre.map((jahr) => (
<option key={jahr} value={jahr}>
{jahr}
</option>
))}
</select>
</div>
<div className="col-md-2">
<button type="submit" className="btn btn-primary w-100">
Filtern
</button>
</div>
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
@@ -60,76 +190,106 @@ export default async function RechnungenSeite({
<th scope="col">Proxmox-Rechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="text-center">
Proxmox bezahlt
Bezahlt
</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Netto</th>
<th scope="col" className="text-center">
Kundenrechnung erstellt
Erstellt
</th>
<th scope="col" className="text-center">
Bezahlt
</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{liste.map((laufzeit) => {
const host = hosts.find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
const marge = (laufzeit.verkauf?.nettoBetrag ?? 0) - (laufzeit.einkauf?.nettoBetrag ?? 0);
{zeilen.map((zeile) => {
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return (
<tr key={laufzeit.id}>
<tr key={zeile.id}>
<td>
{kunde ? (
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
{zeile.kunde ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
{host?.hostname}
<Link href={`/hosts/${zeile.hostId}`} className="text-decoration-none">
{zeile.hostname}
</Link>
</td>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td>
<td>
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
fällig {formatDatum(zeile.einkaufFaelligAm)}
</div>
) : null}
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td>{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<input
type="checkbox"
className="form-check-input pl-touch-target"
defaultChecked={laufzeit.einkauf?.bezahlt ?? false}
aria-label="Proxmox-Rechnung bezahlt"
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="einkaufBezahlt"
wert={zeile.einkaufBezahlt === true}
deaktiviert={zeile.einkaufBezahlt === null}
titel="Proxmox-Rechnung bezahlt"
/>
</td>
<td>
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td>{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<input
type="checkbox"
className="form-check-input pl-touch-target"
defaultChecked={laufzeit.verkauf?.erstellt ?? false}
aria-label="Kundenrechnung erstellt"
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufErstellt"
wert={zeile.verkaufErstellt}
titel="Kundenrechnung erstellt"
/>
</td>
<td className={marge >= 0 ? "pl-text-success" : "pl-text-danger"}>
{laufzeit.verkauf?.nettoBetrag ? formatEuro(marge) : "–"}
<td className="text-center">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
feld="verkaufBezahlt"
wert={zeile.verkaufBezahlt}
deaktiviert={!zeile.verkaufErstellt}
titel="Kundenrechnung vom Kunden bezahlt"
/>
</td>
<td className={(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
</td>
</tr>
);
})}
{zeilen.length === 0 ? (
<tr>
<td colSpan={11} className="text-center text-body-secondary py-4">
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
<p className="small text-body-secondary">
Im Prototyp sind die Häkchen noch ohne Wirkung. Ab #008 setzen sie den Status direkt.
Die Häkchen wirken sofort. Für Rechnungsnummer, Beträge und PDF die Laufzeit öffnen.
</p>
</div>
</div>
+26
View File
@@ -0,0 +1,26 @@
import { readFile } from "node:fs/promises";
import { prisma } from "@/lib/db";
import { anhangPfad } from "@/lib/uploads";
export const dynamic = "force-dynamic";
/** Liefert ein hinterlegtes Rechnungs-PDF aus. */
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const anhang = await prisma.anhang.findUnique({ where: { id } });
if (!anhang) return new Response("Nicht gefunden", { status: 404 });
try {
const inhalt = await readFile(anhangPfad(anhang.pfad));
return new Response(new Uint8Array(inhalt), {
headers: {
"Content-Type": anhang.mimeTyp,
"Content-Disposition": `inline; filename="${encodeURIComponent(anhang.dateiname)}"`,
"Content-Length": String(inhalt.length),
"Cache-Control": "private, no-store",
},
});
} catch {
return new Response("Datei fehlt auf der Platte", { status: 410 });
}
}