feat(laufzeiten): anlegen, bearbeiten und zusammenfuehren (#043)

Laufzeiten entstanden bisher nur als Vorschlag aus dem Agenten-Report;
bestaetigen oder verwerfen war alles. Anlegen von Hand gab es als Aktion ohne
Oberflaeche, Aendern und Zusammenfuehren gar nicht - obwohl der Doppelfall
zwangslaeufig auftritt, sobald das gemeldete Ablaufdatum von der Bestellung
abweicht.

Jetzt: Formular zum Anlegen auf der Rechnungsseite (Key vorgeschlagen, Ende
aus dem Beginn vorbelegt), Bearbeiten von Key, Zeitraum und Notiz im
Laufzeit-Detail, und Zusammenfuehren zweier Laufzeiten desselben Hosts. Die
aufgeloeste gibt ihre Rechnungen ab, sofern das Ziel dort nichts hat; sonst
bricht der Vorgang mit Erklaerung ab - zwei Einkaufsrechnungen auf einem
Lizenzjahr waeren nicht mehr zuzuordnen.

Dazu die Kundenansicht: Laufzeit und Host verlinkt, Marge und Rabatt je Zeile.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 20:57:07 +02:00
co-authored by Claude Opus 5
parent f076f49631
commit 83320f0443
22 changed files with 738 additions and 15 deletions
+43 -9
View File
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Laufzeit</th>
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
<th scope="col">Einkauf</th>
<th scope="col">Kundenrechnung</th>
<th scope="col">Verkauf</th>
<th scope="col">Marge</th>
</tr>
</thead>
<tbody>
{laufzeiten.map((laufzeit) => (
<tr key={laufzeit.id}>
<td>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
Kundenansicht in eine Sackgasse (#043). */}
<td className="pl-karten-kopf">
<Link
href={`/rechnungen/${laufzeit.id}`}
className="fw-semibold text-decoration-none"
>
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
</Link>
{laufzeit.vorschlag ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>{laufzeit.hostname}</td>
<td>
<td data-label="Host">
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
{laufzeit.hostname}
</Link>
</td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
<div>
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
</div>
</td>
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
<td>
<td data-label="Einkauf">
{formatEuro(laufzeit.einkauf?.nettoBetrag)}
{laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? (
<div className="small text-body-secondary">
{formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "}
Rabatt
</div>
) : null}
</td>
<td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
<div>
<VerkaufBadge
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
/>
</div>
</td>
<td>{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td data-label="Verkauf">{formatEuro(laufzeit.verkauf?.nettoBetrag)}</td>
<td
data-label="Marge"
className={
laufzeit.einkauf && laufzeit.verkauf
? (laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag >= 0
? "pl-text-success"
: "pl-text-danger"
: undefined
}
>
{laufzeit.einkauf && laufzeit.verkauf
? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag)
: "–"}
</td>
</tr>
))}
{laufzeiten.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
<td colSpan={7} className="text-center text-body-secondary py-4">
Noch keine Laufzeiten erfasst.
</td>
</tr>
+35 -1
View File
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
import { laufzeitDetail } from "@/server/queries/rechnungen";
import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten";
import { prisma } from "@/lib/db";
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
@@ -33,7 +35,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]);
const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([
einstellungen(),
faelligkeitsVorschlag(),
prisma.laufzeit.findMany({
where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } },
orderBy: { start: "desc" },
select: {
id: true,
key: true,
start: true,
ende: true,
proxmoxPositionId: true,
kundenPositionId: true,
},
}),
]);
const verkaufVorschlag =
verkaufBetrag ??
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
@@ -127,6 +144,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
{marge === null ? "–" : formatEuro(marge)}
</dd>
</dl>
<hr />
<LaufzeitBearbeiten
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
schluessel={laufzeit.key}
start={laufzeit.start.toISOString().slice(0, 10)}
ende={laufzeit.ende.toISOString().slice(0, 10)}
notizen={laufzeit.notizen}
nachbarn={nachbarn.map((nachbar) => ({
id: nachbar.id,
zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`,
key: nachbar.key,
hatEinkauf: nachbar.proxmoxPositionId !== null,
hatVerkauf: nachbar.kundenPositionId !== null,
}))}
/>
</Card>
{einkauf && einkauf.positionen.length > 1 ? (
+20 -1
View File
@@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
import { InfoBox } from "@/components/ui/SmallBox";
import { prisma } from "@/lib/db";
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
import {
anzahlVorschlaege,
laufzeitJahre,
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
q: einzeln(roh.q),
};
const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([
const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([
rechnungsListe(filter),
laufzeitJahre(),
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
anzahlVorschlaege(),
prisma.host.findMany({
where: { archiviertAm: null },
orderBy: { hostname: "asc" },
select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } },
}),
]);
const heute = new Date();
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
</div>
{/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für
Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */}
<Card titel="Laufzeit von Hand anlegen" rahmen="keiner">
<LaufzeitAnlegen
hosts={hostsFuerLaufzeit.map((host) => ({
id: host.id,
hostname: host.hostname,
kundeName: host.kunde?.name ?? null,
subKey: host.subKey,
}))}
/>
</Card>
<Card
titel="Laufzeiten und Rechnungen"
rahmen="primary"
@@ -0,0 +1,131 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import { laufzeitAnlegenAktion } from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
/** Ein Jahr minus einen Tag – dieselbe Rechnung wie beim Vorschlag aus dem Agenten-Report. */
function endeAus(start: string): string {
if (!start) return "";
const datum = new Date(`${start}T00:00:00Z`);
datum.setUTCFullYear(datum.getUTCFullYear() + 1);
datum.setUTCDate(datum.getUTCDate() - 1);
return datum.toISOString().slice(0, 10);
}
/**
* Laufzeit von Hand anlegen (#043).
*
* Der Normalfall ist der Vorschlag aus dem Agenten-Report. Von Hand braucht man es, wenn ein Host
* (noch) kein Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein
* zurückliegendes Lizenzjahr nachgetragen werden soll.
*/
export function LaufzeitAnlegen({
hosts,
}: {
hosts: { id: string; hostname: string; kundeName: string | null; subKey: string | null }[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitAnlegenAktion, LEER);
const [hostId, setHostId] = useState("");
const [key, setKey] = useState("");
const [start, setStart] = useState("");
const [ende, setEnde] = useState("");
const gewaehlt = hosts.find((host) => host.id === hostId);
return (
<form action={aktion} className="row g-3 align-items-start">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lz-host">
Host
</label>
<select
className="form-select"
id="lz-host"
name="hostId"
value={hostId}
onChange={(e) => {
setHostId(e.target.value);
// Den gemeldeten Key vorschlagen – abtippen ist hier die häufigste Fehlerquelle.
const host = hosts.find((h) => h.id === e.target.value);
if (host?.subKey) setKey(host.subKey);
}}
required
>
<option value="">– bitte wählen –</option>
{hosts.map((host) => (
<option key={host.id} value={host.id}>
{host.hostname}
{host.kundeName ? ` · ${host.kundeName}` : " · nicht zugeordnet"}
</option>
))}
</select>
</div>
<div className="col-12 col-md-3">
<label className="form-label" htmlFor="lz-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lz-key"
name="key"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="pve2s-…"
required
/>
{gewaehlt?.subKey ? (
<div className="form-text">Gemeldet: {gewaehlt.subKey}</div>
) : (
<div className="form-text">Dieser Host meldet keinen Key – Nummer aus der Bestellung eintragen.</div>
)}
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-start">
Beginn
</label>
<input
type="date"
className="form-control"
id="lz-start"
name="start"
value={start}
onChange={(e) => {
setStart(e.target.value);
setEnde(endeAus(e.target.value));
}}
required
/>
</div>
<div className="col-6 col-md-2">
<label className="form-label" htmlFor="lz-ende">
Ende
</label>
<input
type="date"
className="form-control"
id="lz-ende"
name="ende"
value={ende}
onChange={(e) => setEnde(e.target.value)}
required
/>
<div className="form-text">Ein Jahr, vorbelegt.</div>
</div>
<div className="col-12">
<button type="submit" className="btn btn-outline-primary" disabled={laeuft}>
{laeuft ? "Wird angelegt …" : "Laufzeit anlegen"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,160 @@
"use client";
import { useActionState, useState } from "react";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import {
laufzeitBearbeitenAktion,
laufzeitenZusammenfuehrenAktion,
} from "@/server/actions/rechnungen";
const LEER: AktionsErgebnis = {};
export interface NachbarLaufzeit {
id: string;
zeitraum: string;
key: string;
hatEinkauf: boolean;
hatVerkauf: boolean;
}
/**
* Laufzeit nachträglich ändern und mit einer anderen desselben Hosts zusammenführen (#043).
*
* Nötig, weil Laufzeiten meist aus einem Agenten-Report entstehen: Weicht das gemeldete
* Ablaufdatum von der Bestellung ab, steht die Laufzeit falsch da – oder gleich doppelt.
*/
export function LaufzeitBearbeiten({
laufzeitId,
hostId,
// Nicht `key` nennen: Das ist in React reserviert und käme hier nie an.
schluessel,
start,
ende,
notizen,
nachbarn,
}: {
laufzeitId: string;
hostId: string;
schluessel: string;
start: string;
ende: string;
notizen: string | null;
nachbarn: NachbarLaufzeit[];
}) {
const [ergebnis, aktion, laeuft] = useActionState(laufzeitBearbeitenAktion, LEER);
const [zusammen, zusammenAktion, fuehrtZusammen] = useActionState(laufzeitenZusammenfuehrenAktion, LEER);
const [offen, setOffen] = useState(false);
return (
<>
<button
type="button"
className="btn btn-sm btn-outline-secondary"
onClick={() => setOffen(!offen)}
aria-expanded={offen}
>
<i className="bi bi-pencil me-1" aria-hidden="true" />
{offen ? "Bearbeiten schließen" : "Laufzeit bearbeiten"}
</button>
{offen ? (
<div className="mt-3">
{ergebnis.fehler ? <div className="alert alert-danger py-2">{ergebnis.fehler}</div> : null}
{ergebnis.erfolg ? <div className="alert alert-success py-2">{ergebnis.erfolg}</div> : null}
<form action={aktion} className="row g-3 align-items-start">
<input type="hidden" name="laufzeitId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-4">
<label className="form-label" htmlFor="lzb-key">
Subscription-Key
</label>
<input
className="form-control pl-mono"
id="lzb-key"
name="key"
defaultValue={schluessel}
required
/>
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-start">
Beginn
</label>
<input type="date" className="form-control" id="lzb-start" name="start" defaultValue={start} required />
</div>
<div className="col-6 col-md-3">
<label className="form-label" htmlFor="lzb-ende">
Ende
</label>
<input type="date" className="form-control" id="lzb-ende" name="ende" defaultValue={ende} required />
</div>
<div className="col-12">
<label className="form-label" htmlFor="lzb-notizen">
Notiz
</label>
<textarea
className="form-control"
id="lzb-notizen"
name="notizen"
rows={2}
defaultValue={notizen ?? ""}
/>
</div>
<div className="col-12">
<button type="submit" className="btn btn-primary" disabled={laeuft}>
{laeuft ? "Wird gespeichert …" : "Änderung speichern"}
</button>
</div>
</form>
{nachbarn.length > 0 ? (
<div className="mt-4 pt-3 border-top">
<h4 className="h6">Mit einer anderen Laufzeit zusammenführen</h4>
<p className="small text-body-secondary">
Die gewählte Laufzeit wird aufgelöst; ihre Rechnungen wandern hierher, sofern hier noch
keine stehen. Nützlich, wenn der Agent neben einer erfassten Laufzeit einen Vorschlag
mit abweichendem Datum angelegt hat.
</p>
{zusammen.fehler ? <div className="alert alert-danger py-2">{zusammen.fehler}</div> : null}
{zusammen.erfolg ? <div className="alert alert-success py-2">{zusammen.erfolg}</div> : null}
<form action={zusammenAktion} className="row g-2 align-items-end">
<input type="hidden" name="zielId" value={laufzeitId} />
<input type="hidden" name="hostId" value={hostId} />
<div className="col-12 col-md-8">
<label className="form-label" htmlFor="lzb-quelle">
Diese Laufzeit auflösen
</label>
<select className="form-select" id="lzb-quelle" name="quelleId" required>
<option value="">– bitte wählen –</option>
{nachbarn.map((nachbar) => (
<option key={nachbar.id} value={nachbar.id}>
{nachbar.zeitraum} · {nachbar.key}
{nachbar.hatEinkauf ? " · mit Einkauf" : ""}
{nachbar.hatVerkauf ? " · mit Verkauf" : ""}
</option>
))}
</select>
</div>
<div className="col-12 col-md-4">
<button type="submit" className="btn btn-outline-danger" disabled={fuehrtZusammen}>
{fuehrtZusammen ? "Wird zusammengeführt …" : "Zusammenführen"}
</button>
</div>
</form>
</div>
) : null}
</div>
) : null}
</>
);
}
+45
View File
@@ -7,6 +7,8 @@ import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBearbeiten,
laufzeitenZusammenfuehren,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
@@ -173,3 +175,46 @@ export async function laufzeitAnlegenAktion(
return "Laufzeit angelegt.";
});
}
export async function laufzeitBearbeitenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const id = formular.get("laufzeitId")?.toString();
const hostId = formular.get("hostId")?.toString();
const key = formular.get("key")?.toString().trim();
const start = datum(formular.get("start"));
const ende = datum(formular.get("ende"));
const notizen = formular.get("notizen")?.toString().trim() || null;
if (!id || !hostId || !key || !start || !ende) {
return { fehler: "Bitte Key, Beginn und Ende angeben." };
}
return ausfuehren(async () => {
await laufzeitBearbeiten(id, { key, start, ende, notizen });
seitenNeuLaden(hostId);
return "Laufzeit geändert.";
});
}
export async function laufzeitenZusammenfuehrenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
await sitzungErforderlich("fachlichSchreiben");
const zielId = formular.get("zielId")?.toString();
const quelleId = formular.get("quelleId")?.toString();
const hostId = formular.get("hostId")?.toString();
if (!zielId || !quelleId || !hostId) return { fehler: "Bitte eine Laufzeit zum Zusammenführen wählen." };
return ausfuehren(async () => {
await laufzeitenZusammenfuehren(zielId, quelleId);
seitenNeuLaden(hostId);
return "Laufzeiten zusammengeführt.";
});
}
+101
View File
@@ -324,3 +324,104 @@ export async function laufzeitAnlegen(daten: {
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
return laufzeit;
}
/** Ändert Key, Zeitraum oder Notiz einer Laufzeit (#043). */
export async function laufzeitBearbeiten(
id: string,
daten: { key: string; start: Date; ende: Date; notizen?: string | null },
) {
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (daten.ende <= daten.start) throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
// Je Host und Startdatum darf es nur eine Laufzeit geben – sonst entstünden zwei Lizenzjahre
// für denselben Zeitraum, und die Rechnungen ließen sich nicht mehr zuordnen.
const kollision = await prisma.laufzeit.findUnique({
where: { hostId_start: { hostId: laufzeit.hostId, start: daten.start } },
});
if (kollision && kollision.id !== id) {
throw new RechnungsFehler(
"Für dieses Startdatum gibt es an diesem Host schon eine Laufzeit. Beide lassen sich zusammenführen.",
);
}
const geaendert = await prisma.laufzeit.update({
where: { id },
data: {
key: daten.key,
start: daten.start,
ende: daten.ende,
notizen: daten.notizen ?? null,
// Von Hand geändert heißt: der Vorschlag ist angenommen.
status: "BESTAETIGT",
},
});
await protokolliere("laufzeit.geaendert", "Laufzeit", id, {
key: daten.key,
start: daten.start,
ende: daten.ende,
});
return geaendert;
}
/**
* Führt zwei Laufzeiten desselben Hosts zusammen (#043).
*
* Gedacht für den Fall, dass neben einer von Hand erfassten Laufzeit ein Vorschlag des Agenten
* steht – etwa weil das gemeldete Ablaufdatum einen Tag abweicht. Die Rechnungspositionen der
* aufgelösten Laufzeit wandern mit, sofern das Ziel dort noch nichts hat; andernfalls bricht der
* Vorgang ab. Zwei Einkaufsrechnungen auf einem Lizenzjahr wären nicht mehr zuzuordnen.
*/
export async function laufzeitenZusammenfuehren(zielId: string, quelleId: string) {
if (zielId === quelleId) throw new RechnungsFehler("Das ist dieselbe Laufzeit.");
return prisma.$transaction(async (tx) => {
const [ziel, quelle] = await Promise.all([
tx.laufzeit.findUnique({ where: { id: zielId } }),
tx.laufzeit.findUnique({ where: { id: quelleId } }),
]);
if (!ziel || !quelle) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (ziel.hostId !== quelle.hostId) {
throw new RechnungsFehler("Zusammenführen geht nur innerhalb desselben Hosts.");
}
if (ziel.proxmoxPositionId && quelle.proxmoxPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Proxmox-Rechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
if (ziel.kundenPositionId && quelle.kundenPositionId) {
throw new RechnungsFehler(
"Beide Laufzeiten haben eine Kundenrechnung. Bitte zuerst die falsche Rechnung entfernen.",
);
}
// Zuerst muss die Quelle ihre Positionen loslassen: Eine Position gehört zu genau einer
// Laufzeit, und die Datenbank wacht darüber. Erst danach darf das Ziel sie übernehmen.
await tx.laufzeit.update({
where: { id: quelleId },
data: { proxmoxPositionId: null, kundenPositionId: null },
});
await tx.laufzeit.update({
where: { id: zielId },
data: {
proxmoxPositionId: ziel.proxmoxPositionId ?? quelle.proxmoxPositionId,
kundenPositionId: ziel.kundenPositionId ?? quelle.kundenPositionId,
status: "BESTAETIGT",
notizen: [ziel.notizen, quelle.notizen].filter(Boolean).join(" · ") || null,
},
});
await tx.laufzeit.delete({ where: { id: quelleId } });
await protokolliere("laufzeit.zusammengefuehrt", "Laufzeit", zielId, {
quelle: quelleId,
quelleStart: quelle.start,
});
return ziel;
});
}