Compare commits

...
4 Commits
Author SHA1 Message Date
florian.wilde 81b2066d61 Merge branch 'phase/008-laufzeiten-rechnungen' 2026-09-17 21:54:45 +02:00
florian.wildeandClaude Opus 5 558dfea34c 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>
2026-09-17 21:54:45 +02:00
florian.wilde 1e1d6c4418 Merge branch 'phase/020b-gitea-token' 2026-09-17 21:44:01 +02:00
florian.wildeandClaude Opus 5 2dbb30041b feat(tooling): Gitea-Token aus den Git-Zugangsdaten lesen (#020)
Serveradresse und Repository stammen aus dem Remote, der Token aus dem
Schluesselbund bzw. ~/.git-credentials. Ein zweiter Token in der .env entfaellt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 21:44:01 +02:00
26 changed files with 2094 additions and 103 deletions
+3
View File
@@ -12,6 +12,9 @@ ADMIN_PASSWORD="bitte-aendern"
# Öffentlich erreichbare Adresse des Dashboards (für install.sh und E-Mails)
APP_URL="http://localhost:3000"
# Ablage für hochgeladene Rechnungs-PDFs
UPLOAD_DIR="./data/uploads"
# Demo-Daten beim Seed anlegen (1 = ja)
SEED_DEMO="0"
+18
View File
@@ -17,6 +17,24 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.5.0] – 2026-09-17
### Hinzugefügt
- Laufzeit-Vorschläge der Agents bestätigen oder verwerfen (#008)
- Proxmox- und Kundenrechnungen erfassen; gleiche Rechnungsnummer fasst mehrere Laufzeiten zu einer Sammelrechnung zusammen (#008)
- PDF-Upload für beide Rechnungsarten mit Prüfung des Dateiinhalts und Ablage unter der Prüfsumme (#008)
- Rechnungsübersicht mit Häkchen „bezahlt“ und „erstellt“ direkt in der Zeile, Filter nach Status, Kunde, Jahr und Suchbegriff (#008)
- Detailseite je Laufzeit mit Beträgen, Marge und Übersicht der Sammelrechnung (#008)
- Laufzeiten lassen sich von Hand anlegen (#008)
### Geändert
- Die Aufgabenliste auf dem Dashboard setzt beim Abhaken den Rechnungsstatus in der Datenbank (#008)
## [0.4.2] – 2026-09-17
### Geändert
- Der Issue-Abgleich holt Serveradresse und Repository aus dem Git-Remote und den Token aus den hinterlegten Git-Zugangsdaten; ein zweiter Token in der `.env` ist nicht mehr nötig (#020)
## [0.4.1] – 2026-09-17
### Hinzugefügt
+13 -5
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 2 h 48 min
**Gesamt:** 3 h 23 min
| Issue | Zeit |
|---|---|
@@ -13,7 +13,8 @@
| #005 Agent-Skript | 0 h 25 min |
| #006 Kunden und Hostzuweisung | 0 h 35 min |
| #007 Host-Tabelle und Detailansichten | 0 h 25 min |
| #020 Issues nach Gitea übertragen | 0 h 10 min |
| #020 Issues nach Gitea übertragen | 0 h 20 min |
| #008 Laufzeiten und Rechnungen | 0 h 35 min |
| Meilenstein | Zeit |
|---|---|
@@ -22,12 +23,14 @@
| v0.3.0 | 0 h 35 min | **abgeschlossen**
| v0.4.0 | 0 h 25 min | **abgeschlossen**
| v0.4.1 | 0 h 10 min | **abgeschlossen**
| v0.4.2 | 0 h 10 min | **abgeschlossen**
| v0.5.0 | 0 h 35 min | **abgeschlossen**
---
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.4.1
**Version:** 0.5.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -49,8 +52,14 @@
- #007 abgeschlossen: Host-Tabelle mit Suche, fünf Filtern, Sortierung, Seitenblättern und CSV-Export; Host-Detail mit sechs Tabs, Notizen, Tags, Kundenzuweisung und Token-Widerruf
- Meilenstein v0.4.0 abgeschlossen und getaggt; 59 Tests
- #020 abgeschlossen: Abgleichskript für den Gitea-Issue-Tracker (Missverständnis geklärt – die Issues lagen bisher nur als Dateien im Repo, nicht im Tracker)
- Token kommt aus den Git-Zugangsdaten statt aus einer zweiten Stelle; 20 Issues, 15 Labels und 13 Meilensteine in Gitea angelegt
- Alles nach Gitea gepusht: main bei 51243c2, Tags v0.1.0 bis v0.4.1
- #008 abgeschlossen: Laufzeit-Vorschläge bestätigen oder verwerfen, Rechnungen mit Positionen erfassen, PDF-Upload, Häkchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr; Aufgabenliste auf dem Dashboard setzt jetzt echte Status
- Meilenstein v0.5.0 abgeschlossen; 78 Tests
### Entscheidungen
- **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig.
- **PDF-Prüfung am Dateianfang** statt am gemeldeten Typ; abgelegt wird unter der Prüfsumme, gleiche Datei also nur einmal.
- **Screenshots mit Firefox headless** statt Playwright: Firefox ist vorhanden, es muss kein Browser samt Systembibliotheken nachinstalliert werden (dafür wäre root nötig).
- **Kennzahlen werden berechnet, nicht hinterlegt:** auch im Prototyp kommen alle Zahlen aus den Beispieldaten, damit Tabellen und Kacheln nie widersprüchlich sind.
- **AdminLTE 4.9.1 nur als CSS:** stabil, Bootstrap 5.3.8, kein jQuery; die JS-Plugins manipulieren das DOM und werden daher als React-Komponenten nachgebaut.
@@ -75,5 +84,4 @@
- Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen)
### Nächster Schritt
- Gitea-Token erzeugen und `npm run issues:sync` einmal echt laufen lassen
- #008 Laufzeiten und Rechnungen: Vorschläge bestätigen, Rechnungen erfassen, Häkchen direkt in der Tabelle
- #009 Kundenübersicht, Ablauf-Kalender und Jahresbahn auf Datenbankbasis
+8 -8
View File
@@ -52,16 +52,16 @@ jedem Klon mit. Wer sie zusätzlich im Gitea-Tracker haben möchte, gleicht sie
```bash
npm run issues:sync -- --dry-run # zeigt nur, was passieren würde
GITEA_URL=https://git.example.de \
GITEA_REPO=benutzer/ProxLedger \
GITEA_TOKEN=... \
npm run issues:sync
npm run issues:sync # echter Abgleich
```
Der Token braucht das Recht `issue:write` (Gitea: Einstellungen → Anwendungen → Zugriffstoken). Er kann auch in
`.env` stehen, die Datei ist von Git ausgenommen. Der Abgleich legt Labels und Meilensteine an, erkennt
vorhandene Issues an der Nummer im Titel, aktualisiert sie und schließt erledigte.
Serveradresse und Repository liest das Skript aus dem Git-Remote, den Token aus den hinterlegten
Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`) – derselbe Token wie fürs Pushen, er braucht
zusätzlich das Recht `issue:write`. Wer beides trennen möchte, setzt `GITEA_URL`, `GITEA_REPO` und
`GITEA_TOKEN` in der `.env`.
Der Abgleich legt Labels und Meilensteine an, erkennt vorhandene Issues an der Nummer im Titel,
aktualisiert sie und schließt erledigte. Ein zweiter Lauf erzeugt keine Dubletten.
## Dokumentation
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 149 KiB

+22 -13
View File
@@ -1,28 +1,37 @@
# #008 Laufzeiten und Rechnungen
**Status:** offen
**Status:** erledigt
**Meilenstein:** v0.5.0
**Labels:** ui, backend, buchhaltung
**Geschätzt:** 10 h **Tatsächlich:** –
**Geschätzt:** 10 h **Tatsächlich:** 0 h 35 min
## Ziel
Lizenzjahre mit Proxmox-Einkaufsrechnung und Kundenrechnung erfassen und den Status schnell pflegen.
## Aufgaben
- [ ] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen
- [ ] Laufzeit manuell anlegen/bearbeiten
- [ ] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload
- [ ] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload
- [ ] Marge pro Laufzeit, Kunde und Jahr berechnen
- [ ] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum
- [ ] Sidebar-Badge offene Rechnungspunkte
- [ ] Aufgaben-Card auf dem Dashboard mit echten Aktionen
- [x] Laufzeitvorschlag bei neuem `nextduedate` bestätigen/verwerfen
- [x] Laufzeit manuell anlegen/bearbeiten
- [x] Proxmox-Rechnung erfassen (auch Sammelrechnung über mehrere Laufzeiten), PDF-Upload
- [x] Kundenrechnung erfassen (auch Sammelrechnung), PDF-Upload
- [x] Marge pro Laufzeit, Kunde und Jahr berechnen
- [x] Rechnungsübersicht: alle Laufzeiten, Umschalter „Proxmox bezahlt“ / „Kundenrechnung erstellt“ direkt in der Zeile, Filter Offen/Erledigt/Alle, Kunde, Zeitraum
- [x] Sidebar-Badge offene Rechnungspunkte
- [x] Aufgaben-Card auf dem Dashboard mit echten Aktionen
## Akzeptanzkriterien
- [ ] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert
- [ ] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests)
- [ ] Nur PDF bis 10 MB wird angenommen
- [x] Umschalten in der Tabelle wirkt sofort und wird im Audit-Log protokolliert
- [x] Summen Einkauf/Verkauf/Marge stimmen mit Einzelwerten überein (Tests)
- [x] Nur PDF bis 10 MB wird angenommen
## Abhängigkeiten
#007
## Notizen / Entscheidungen
- **Sammelrechnungen ohne Extraschritt:** Wer bei einer zweiten Laufzeit dieselbe Rechnungsnummer einträgt,
hängt sie als weitere Position an die bestehende Rechnung. Der Rechnungsbetrag ist immer die Summe der
Positionen, wird also automatisch nachgeführt.
- Das Häkchen „Kundenrechnung erstellt“ legt bei fehlender Rechnung eine ohne Nummer an; Nummer und Betrag
lassen sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
- Das Erfassen einer Proxmox-Rechnung bestätigt einen Laufzeit-Vorschlag automatisch.
- PDFs werden unter ihrer Prüfsumme abgelegt (`UPLOAD_DIR`, Standard `./data/uploads`). Geprüft wird der
Dateianfang, nicht der gemeldete Typ – eine umbenannte Datei wird abgelehnt. Dieselbe Datei belegt nur
einmal Platz.
+6 -2
View File
@@ -27,5 +27,9 @@ aktuell bleiben – ohne dass sie von Hand nachgepflegt werden müssen.
## Notizen / Entscheidungen
- Die Markdown-Dateien bleiben die Quelle der Wahrheit: Sie liegen im Repo, sind ohne Netz lesbar und
wandern mit jedem Klon mit. Gitea bekommt eine Kopie zum Kommentieren und Zuweisen.
- Der erste echte Lauf steht noch aus: Dafür braucht es einen Gitea-Token mit `issue:write`.
Der Trockenlauf liest alle 20 Dateien korrekt.
- Der Token kommt aus den Git-Zugangsdaten (`git credential fill`), Adresse und Repo aus dem Remote.
Damit muss derselbe Token nicht zweimal gepflegt werden.
- Gefragt werden zuerst die eingerichteten Helfer (Schlüsselbund), danach zusätzlich `~/.git-credentials` –
je nach Rechner ist mal das eine, mal das andere gefüllt.
- Erster echter Lauf am 17.09.2026: 20 Issues, 15 Labels und 13 Meilensteine angelegt, erledigte Issues
direkt geschlossen. Zweiter Lauf: 0 neue, 20 aktualisiert – keine Dubletten.
+3 -3
View File
@@ -11,8 +11,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#005](005-agent-skript.md) | Agent-Skript | erledigt | v0.2.0 | 8 h | 0 h 25 min |
| [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min |
| [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 min |
| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | in Arbeit | v0.5.0 | 10 h | – |
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | offen | v0.6.0 | 8 h | – |
| [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min |
| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | in Arbeit | v0.6.0 | 8 h | – |
| [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – |
| [#011](011-warnregeln.md) | Warnregeln | offen | v0.7.0 | 8 h | – |
| [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – |
@@ -24,4 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – |
| [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – |
| [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min |
| | **Summe** | | | **114 h** | **2 h 48 min** |
| | **Summe** | | | **114 h** | **3 h 23 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"scripts": {
"dev": "next dev",
+56 -5
View File
@@ -10,6 +10,7 @@
* Die Markdown-Dateien bleiben die Quelle der Wahrheit; Gitea erhält eine Kopie zum Kommentieren.
*/
import { execFileSync } from "node:child_process";
import { readFile, readdir } from "node:fs/promises";
import "dotenv/config";
import path from "node:path";
@@ -138,6 +139,49 @@ class Gitea {
}
}
/**
* Ermittelt Serveradresse und Repo aus dem Git-Remote, damit beides nicht doppelt gepflegt werden muss.
* Beispiel: https://git.example.de/benutzer/ProxLedger.git → { basis, repo }
*/
function ausRemote(): { basis: string; repo: string } | null {
try {
const url = execFileSync("git", ["remote", "get-url", "origin"], { encoding: "utf8" }).trim();
const treffer = /^https?:\/\/(?:[^@/]*@)?([^/]+)\/(.+?)(?:\.git)?$/.exec(url);
if (!treffer) return null;
return { basis: `https://${treffer[1]}`, repo: treffer[2] };
} catch {
return null;
}
}
/**
* Holt den Token aus den Git-Zugangsdaten (Schlüsselbund oder `~/.git-credentials`).
* So genügt ein einziger Token für Push und API – er muss nicht zusätzlich in der .env stehen.
*/
function tokenAusGitCredentials(basis: string): string | null {
const host = new URL(basis).host;
// Erst die eingerichteten Helfer (z. B. Schlüsselbund), dann zusätzlich die Datei
// ~/.git-credentials – je nach Rechner ist mal das eine, mal das andere gefüllt.
for (const zusatz of [[], ["-c", "credential.helper=store"]]) {
try {
const antwort = execFileSync("git", [...zusatz, "credential", "fill"], {
input: `protocol=https\nhost=${host}\n\n`,
encoding: "utf8",
// Keine Rückfrage im Terminal: Wenn nichts hinterlegt ist, soll der Aufruf scheitern.
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
stdio: ["pipe", "pipe", "ignore"],
});
const passwort = /^password=(.*)$/m.exec(antwort);
if (passwort?.[1]) return passwort[1];
} catch {
// Nächste Möglichkeit versuchen.
}
}
return null;
}
/** Titel in Gitea, z. B. „#004 Agent-Skript“ – daran werden vorhandene Issues wiedererkannt. */
function giteaTitel(issue: IssueDatei): string {
return `#${String(issue.nummer).padStart(3, "0")} ${issue.titel}`;
@@ -145,9 +189,11 @@ function giteaTitel(issue: IssueDatei): string {
async function main() {
const trockenlauf = process.argv.includes("--dry-run");
const basis = (process.env.GITEA_URL ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? "";
const token = process.env.GITEA_TOKEN ?? "";
const remote = ausRemote();
const basis = (process.env.GITEA_URL ?? remote?.basis ?? "").replace(/\/$/, "");
const repo = process.env.GITEA_REPO ?? remote?.repo ?? "";
const token = process.env.GITEA_TOKEN || (basis ? (tokenAusGitCredentials(basis) ?? "") : "");
const tokenQuelle = process.env.GITEA_TOKEN ? "Umgebungsvariable" : "Git-Zugangsdaten";
const verzeichnis = path.join(process.cwd(), "docs", "issues");
const issues = await dateienLesen(verzeichnis);
@@ -155,7 +201,10 @@ async function main() {
if (trockenlauf || !basis || !repo || !token) {
if (!trockenlauf) {
console.log("\nGITEA_URL, GITEA_REPO oder GITEA_TOKEN fehlen – es läuft nur ein Trockenlauf.\n");
console.log(
"\nAdresse, Repo oder Token nicht ermittelbar – es läuft nur ein Trockenlauf.\n" +
"Entweder GITEA_TOKEN setzen oder einmal `git push` ausführen, damit der Token hinterlegt wird.\n",
);
}
for (const issue of issues) {
console.log(
@@ -165,11 +214,13 @@ async function main() {
);
}
console.log(
"\nEchter Abgleich: GITEA_URL, GITEA_REPO und GITEA_TOKEN setzen (Token mit Recht „issue:write“).",
`\nZiel wäre: ${basis || "?"}/${repo || "?"} – Token ${token ? "vorhanden" : "fehlt"}.` +
"\nDer Token wird aus den Git-Zugangsdaten gelesen; er braucht zusätzlich das Recht „issue:write“.",
);
return;
}
console.log(`Ziel: ${basis}/${repo} (Token aus: ${tokenQuelle})`);
const gitea = new Gitea(basis, repo, token);
const vorhandeneLabels = new Map((await gitea.labels()).map((l) => [l.name.toLowerCase(), l.id]));
+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>
</>
);
}
+211 -51
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]) => (
["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 });
}
}
@@ -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">
+53
View File
@@ -0,0 +1,53 @@
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { prisma } from "./db";
/** Rechnungs-PDFs liegen auf der Platte, nur die Metadaten in der Datenbank. */
export const UPLOAD_VERZEICHNIS = process.env.UPLOAD_DIR ?? path.join(process.cwd(), "data", "uploads");
export const MAX_GROESSE = 10 * 1024 * 1024;
export class UploadFehler extends Error {}
/**
* Nimmt ein PDF entgegen und legt es unter seiner Prüfsumme ab.
* Gleiche Datei zweimal hochgeladen heißt: derselbe Anhang, kein zweites Exemplar auf der Platte.
*/
export async function anhangSpeichern(datei: File): Promise<string> {
if (datei.size === 0) throw new UploadFehler("Die Datei ist leer.");
if (datei.size > MAX_GROESSE) {
throw new UploadFehler(`Die Datei ist größer als ${MAX_GROESSE / 1024 / 1024} MB.`);
}
const inhalt = Buffer.from(await datei.arrayBuffer());
// Nicht auf den gemeldeten Typ verlassen, sondern in die Datei sehen.
if (inhalt.subarray(0, 5).toString("latin1") !== "%PDF-") {
throw new UploadFehler("Nur PDF-Dateien sind erlaubt.");
}
const sha256 = createHash("sha256").update(inhalt).digest("hex");
const vorhanden = await prisma.anhang.findFirst({ where: { sha256 } });
if (vorhanden) return vorhanden.id;
await mkdir(UPLOAD_VERZEICHNIS, { recursive: true });
const dateiname = `${sha256}.pdf`;
await writeFile(path.join(UPLOAD_VERZEICHNIS, dateiname), inhalt, { mode: 0o640 });
const anhang = await prisma.anhang.create({
data: {
dateiname: datei.name || dateiname,
pfad: dateiname,
mimeTyp: "application/pdf",
groesse: inhalt.length,
sha256,
},
});
return anhang.id;
}
export function anhangPfad(pfad: string): string {
// Nur der Dateiname aus der Datenbank wird verwendet; Pfadangaben darin werden verworfen.
return path.join(UPLOAD_VERZEICHNIS, path.basename(pfad));
}
+168
View File
@@ -0,0 +1,168 @@
"use server";
import { revalidatePath } from "next/cache";
import { anhangSpeichern, UploadFehler } from "@/lib/uploads";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
RechnungsFehler,
verkaufBezahltSetzen,
verkaufErstelltSetzen,
} from "@/server/services/rechnungen";
import type { AktionsErgebnis } from "./kunden";
function datum(wert: FormDataEntryValue | null): Date | null {
const text = wert?.toString().trim();
return text ? new Date(`${text}T00:00:00Z`) : null;
}
function betrag(wert: FormDataEntryValue | null): number | null {
const text = wert?.toString().trim().replace(",", ".");
if (!text) return null;
const zahl = Number(text);
return Number.isFinite(zahl) ? zahl : null;
}
function seitenNeuLaden(hostId?: string | null) {
revalidatePath("/rechnungen");
revalidatePath("/");
revalidatePath("/kunden");
if (hostId) revalidatePath(`/hosts/${hostId}`);
}
/** Wandelt Fehler aus der Fachlogik in eine Rückmeldung für das Formular. */
async function ausfuehren(arbeit: () => Promise<string | void>): Promise<AktionsErgebnis> {
try {
const meldung = await arbeit();
return { erfolg: meldung || "Gespeichert." };
} catch (fehler) {
if (fehler instanceof RechnungsFehler || fehler instanceof UploadFehler) {
return { fehler: fehler.message };
}
throw fehler;
}
}
export async function laufzeitBestaetigenAktion(formular: FormData) {
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitBestaetigen(id);
seitenNeuLaden(formular.get("hostId")?.toString());
}
export async function laufzeitVerwerfenAktion(formular: FormData) {
const id = formular.get("laufzeitId")?.toString();
if (!id) return;
await laufzeitVerwerfen(id);
seitenNeuLaden(formular.get("hostId")?.toString());
}
/** Häkchen aus der Rechnungstabelle. */
export async function statusUmschalten(formular: FormData): Promise<void> {
const laufzeitId = formular.get("laufzeitId")?.toString();
const feld = formular.get("feld")?.toString();
const wert = formular.get("wert")?.toString() === "true";
if (!laufzeitId || !feld) return;
try {
if (feld === "einkaufBezahlt") await einkaufBezahltSetzen(laufzeitId, wert);
if (feld === "verkaufErstellt") await verkaufErstelltSetzen(laufzeitId, wert);
if (feld === "verkaufBezahlt") await verkaufBezahltSetzen(laufzeitId, wert);
} catch (fehler) {
if (!(fehler instanceof RechnungsFehler)) throw fehler;
// Fehlt die Rechnung, bleibt der Status unverändert; die Tabelle zeigt weiterhin „Fehlt“.
}
seitenNeuLaden(formular.get("hostId")?.toString());
}
export async function proxmoxRechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
return ausfuehren(async () => {
const pdf = formular.get("pdf");
const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null;
const rechnungsdatum = datum(formular.get("datum"));
if (!rechnungsdatum) throw new RechnungsFehler("Bitte ein Rechnungsdatum angeben.");
const netto = betrag(formular.get("nettoBetrag"));
if (netto === null) throw new RechnungsFehler("Bitte einen Nettobetrag angeben.");
const ergebnis = await proxmoxRechnungErfassen(laufzeitId, {
nummer: formular.get("nummer")?.toString() ?? "",
datum: rechnungsdatum,
faelligAm: datum(formular.get("faelligAm")),
nettoBetrag: netto,
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
rabatt: betrag(formular.get("rabatt")),
zahlungsart: formular.get("zahlungsart")?.toString().trim() || null,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
notizen: formular.get("notizen")?.toString().trim() || null,
});
seitenNeuLaden(formular.get("hostId")?.toString());
return ergebnis.sammelrechnung
? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung."
: "Proxmox-Rechnung gespeichert.";
});
}
export async function kundenrechnungSpeichern(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const laufzeitId = formular.get("laufzeitId")?.toString();
if (!laufzeitId) return { fehler: "Laufzeit fehlt." };
return ausfuehren(async () => {
const pdf = formular.get("pdf");
const anhangId = pdf instanceof File && pdf.size > 0 ? await anhangSpeichern(pdf) : null;
const ergebnis = await kundenrechnungErfassen(laufzeitId, {
erstellt: formular.get("erstellt") === "on",
nummer: formular.get("nummer")?.toString().trim() || null,
datum: datum(formular.get("datum")),
nettoBetrag: betrag(formular.get("nettoBetrag")),
ustSatz: betrag(formular.get("ustSatz")) ?? 19,
bezahlt: formular.get("bezahlt") === "on",
bezahltAm: datum(formular.get("bezahltAm")),
anhangId,
notizen: formular.get("notizen")?.toString().trim() || null,
});
seitenNeuLaden(formular.get("hostId")?.toString());
return ergebnis.sammelrechnung
? "Gespeichert – die Laufzeit hängt jetzt als weitere Position an dieser Sammelrechnung."
: "Kundenrechnung gespeichert.";
});
}
export async function laufzeitAnlegenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
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"));
if (!hostId || !key || !start || !ende) {
return { fehler: "Bitte Host, Key, Beginn und Ende angeben." };
}
return ausfuehren(async () => {
await laufzeitAnlegen({ hostId, key, start, ende, notizen: null });
seitenNeuLaden(hostId);
return "Laufzeit angelegt.";
});
}
+17 -3
View File
@@ -66,7 +66,19 @@ export async function offeneAufgaben() {
},
});
const aufgaben = [];
const aufgaben: {
id: string;
titel: string;
detail: string;
betrag: number | null;
faelligAm: Date | null;
dringlichkeit: "gefahr" | "warnung";
badgeText: string;
href: string;
start: Date;
ende: Date;
aktion: { laufzeitId: string; hostId: string; feld: "einkaufBezahlt" | "verkaufErstellt" };
}[] = [];
for (const laufzeit of laufzeiten) {
const basis = `${laufzeit.host.hostname} · ${laufzeit.host.kunde?.name ?? "Nicht zugeordnet"}`;
const einkauf = laufzeit.proxmoxPosition?.rechnung;
@@ -80,9 +92,10 @@ export async function offeneAufgaben() {
faelligAm: einkauf.faelligAm,
dringlichkeit: "gefahr" as const,
badgeText: "Einkauf",
href: `/hosts/${laufzeit.hostId}`,
href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start,
ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "einkaufBezahlt" as const },
});
}
@@ -95,9 +108,10 @@ export async function offeneAufgaben() {
faelligAm: null,
dringlichkeit: "warnung" as const,
badgeText: "Verkauf",
href: "/rechnungen",
href: `/rechnungen/${laufzeit.id}`,
start: laufzeit.start,
ende: laufzeit.ende,
aktion: { laufzeitId: laufzeit.id, hostId: laufzeit.hostId, feld: "verkaufErstellt" as const },
});
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
export interface RechnungsFilter {
status?: "offen" | "erledigt" | "alle" | "vorschlaege";
kunde?: string;
jahr?: string;
q?: string;
}
function bedingung(filter: RechnungsFilter): Prisma.LaufzeitWhereInput {
const und: Prisma.LaufzeitWhereInput[] = [];
if (filter.status === "vorschlaege") {
und.push({ status: "VORSCHLAG" });
} else {
und.push({ status: "BESTAETIGT" });
}
if (filter.status === "offen") {
und.push({
OR: [
{ proxmoxPosition: null },
{ proxmoxPosition: { rechnung: { bezahlt: false } } },
{ kundenPosition: null },
{ kundenPosition: { rechnung: { erstellt: false } } },
],
});
}
if (filter.status === "erledigt") {
und.push({ proxmoxPosition: { rechnung: { bezahlt: true } } });
und.push({ kundenPosition: { rechnung: { erstellt: true } } });
}
if (filter.kunde) und.push({ host: { kundeId: filter.kunde } });
if (filter.jahr) {
const jahr = Number(filter.jahr);
und.push({
start: { gte: new Date(Date.UTC(jahr, 0, 1)), lt: new Date(Date.UTC(jahr + 1, 0, 1)) },
});
}
if (filter.q?.trim()) {
const suche = filter.q.trim();
und.push({
OR: [
{ key: { contains: suche, mode: "insensitive" } },
{ host: { hostname: { contains: suche, mode: "insensitive" } } },
{ proxmoxPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
{ kundenPosition: { rechnung: { nummer: { contains: suche, mode: "insensitive" } } } },
],
});
}
return { AND: und };
}
export async function rechnungsListe(filter: RechnungsFilter) {
const laufzeiten = await prisma.laufzeit.findMany({
where: bedingung(filter),
orderBy: [{ start: "desc" }],
include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
});
const zeilen = laufzeiten.map((laufzeit) => {
const einkauf = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
return {
id: laufzeit.id,
hostId: laufzeit.host.id,
hostname: laufzeit.host.hostname,
kunde: laufzeit.host.kunde,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
vorschlag: laufzeit.status === "VORSCHLAG",
einkaufNummer: laufzeit.proxmoxPosition?.rechnung.nummer ?? null,
einkaufBetrag: einkauf,
einkaufBezahlt: laufzeit.proxmoxPosition ? laufzeit.proxmoxPosition.rechnung.bezahlt : null,
einkaufFaelligAm: laufzeit.proxmoxPosition?.rechnung.faelligAm ?? null,
verkaufNummer: laufzeit.kundenPosition?.rechnung.nummer ?? null,
verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
};
});
const summen = zeilen.reduce(
(summe, zeile) => ({
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
}),
{ einkauf: 0, verkauf: 0 },
);
return {
zeilen,
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
};
}
export type RechnungsZeile = Awaited<ReturnType<typeof rechnungsListe>>["zeilen"][number];
/** Jahre, für die Laufzeiten vorliegen – für den Zeitraum-Filter. */
export async function laufzeitJahre(): Promise<string[]> {
const laufzeiten = await prisma.laufzeit.findMany({ select: { start: true } });
return [...new Set(laufzeiten.map((l) => String(l.start.getUTCFullYear())))].sort((a, b) =>
b.localeCompare(a),
);
}
export async function laufzeitDetail(id: string) {
return prisma.laufzeit.findUnique({
where: { id },
include: {
host: {
select: {
id: true,
hostname: true,
produkt: true,
kunde: { select: { id: true, name: true } },
},
},
proxmoxPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
kundenPosition: {
include: {
rechnung: {
include: { anhang: true, positionen: { include: { laufzeit: { include: { host: true } } } } },
},
},
},
},
});
}
export async function anzahlVorschlaege() {
return prisma.laufzeit.count({ where: { status: "VORSCHLAG" } });
}
+312
View File
@@ -0,0 +1,312 @@
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
export class RechnungsFehler extends Error {}
export interface ProxmoxRechnungEingabe {
nummer: string;
datum: Date;
faelligAm: Date | null;
nettoBetrag: number;
ustSatz?: number;
rabatt?: number | null;
zahlungsart?: string | null;
bezahlt: boolean;
bezahltAm?: Date | null;
anhangId?: string | null;
notizen?: string | null;
}
export interface KundenrechnungEingabe {
erstellt: boolean;
nummer?: string | null;
datum?: Date | null;
nettoBetrag?: number | null;
ustSatz?: number;
bezahlt: boolean;
bezahltAm?: Date | null;
anhangId?: string | null;
notizen?: string | null;
}
/** Bestätigt einen vom Agent vorgeschlagenen Lizenzzeitraum. */
export async function laufzeitBestaetigen(id: string) {
const laufzeit = await prisma.laufzeit.update({
where: { id },
data: { status: "BESTAETIGT" },
});
await protokolliere("laufzeit.bestaetigt", "Laufzeit", id, {
start: laufzeit.start,
ende: laufzeit.ende,
});
return laufzeit;
}
/** Verwirft einen Vorschlag; bereits bestätigte Laufzeiten bleiben unangetastet. */
export async function laufzeitVerwerfen(id: string) {
const laufzeit = await prisma.laufzeit.findUnique({ where: { id } });
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (laufzeit.status !== "VORSCHLAG") {
throw new RechnungsFehler("Nur Vorschläge können verworfen werden.");
}
await prisma.laufzeit.delete({ where: { id } });
await protokolliere("laufzeit.verworfen", "Laufzeit", id, { start: laufzeit.start });
}
/**
* Erfasst die Proxmox-Rechnung zu einer Laufzeit.
* Gibt es die Rechnungsnummer schon, wird eine weitere Position angehängt – so entsteht aus mehreren
* Laufzeiten eine Sammelrechnung, ohne dass die Nummer doppelt vergeben wird.
*/
export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: ProxmoxRechnungEingabe) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { proxmoxPosition: true, host: { select: { hostname: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
const nummer = eingabe.nummer.trim();
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
return prisma.$transaction(async (tx) => {
const vorhanden = await tx.proxmoxRechnung.findUnique({ where: { nummer } });
const rechnung = vorhanden
? await tx.proxmoxRechnung.update({
where: { id: vorhanden.id },
data: {
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? 19,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? vorhanden.anhangId,
notizen: eingabe.notizen ?? vorhanden.notizen,
},
})
: await tx.proxmoxRechnung.create({
data: {
nummer,
datum: eingabe.datum,
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? 19,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,
notizen: eingabe.notizen ?? null,
},
});
const position = laufzeit.proxmoxPosition
? await tx.proxmoxRechnungPosition.update({
where: { id: laufzeit.proxmoxPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
})
: await tx.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
});
await tx.laufzeit.update({
where: { id: laufzeitId },
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
});
// Der Rechnungsbetrag ist immer die Summe der Positionen.
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
nummer,
laufzeitId,
nettoBetrag: eingabe.nettoBetrag,
positionen: positionen.length,
});
return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 };
});
}
/** Erfasst die Kundenrechnung zu einer Laufzeit; gleiche Nummer bedeutet auch hier Sammelrechnung. */
export async function kundenrechnungErfassen(laufzeitId: string, eingabe: KundenrechnungEingabe) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
const kundeId = laufzeit.host.kundeId;
if (!kundeId) {
throw new RechnungsFehler("Der Host ist keinem Kunden zugewiesen – ohne Kunde keine Kundenrechnung.");
}
const nummer = eingabe.nummer?.trim() || null;
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
const betrag = eingabe.nettoBetrag ?? 0;
return prisma.$transaction(async (tx) => {
const vorhanden = nummer ? await tx.kundenrechnung.findUnique({ where: { nummer } }) : null;
if (vorhanden && vorhanden.kundeId !== kundeId) {
throw new RechnungsFehler("Diese Rechnungsnummer gehört bereits zu einem anderen Kunden.");
}
const daten = {
erstellt: eingabe.erstellt,
nummer,
datum: eingabe.datum ?? null,
ustSatz: eingabe.ustSatz ?? 19,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
anhangId: eingabe.anhangId ?? null,
notizen: eingabe.notizen ?? null,
};
// Welche Rechnung ist gemeint? Zuerst die über die Nummer gefundene, sonst die bisher verknüpfte.
const zielRechnungId = vorhanden?.id ?? laufzeit.kundenPosition?.rechnungId ?? null;
const rechnung = zielRechnungId
? await tx.kundenrechnung.update({ where: { id: zielRechnungId }, data: daten })
: await tx.kundenrechnung.create({
data: { ...daten, kundeId, nettoBetrag: betrag },
});
const position = laufzeit.kundenPosition
? await tx.kundenrechnungPosition.update({
where: { id: laufzeit.kundenPosition.id },
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag },
})
: await tx.kundenrechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: betrag },
});
await tx.laufzeit.update({ where: { id: laufzeitId }, data: { kundenPositionId: position.id } });
const positionen = await tx.kundenrechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
await tx.kundenrechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
await protokolliere("kundenrechnung.erfasst", "Kundenrechnung", rechnung.id, {
nummer,
laufzeitId,
nettoBetrag: betrag,
positionen: positionen.length,
});
return { rechnungId: rechnung.id, positionId: position.id, sammelrechnung: positionen.length > 1 };
});
}
/** Häkchen „Proxmox bezahlt“ direkt aus der Tabelle. */
export async function einkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { proxmoxPosition: true },
});
if (!laufzeit?.proxmoxPosition) {
throw new RechnungsFehler("Für diese Laufzeit ist noch keine Proxmox-Rechnung erfasst.");
}
await prisma.proxmoxRechnung.update({
where: { id: laufzeit.proxmoxPosition.rechnungId },
data: { bezahlt, bezahltAm: bezahlt ? new Date() : null },
});
await protokolliere(
"proxmox_rechnung.bezahlt_gesetzt",
"ProxmoxRechnung",
laufzeit.proxmoxPosition.rechnungId,
{
bezahlt,
laufzeitId,
},
);
}
/**
* Häkchen „Kundenrechnung erstellt“ direkt aus der Tabelle.
* Fehlt die Rechnung noch, entsteht eine ohne Nummer – sie lässt sich später vervollständigen.
*/
export async function verkaufErstelltSetzen(laufzeitId: string, erstellt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true, host: { select: { hostname: true, kundeId: true } } },
});
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
if (laufzeit.kundenPosition) {
await prisma.kundenrechnung.update({
where: { id: laufzeit.kundenPosition.rechnungId },
data: { erstellt },
});
await protokolliere(
"kundenrechnung.erstellt_gesetzt",
"Kundenrechnung",
laufzeit.kundenPosition.rechnungId,
{
erstellt,
laufzeitId,
},
);
return;
}
if (!erstellt) return;
await kundenrechnungErfassen(laufzeitId, { erstellt: true, bezahlt: false, nettoBetrag: 0 });
}
/** Häkchen „Kundenrechnung bezahlt“. */
export async function verkaufBezahltSetzen(laufzeitId: string, bezahlt: boolean) {
const laufzeit = await prisma.laufzeit.findUnique({
where: { id: laufzeitId },
include: { kundenPosition: true },
});
if (!laufzeit?.kundenPosition) {
throw new RechnungsFehler("Für diese Laufzeit ist noch keine Kundenrechnung erfasst.");
}
await prisma.kundenrechnung.update({
where: { id: laufzeit.kundenPosition.rechnungId },
data: { bezahlt, bezahltAm: bezahlt ? new Date() : null },
});
await protokolliere(
"kundenrechnung.bezahlt_gesetzt",
"Kundenrechnung",
laufzeit.kundenPosition.rechnungId,
{
bezahlt,
laufzeitId,
},
);
}
/** Legt eine Laufzeit von Hand an – etwa für Käufe, die der Agent noch nicht gemeldet hat. */
export async function laufzeitAnlegen(daten: {
hostId: string;
key: string;
start: Date;
ende: Date;
notizen?: string | null;
}) {
const vorhanden = await prisma.laufzeit.findUnique({
where: { hostId_start: { hostId: daten.hostId, start: daten.start } },
});
if (vorhanden) {
throw new RechnungsFehler("Für dieses Startdatum gibt es bereits eine Laufzeit.");
}
if (daten.ende <= daten.start) {
throw new RechnungsFehler("Das Ende muss nach dem Beginn liegen.");
}
const laufzeit = await prisma.laufzeit.create({
data: { ...daten, status: "BESTAETIGT" },
});
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
return laufzeit;
}
+316
View File
@@ -0,0 +1,316 @@
import { afterAll, beforeEach, describe, expect, it } from "vitest";
import {
einkaufBezahltSetzen,
kundenrechnungErfassen,
laufzeitAnlegen,
laufzeitBestaetigen,
laufzeitVerwerfen,
proxmoxRechnungErfassen,
RechnungsFehler,
verkaufBezahltSetzen,
verkaufErstelltSetzen,
} from "@/server/services/rechnungen";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
async function aufbau() {
const kunde = await testPrisma.kunde.create({
data: { name: "Brandl Elektrotechnik GmbH", kundennummer: "K-1001" },
});
const hostA = await testPrisma.host.create({
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE", kundeId: kunde.id },
});
const hostB = await testPrisma.host.create({
data: { agentId: "a-2", hostname: "pve-02", produkt: "PVE", kundeId: kunde.id },
});
const laufzeitA = await testPrisma.laufzeit.create({
data: {
hostId: hostA.id,
key: "pve1c-aaaa",
start: new Date("2026-09-24"),
ende: new Date("2027-09-23"),
status: "VORSCHLAG",
},
});
const laufzeitB = await testPrisma.laufzeit.create({
data: {
hostId: hostB.id,
key: "pve2c-bbbb",
start: new Date("2026-10-08"),
ende: new Date("2027-10-07"),
status: "VORSCHLAG",
},
});
return { kunde, hostA, hostB, laufzeitA, laufzeitB };
}
const EINKAUF = {
nummer: "PX-2026-14790",
datum: new Date("2026-09-10"),
faelligAm: new Date("2026-09-24"),
nettoBetrag: 115,
bezahlt: false,
};
describe.runIf(datenbankVerfuegbar)("Laufzeiten und Rechnungen", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
describe("Laufzeiten", () => {
it("bestätigt einen Vorschlag", async () => {
const { laufzeitA } = await aufbau();
await laufzeitBestaetigen(laufzeitA.id);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: laufzeitA.id } });
expect(danach.status).toBe("BESTAETIGT");
});
it("verwirft einen Vorschlag, aber keine bestätigte Laufzeit", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await laufzeitVerwerfen(laufzeitA.id);
expect(await testPrisma.laufzeit.findUnique({ where: { id: laufzeitA.id } })).toBeNull();
await laufzeitBestaetigen(laufzeitB.id);
await expect(laufzeitVerwerfen(laufzeitB.id)).rejects.toThrow(RechnungsFehler);
});
it("legt eine Laufzeit von Hand an und verhindert doppelte Startdaten", async () => {
const { hostA } = await aufbau();
const daten = {
hostId: hostA.id,
key: "pve1c-manuell",
start: new Date("2028-01-01"),
ende: new Date("2028-12-31"),
};
await laufzeitAnlegen(daten);
await expect(laufzeitAnlegen(daten)).rejects.toThrow(RechnungsFehler);
await expect(
laufzeitAnlegen({ ...daten, start: new Date("2029-01-01"), ende: new Date("2028-12-31") }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Proxmox-Rechnung", () => {
it("erfasst eine Rechnung und bestätigt die Laufzeit dabei", async () => {
const { laufzeitA } = await aufbau();
const ergebnis = await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
expect(ergebnis.sammelrechnung).toBe(false);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { proxmoxPosition: { include: { rechnung: true } } },
});
expect(laufzeit.status).toBe("BESTAETIGT");
expect(laufzeit.proxmoxPosition?.rechnung.nummer).toBe("PX-2026-14790");
expect(Number(laufzeit.proxmoxPosition?.nettoBetrag)).toBe(115);
expect(laufzeit.proxmoxPosition?.rechnung.faelligAm?.toISOString().slice(0, 10)).toBe("2026-09-24");
});
it("hängt eine zweite Laufzeit an dieselbe Rechnungsnummer an (Sammelrechnung)", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
const zweite = await proxmoxRechnungErfassen(laufzeitB.id, { ...EINKAUF, nettoBetrag: 220 });
expect(zweite.sammelrechnung).toBe(true);
expect(await testPrisma.proxmoxRechnung.count()).toBe(1);
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
// Der Rechnungsbetrag ist die Summe der Positionen.
expect(Number(rechnung.nettoBetrag)).toBe(335);
});
it("ändert eine bereits erfasste Rechnung, statt eine zweite Position anzulegen", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nettoBetrag: 130, bezahlt: true });
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
where: { nummer: "PX-2026-14790" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(1);
expect(Number(rechnung.nettoBetrag)).toBe(130);
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
it("verlangt eine Rechnungsnummer", async () => {
const { laufzeitA } = await aufbau();
await expect(proxmoxRechnungErfassen(laufzeitA.id, { ...EINKAUF, nummer: " " })).rejects.toThrow(
RechnungsFehler,
);
});
});
describe("Kundenrechnung", () => {
it("erfasst eine Kundenrechnung mit Position", async () => {
const { laufzeitA, kunde } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0001",
datum: new Date("2026-10-01"),
nettoBetrag: 165,
bezahlt: false,
});
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0001" },
include: { positionen: true },
});
expect(rechnung.kundeId).toBe(kunde.id);
expect(rechnung.erstellt).toBe(true);
expect(Number(rechnung.nettoBetrag)).toBe(165);
expect(rechnung.positionen).toHaveLength(1);
});
it("fasst mehrere Laufzeiten desselben Kunden zu einer Rechnung zusammen", async () => {
const { laufzeitA, laufzeitB } = await aufbau();
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 165,
bezahlt: false,
});
const zweite = await kundenrechnungErfassen(laufzeitB.id, {
erstellt: true,
nummer: "RE-2026-0002",
nettoBetrag: 320,
bezahlt: false,
});
expect(zweite.sammelrechnung).toBe(true);
const rechnung = await testPrisma.kundenrechnung.findUniqueOrThrow({
where: { nummer: "RE-2026-0002" },
include: { positionen: true },
});
expect(rechnung.positionen).toHaveLength(2);
expect(Number(rechnung.nettoBetrag)).toBe(485);
});
it("weist eine Rechnungsnummer ab, die einem anderen Kunden gehört", async () => {
const { laufzeitA } = await aufbau();
const fremderKunde = await testPrisma.kunde.create({
data: { name: "Huber Logistik AG", kundennummer: "K-1002" },
});
const fremderHost = await testPrisma.host.create({
data: { agentId: "a-3", hostname: "pve-huber", produkt: "PVE", kundeId: fremderKunde.id },
});
const fremdeLaufzeit = await testPrisma.laufzeit.create({
data: {
hostId: fremderHost.id,
key: "pve1c-cccc",
start: new Date("2026-11-01"),
ende: new Date("2027-10-31"),
status: "BESTAETIGT",
},
});
await kundenrechnungErfassen(fremdeLaufzeit.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 100,
bezahlt: false,
});
await expect(
kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0003",
nettoBetrag: 165,
bezahlt: false,
}),
).rejects.toThrow(RechnungsFehler);
});
it("lehnt eine Kundenrechnung für einen Host ohne Kunde ab", async () => {
const host = await testPrisma.host.create({
data: { agentId: "a-ohne", hostname: "pve-ohne-kunde", produkt: "PVE" },
});
const laufzeit = await testPrisma.laufzeit.create({
data: {
hostId: host.id,
key: "pve1c-dddd",
start: new Date("2026-09-01"),
ende: new Date("2027-08-31"),
status: "BESTAETIGT",
},
});
await expect(
kundenrechnungErfassen(laufzeit.id, { erstellt: true, bezahlt: false, nettoBetrag: 10 }),
).rejects.toThrow(RechnungsFehler);
});
});
describe("Häkchen in der Tabelle", () => {
it("setzt „Proxmox bezahlt“ samt Bezahldatum und nimmt es zurück", async () => {
const { laufzeitA } = await aufbau();
await proxmoxRechnungErfassen(laufzeitA.id, EINKAUF);
await einkaufBezahltSetzen(laufzeitA.id, true);
let rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
await einkaufBezahltSetzen(laufzeitA.id, false);
rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: EINKAUF.nummer } });
expect(rechnung.bezahlt).toBe(false);
expect(rechnung.bezahltAm).toBeNull();
});
it("meldet sich, wenn noch keine Proxmox-Rechnung erfasst ist", async () => {
const { laufzeitA } = await aufbau();
await expect(einkaufBezahltSetzen(laufzeitA.id, true)).rejects.toThrow(RechnungsFehler);
});
it("legt beim Häkchen „Kundenrechnung erstellt“ eine Rechnung ohne Nummer an", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
const laufzeit = await testPrisma.laufzeit.findUniqueOrThrow({
where: { id: laufzeitA.id },
include: { kundenPosition: { include: { rechnung: true } } },
});
expect(laufzeit.kundenPosition?.rechnung.erstellt).toBe(true);
expect(laufzeit.kundenPosition?.rechnung.nummer).toBeNull();
// Die Nummer lässt sich später nachtragen, ohne dass eine zweite Rechnung entsteht.
await kundenrechnungErfassen(laufzeitA.id, {
erstellt: true,
nummer: "RE-2026-0009",
nettoBetrag: 165,
bezahlt: false,
});
expect(await testPrisma.kundenrechnung.count()).toBe(1);
const nachgetragen = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(nachgetragen.nummer).toBe("RE-2026-0009");
expect(Number(nachgetragen.nettoBetrag)).toBe(165);
});
it("setzt „Kundenrechnung bezahlt“", async () => {
const { laufzeitA } = await aufbau();
await verkaufErstelltSetzen(laufzeitA.id, true);
await verkaufBezahltSetzen(laufzeitA.id, true);
const rechnung = await testPrisma.kundenrechnung.findFirstOrThrow();
expect(rechnung.bezahlt).toBe(true);
expect(rechnung.bezahltAm).not.toBeNull();
});
});
});
+67
View File
@@ -0,0 +1,67 @@
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
let verzeichnis: string;
beforeAll(() => {
verzeichnis = mkdtempSync(path.join(tmpdir(), "proxledger-uploads-"));
process.env.UPLOAD_DIR = verzeichnis;
});
afterAll(() => {
rmSync(verzeichnis, { recursive: true, force: true });
});
/** Kleinstmögliche gültige PDF-Datei. */
function pdf(inhalt = "Testrechnung"): File {
return new File([`%PDF-1.4\n${inhalt}\n%%EOF\n`], "rechnung.pdf", { type: "application/pdf" });
}
describe.runIf(datenbankVerfuegbar)("PDF-Upload", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("nimmt ein PDF an und legt Datei samt Datensatz an", async () => {
const { anhangSpeichern } = await import("@/lib/uploads");
const id = await anhangSpeichern(pdf());
const anhang = await testPrisma.anhang.findUniqueOrThrow({ where: { id } });
expect(anhang.mimeTyp).toBe("application/pdf");
expect(anhang.dateiname).toBe("rechnung.pdf");
expect(anhang.sha256).toHaveLength(64);
expect(readdirSync(verzeichnis)).toContain(`${anhang.sha256}.pdf`);
});
it("speichert dieselbe Datei nicht zweimal", async () => {
const { anhangSpeichern } = await import("@/lib/uploads");
const erste = await anhangSpeichern(pdf());
const zweite = await anhangSpeichern(pdf());
expect(zweite).toBe(erste);
expect(await testPrisma.anhang.count()).toBe(1);
});
it("lehnt Dateien ab, die kein PDF sind – auch bei passendem Dateinamen", async () => {
const { anhangSpeichern, UploadFehler } = await import("@/lib/uploads");
const getarnt = new File(["<?php system($_GET[0]); ?>"], "rechnung.pdf", { type: "application/pdf" });
await expect(anhangSpeichern(getarnt)).rejects.toThrow(UploadFehler);
expect(await testPrisma.anhang.count()).toBe(0);
});
it("lehnt leere und zu große Dateien ab", async () => {
const { anhangSpeichern, MAX_GROESSE, UploadFehler } = await import("@/lib/uploads");
await expect(anhangSpeichern(new File([], "leer.pdf"))).rejects.toThrow(UploadFehler);
const zuGross = new File(["%PDF-1.4\n" + "x".repeat(MAX_GROESSE)], "gross.pdf");
await expect(anhangSpeichern(zuGross)).rejects.toThrow(UploadFehler);
});
});