Compare commits

..
11 Commits
Author SHA1 Message Date
florian.wilde 0e48f1e3fa Merge branch 'phase/046-next-sicherheit'
Prüfung / pruefung (push) Waiting to run
2026-10-23 13:56:43 +02:00
florian.wildeandClaude Opus 5 476ecb3be1 fix(deps): Next.js 16.4.2 wegen zweier kritischer Luecken
Fuer Next.js 16.0.0 bis 16.4.0 wurden drei Luecken gemeldet:

- KRITISCH: Remote Code Execution ueber die Bildoptimierung mit SVG-Dateien
  (GHSA-2464-fhr4-w9xw)
- KRITISCH: Remote Code Execution in `use cache` (GHSA-673q-mjm6-r5xr)
- HOCH: Blockade des Dienstes ueber Server Actions (GHSA-p3m5-59mp-jv5r)

Geprueft, was ProxLedger davon trifft: next/image und `use cache` werden
nicht genutzt, Server Actions dagegen ueberall - die Blockade war also
unmittelbar wirksam. Wer oeffentlich erreichbar ist, sollte sofort
aktualisieren.

Tests, Typen, Lint und Build gruen, npm audit ohne Befund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-23 13:56:43 +02:00
florian.wilde 173d6d2e61 Merge branch 'phase/045-wiederherstellung'
Prüfung / pruefung (push) Canceled after 0s
2026-10-10 19:31:35 +02:00
florian.wildeandClaude Opus 5 409a02561d feat(deploy): Wiederherstellung proben und Skript dafuer (#045)
Einer der vier Punkte bis 1.0.0: eine Sicherung einmal wirklich
zurueckspielen. Mit einem eigenen Stapel durchgespielt - Datenstand
festgehalten, Marke gesetzt, PDF abgelegt, gesichert, alle Volumes
zerstoert, zurueckgespielt. Datenbank, PDF und Anmeldung waren zurueck.

Zwei Luecken kamen erst dabei heraus:

1. Ohne --clean liess sich die Sicherung nur in eine LEERE Datenbank
   zurueckspielen - im Ernstfall also gerade nicht. Jetzt
   pg_dump --clean --if-exists, gegen eine Datenbank mit Daten geprueft.
2. Fuer die hochgeladenen PDFs gab es keinen beschriebenen Weg zurueck.

deploy/restore.sh haelt die Dienste an, spielt Datenbank und PDFs zurueck,
startet wieder und prueft die Erreichbarkeit. Die .env bleibt unangetastet:
Sie traegt APP_SECRET, und ein falscher Wert dort macht gespeicherte
Zugangsdaten unlesbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:31:35 +02:00
florian.wilde 8119aaad66 Merge branch 'phase/045-abhaengigkeiten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-10 19:01:22 +02:00
florian.wildeandClaude Opus 5 ee36f78cc3 fix(deps): sharp und source-map-js wegen hoher Meldungen anheben
Turnusmaessiger Blick auf npm audit: Fuer sharp vor 0.35.5 wurde eine als
hoch eingestufte Luecke in der mitgelieferten librsvg gemeldet
(CVE-2026-96889); source-map-js erlaubt in Fassungen vor 1.2.2 eine
Blockade der Ereignisschleife und kommt ueber postcss und vite mit.

sharp nutzt ProxLedger nur beim Erzeugen der Symbole, nicht zur Laufzeit -
angreifbar war die Installation also nicht. Aktualisiert gehoert es
trotzdem. Dazu Next.js auf 16.4.0.

Tests, Typen, Lint und Build gruen, npm audit ohne Befund.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-10 19:01:22 +02:00
florian.wildeandClaude Opus 5 38e674de34 docs(roadmap): Reihenfolge nach dem Produktivgang neu ordnen
Prüfung / pruefung (push) Canceled after 0s
Die erste Fassung entstand vor dem Einsatz. Was seitdem aus der Benutzung kam,
war fast immer wichtiger als das, was vorher auf der Liste stand - deshalb
stehen jetzt Rechnungsimport, Verlaengerung mit einem Klick und
Oberflaechentests oben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 08:18:17 +02:00
florian.wilde b745910afa Merge branch 'phase/044-eigenbedarf'
Prüfung / pruefung (push) Canceled after 0s
2026-10-04 06:54:43 +02:00
florian.wildeandClaude Opus 5 840e902b26 feat(kunden): Eigenbedarf fuer eigene Hosts (#044)
Eigene Hosts brauchen einen Kunden - sonst stehen sie unter "Nicht
zugeordnet" und fehlen in jeder Auswertung. Als gewoehnlicher Kunde erzeugen
sie aber Aufgaben, die es nicht gibt: Kundenrechnung erstellen, Kundenrechnung
unbezahlt, dazu eine negative Marge in jeder Summe.

Das Kennzeichen Eigenbedarf am Kunden nimmt die Verkaufsseite heraus: kein
Formular fuer die Kundenrechnung, keine Warnungen darueber, keine Aufgabe,
keine Marge - und in den Summen zaehlt der Einkauf mit, Verkauf und Marge
nicht.

Die Einkaufsseite bleibt vollstaendig: Eigene Hosts kosten echtes Geld, und
die Rechnung von Proxmox will bezahlt werden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-04 06:54:43 +02:00
florian.wilde 5e5028c53d Merge branch 'phase/043-kunden-laufzeiten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 20:57:07 +02:00
florian.wildeandClaude Opus 5 83320f0443 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>
2026-10-03 20:57:07 +02:00
43 changed files with 1493 additions and 269 deletions
+38
View File
@@ -17,6 +17,44 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [0.27.1] – 2026-10-23
### Sicherheit
- **Next.js auf 16.4.2.** Für 16.0.0 bis 16.4.0 wurden drei Lücken gemeldet, zwei davon **kritisch**: Remote Code Execution über die Bildoptimierung mit SVG-Dateien (GHSA-2464-fhr4-w9xw), Remote Code Execution in `use cache` (GHSA-673q-mjm6-r5xr) und eine Blockade über Server Actions (GHSA-p3m5-59mp-jv5r)
- ProxLedger nutzt weder `next/image` noch `use cache`; die Blockade über Server Actions betraf die Installation dagegen unmittelbar. **Wer öffentlich erreichbar ist, sollte sofort aktualisieren.**
## [0.27.0] – 2026-10-10
### Hinzugefügt
- `deploy/restore.sh`: spielt Datenbank **und** hochgeladene PDFs aus einer Sicherung zurück, startet die Dienste und prüft die Erreichbarkeit (#045)
### Behoben
- **Sicherungen ließen sich nur in eine leere Datenbank zurückspielen** – im Ernstfall also gerade nicht. `pg_dump` läuft jetzt mit `--clean --if-exists` (#045)
- Für die hochgeladenen PDFs gab es keinen beschriebenen Weg zurück (#045)
### Dokumentation
- `docs/betrieb/sicherung.md` beschreibt den **geprobten** Ablauf: Daten zerstört, zurückgespielt, geprüft (#045)
## [0.26.1] – 2026-10-10
### Sicherheit
- `sharp` auf 0.35.5 angehoben: Für ältere Fassungen wurde eine als hoch eingestufte Lücke in der mitgelieferten librsvg gemeldet (CVE-2026-96889). ProxLedger nutzt `sharp` nur beim Erzeugen der Symbole, nicht zur Laufzeit – die Fassung gehört trotzdem aktualisiert
- `source-map-js` über `overrides` auf 1.2.2 angehoben (Denial of Service, kam über postcss und vite mit)
- Next.js auf 16.4.0
## [0.26.0] – 2026-10-04
### Hinzugefügt
- Kennzeichen **Eigenbedarf** am Kunden: für eigene Hosts entfallen Kundenrechnung, die zugehörigen Warnungen, die Aufgabe „Kundenrechnung erstellen“ und die Marge – die Proxmox-Rechnung wird weiterhin geführt (#044)
## [0.25.0] – 2026-10-03
### Hinzugefügt
- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043)
- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043)
- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043)
- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043)
## [0.24.1] – 2026-10-03
### Behoben
+23 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 22 h 53 min
**Gesamt:** 25 h 28 min
| Issue | Zeit |
|---|---|
@@ -48,6 +48,9 @@
| #040 Schlagworte sichtbar | 0 h 40 min |
| #041 Doppelte Hosts | 0 h 50 min |
| #042 QDevice im Cluster | 0 h 20 min |
| #043 Laufzeiten bearbeiten | 1 h 05 min |
| #044 Eigenbedarf | 0 h 40 min |
| #045 Wiederherstellung geprobt | 0 h 50 min |
| Meilenstein | Zeit |
|---|---|
@@ -70,7 +73,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.24.1
**Version:** 0.27.1
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -216,6 +219,24 @@
- QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice
- Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen
- Laufzeiten (#043): Anlegen gab es nur als Server-Aktion ohne Oberfläche, Ändern und Zusammenführen gar nicht. Dabei tritt der Doppelfall zwangsläufig auf, sobald das gemeldete Ablaufdatum von der Bestellung abweicht
- Beim Zusammenführen muss die Quelle ihre Rechnungsposition zuerst loslassen – die Datenbank lässt eine Position nur an einer Laufzeit zu. Der Test hat die falsche Reihenfolge sofort gezeigt
- Eigenbedarf (#044): Eigene Hosts brauchen einen Kunden, sonst fehlen sie in jeder Auswertung – als gewöhnlicher Kunde erzeugen sie aber Aufgaben, die es nicht gibt, und eine negative Marge
- Die Einkaufsseite bleibt vollständig: Eigene Hosts kosten echtes Geld
### 10.10.2026 – Wartungsblick
- `npm audit` turnusmäßig geprüft: zwei neue hohe Meldungen (`sharp`/librsvg, `source-map-js`). Beide angehoben, Tests und Build grün
- Produktivinstanz antwortet: `https://proxledger.wilde-ste.de/api/gesundheit` meldet Datenbank erreichbar
- Wiederherstellung geprobt (#045): Daten zerstört, zurückgespielt, geprüft. Zwei Lücken kamen erst dabei heraus – ohne `--clean` ließ sich nur in eine leere Datenbank zurückspielen, und für die PDFs gab es gar keinen Weg
- Damit ist einer der vier Punkte bis 1.0.0 erledigt. Offen bleiben PMG, PBS mit Lizenz und ein vollständiger Lizenzjahr-Durchlauf (17.10.)
### 23.10.2026 – Wartungsblick
- `npm audit`: drei neue Meldungen zu Next.js ≤ 16.4.0, zwei davon kritisch (RCE über Bildoptimierung und `use cache`, dazu eine Blockade über Server Actions). Auf 16.4.2 angehoben, alles grün
- Geprüft, was davon ProxLedger trifft: `next/image` und `use cache` werden nicht genutzt, Server Actions dagegen überall – die Blockade war also echt
- Erinnerung an mich: Zwei Wartungsblicke in Folge haben hohe bis kritische Meldungen gefunden. Der wöchentliche Lauf in der CI ist überfällig
### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+3 -2
View File
@@ -30,7 +30,8 @@ echo "Sichere ProxLedger (${ART}) nach ${ZIEL} …"
if [ "$ART" = "docker" ]; then
echo "Sichere Datenbank …"
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
docker compose exec -T postgres pg_dump --clean --if-exists \
-U "${POSTGRES_USER:-proxledger}" "${POSTGRES_DB:-proxledger}" \
| gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
@@ -48,7 +49,7 @@ else
}
echo "Sichere Datenbank …"
pg_dump "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
pg_dump --clean --if-exists "$DATABASE_URL" | gzip > "${ZIEL}/datenbank-${STAND}.sql.gz"
echo "Sichere hochgeladene Dateien …"
UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# ProxLedger aus einer Sicherung wiederherstellen (#046).
#
# bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
# bash deploy/restore.sh /var/backups/proxledger 20261010-192437
# bash deploy/restore.sh … --ja # ohne Rückfrage
#
# Spielt Datenbank und hochgeladene PDFs zurück. Die `.env` wird **nicht** angefasst:
# Sie trägt APP_SECRET, und ein falscher Wert dort macht gespeicherte Zugangsdaten unlesbar.
# Die passende Fassung liegt als env-<Stand>.txt in der Sicherung – im Zweifel von Hand vergleichen.
set -o errexit
set -o nounset
set -o pipefail
VERZEICHNIS=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$VERZEICHNIS"
blau() { printf '\033[1;36m› %s\033[0m\n' "$*"; }
gruen() { printf '\033[1;32m✓ %s\033[0m\n' "$*"; }
fehler() {
printf '\033[1;31m✗ %s\033[0m\n' "$*" >&2
exit 1
}
QUELLE="${1:-/var/backups/proxledger}"
STAND="${2:-}"
[ "${2:-}" = "--ja" ] && STAND=""
OHNE_RUECKFRAGE="nein"
for argument in "$@"; do [ "$argument" = "--ja" ] && OHNE_RUECKFRAGE="ja"; done
[ -d "$QUELLE" ] || fehler "Kein Sicherungsordner unter ${QUELLE}."
# Jüngste Sicherung nehmen, wenn kein Stand angegeben ist.
if [ -z "$STAND" ]; then
DATENBANK=$(ls -1t "${QUELLE}"/datenbank-*.sql.gz 2>/dev/null | head -n1 || true)
else
DATENBANK="${QUELLE}/datenbank-${STAND}.sql.gz"
fi
[ -n "${DATENBANK:-}" ] && [ -f "$DATENBANK" ] || fehler "Keine Datenbanksicherung in ${QUELLE} gefunden."
UPLOADS="${DATENBANK/datenbank-/uploads-}"
UPLOADS="${UPLOADS/.sql.gz/.tar.gz}"
# Siehe deploy/update.sh – der Dienst ist das eindeutigere Merkmal.
if systemctl cat proxledger.service >/dev/null 2>&1; then
ART="dienst"
else
ART="docker"
fi
printf '\033[1;33m! Achtung: Der aktuelle Datenbestand wird durch die Sicherung ersetzt.\033[0m\n'
echo " Installationsart: ${ART}"
echo " Datenbank: $(basename "$DATENBANK")"
echo " Uploads: $([ -f "$UPLOADS" ] && basename "$UPLOADS" || echo 'keine in dieser Sicherung')"
if [ "$OHNE_RUECKFRAGE" != "ja" ]; then
read -r -p "Wirklich zurückspielen? [tippen Sie ja] " antwort
[ "$antwort" = "ja" ] || fehler "Abgebrochen."
fi
# ---------------------------------------------------------------- Datenbank
blau "Datenbank zurückspielen …"
if [ "$ART" = "docker" ]; then
# Web und Worker anhalten: Sie würden während des Einspielens weiterschreiben.
docker compose stop web worker >/dev/null 2>&1 || true
docker compose up -d postgres >/dev/null
# Kurz warten, bis Postgres Verbindungen annimmt.
for _ in $(seq 1 30); do
docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-proxledger}" >/dev/null 2>&1 && break
sleep 1
done
gunzip -c "$DATENBANK" | docker compose exec -T postgres psql -U "${POSTGRES_USER:-proxledger}" \
-d "${POSTGRES_DB:-proxledger}" >/dev/null
else
systemctl stop proxledger proxledger-worker >/dev/null 2>&1 || true
# shellcheck disable=SC1091
set -o allexport
. ./.env
set +o allexport
[ -n "${DATABASE_URL:-}" ] || fehler "DATABASE_URL fehlt in der .env."
gunzip -c "$DATENBANK" | psql "$DATABASE_URL" >/dev/null
fi
gruen "Datenbank zurückgespielt."
# ---------------------------------------------------------------- Uploads
if [ -f "$UPLOADS" ]; then
blau "Hochgeladene PDFs zurückspielen …"
if [ "$ART" = "docker" ]; then
# Das Archiv enthält den Pfad /data/uploads – deshalb nach / entpacken.
docker compose run --rm -T --entrypoint tar web -xzf - -C / < "$UPLOADS"
else
ZIEL_UPLOADS="${UPLOAD_DIR:-${VERZEICHNIS}/data/uploads}"
mkdir -p "$(dirname "$ZIEL_UPLOADS")"
tar -xzf "$UPLOADS" -C "$(dirname "$ZIEL_UPLOADS")"
fi
gruen "PDFs zurückgespielt."
fi
# ---------------------------------------------------------------- Starten und prüfen
blau "Dienste starten …"
if [ "$ART" = "docker" ]; then
docker compose up -d >/dev/null
else
systemctl start proxledger proxledger-worker
fi
PORT="${PORT:-3000}"
for versuch in $(seq 1 45); do
if [ "$ART" = "docker" ]; then
erreichbar=$(docker compose exec -T web wget -qO- "http://127.0.0.1:3000/api/gesundheit" >/dev/null 2>&1 && echo ja || echo nein)
else
erreichbar=$(curl -fsS -o /dev/null --max-time 2 "http://localhost:${PORT}/api/gesundheit" && echo ja || echo nein)
fi
if [ "$erreichbar" = "ja" ]; then
gruen "ProxLedger läuft wieder (nach ${versuch} s)."
echo
echo "Bitte jetzt anmelden und stichprobenartig prüfen, ob die Daten vollständig sind."
echo "Passt das Passwort nicht, stammt die Sicherung aus einer Zeit vor der letzten Änderung –"
echo "dann hilft: npm run benutzer -- passwort <email>"
exit 0
fi
sleep 1
done
fehler "Das Dashboard antwortet nicht. Protokoll ansehen: docker compose logs -n 50 web"
+26
View File
@@ -237,6 +237,16 @@ mit Marge. Die vier Kästen oben fassen Hosts, Einkauf, Verkauf und Marge zusamm
Standorte sind optional und lohnen sich bei Kunden mit mehreren Filialen: Sie erscheinen in der
Host-Tabelle und lassen sich bei der Zuweisung mit angeben.
### Eigene Hosts
Auch die eigenen Hosts gehören einem Kunden zugeordnet – sonst fehlen sie in jeder Auswertung und
lösen die Warnung „Host ohne Kunde“ aus. Lege dafür dich selbst als Kunden an und setze im
Kundenformular den Haken **Eigenbedarf**.
Für solche Kunden entfällt alles, was mit Weiterberechnung zu tun hat: keine Kundenrechnung, keine
Warnungen darüber, keine Aufgabe „Kundenrechnung erstellen“ und keine Marge. Die Proxmox-Rechnung
wird weiterhin geführt und erinnert ans Bezahlen – die Kosten fallen ja an.
---
## 6. Laufzeiten und Rechnungen
@@ -279,6 +289,22 @@ Positionen und wird automatisch nachgeführt.
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
### Woher Laufzeiten kommen
Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege:
1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von
diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge*
bestätigst oder verwirfst du ihn.
2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein
Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr
nachgetragen wird.
Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende
und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das
gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit
gibt ihre Rechnungen an die verbleibende ab.
### Der übliche Ablauf
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

After

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 170 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+41 -9
View File
@@ -27,19 +27,51 @@ ruft es als Erstes auf und bricht ab, wenn es fehlschlägt.
## Zurückspielen
```bash
# Docker
docker compose down
docker compose up -d postgres
gunzip -c sicherung.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
bash deploy/restore.sh /var/backups/proxledger # jüngste Sicherung
bash deploy/restore.sh /var/backups/proxledger 20261010-192437 # bestimmter Stand
```
Das Skript hält Dashboard und Worker an, spielt Datenbank **und** hochgeladene PDFs zurück, startet
alles wieder und prüft, ob das Dashboard antwortet. Vorher fragt es nach; `--ja` überspringt die
Rückfrage (für Skripte).
Die `.env` fasst es bewusst **nicht** an: Sie trägt `APP_SECRET`, und ein falscher Wert dort macht die
gespeicherten Zugangsdaten für SMTP und Webhook unlesbar. Die zum Stand passende Fassung liegt als
`env-<Stand>.txt` in der Sicherung – im Zweifel von Hand vergleichen.
### Von Hand, wenn es sein muss
```bash
docker compose stop web worker
gunzip -c datenbank-20261010-192437.sql.gz | docker compose exec -T postgres psql -U proxledger proxledger
docker compose run --rm -T --entrypoint tar web -xzf - -C / < uploads-20261010-192437.tar.gz
docker compose up -d
```
Danach die `.env` mit **demselben** `APP_SECRET` zurücklegen wie zum Zeitpunkt der Sicherung. Ein
anderer Wert lässt die gespeicherten Zugangsdaten unlesbar werden; das Dashboard sagt das beim
nächsten Versand deutlich, aber die Werte müssen dann neu eingetragen werden.
Bei der Installation als Dienst stattdessen `psql "$DATABASE_URL"` und `tar -xzf … -C /opt/proxledger/data`.
## Geprobt am 10.10.2026
Nicht behauptet, sondern durchgespielt – mit einem eigenen Stapel:
1. Stand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), ein Kunde zur Wiedererkennung umbenannt,
eine PDF-Datei abgelegt
2. Sicherung angelegt
3. **Alle Daten zerstört** (`docker compose down -v` – Datenbank und Upload-Volume weg)
4. `deploy/restore.sh` aufgerufen
Ergebnis: Datenbank vollständig zurück (die umbenannte Kundin war wieder da), die PDF-Datei lag mit
den richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
Dabei zwei Lücken gefunden und geschlossen: Die Sicherung ließ sich nur in eine **leere** Datenbank
zurückspielen (jetzt `pg_dump --clean --if-exists`), und für die hochgeladenen PDFs gab es überhaupt
keinen beschriebenen Weg.
> Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
> vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
## Das Wichtigste
**Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung.** Spielen Sie sie einmal in eine
leere Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und
`APP_SECRET` zusammenpassen.
Testumgebung ein und melden Sie sich dort an. Erst dann wissen Sie, dass Sicherung und `APP_SECRET`
zusammenpassen.
+42
View File
@@ -0,0 +1,42 @@
# #043 Laufzeiten anlegen, bearbeiten und zusammenführen
**Status:** erledigt
**Meilenstein:** v0.25.0
**Labels:** backend, ui
**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min
## Auslöser
Drei Rückmeldungen aus dem Einsatz:
1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“
2. „Wie werden so Laufzeiten erfasst?“
3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu
verknüpfen oder zu vereinen.“
## Ausgangslage
Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein
Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder
verwerfen war möglich – mehr nicht:
- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**.
- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch.
- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten
Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht.
- Aus der **Kundenansicht** führte kein Weg in die Laufzeit.
## Behebung
- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen,
das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag).
- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den
Vorschlag zugleich.
- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre
Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der
Vorgang mit einer Erklärung ab.
- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei.
## Entscheidungen
- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr
wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis.
- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die
Meldung aufs Zusammenführen.
- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört
zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen.
+38
View File
@@ -0,0 +1,38 @@
# #044 Eigene Hosts ohne Weiterberechnung
**Status:** erledigt
**Meilenstein:** v0.26.0
**Labels:** backend, ui, alerts
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
„Ich habe mich auch selbst als Kunden angelegt, da ich ja auch Hosts habe. Aber mir selbst schreibe
ich ja keine E-Mails.“
## Das Problem
Die eigenen Hosts brauchen einen Kunden – sonst stehen sie unter *Nicht zugeordnet* und lösen die
Warnung „Host ohne Kunde“ aus. Als gewöhnlicher Kunde erzeugen sie aber lauter Aufgaben, die es nicht
gibt: „Kundenrechnung erstellen“, „Kundenrechnung unbezahlt“, dazu eine negative Marge in jeder
Summe, weil dem Einkauf kein Verkauf gegenübersteht.
## Behebung
Kennzeichen **Eigenbedarf** am Kunden. Für dessen Hosts gilt:
| | Verhalten |
|---|---|
| Proxmox-Rechnung | unverändert – die Kosten fallen ja an |
| Kundenrechnung | entfällt; das Formular wird durch eine Erklärung ersetzt |
| Warnungen „Kundenrechnung fehlt / unbezahlt“ | entfallen |
| Warnung „Kunde hat bezahlt – Einkauf offen“ | entfällt |
| Aufgabe „Kundenrechnung erstellen“ | entfällt |
| Marge | bleibt leer statt negativ |
| Summen | Einkauf zählt mit, Verkauf und Marge nicht |
Dazu ein Abzeichen in der Kundenliste und auf der Laufzeit.
## Entscheidung
Die Einkaufsseite bleibt vollständig erhalten. Eigene Hosts kosten echtes Geld, und die Rechnung von
Proxmox will bezahlt werden – nur die Weiterberechnung entfällt.
Die Alternative wäre gewesen, eigene Hosts ohne Kunden zu lassen und die Warnung „Host ohne Kunde“
abzuschalten. Dann fehlten sie aber in jeder Auswertung nach Kunde, und der Einkauf ließe sich nicht
mehr zuordnen.
@@ -0,0 +1,40 @@
# #045 Wiederherstellung proben und Skript dafür
**Status:** erledigt
**Meilenstein:** v0.27.0
**Labels:** deployment, doku
**Geschätzt:** 2 h **Tatsächlich:** 0 h 50 min
## Ziel
Einer der vier Punkte bis 1.0.0: eine Sicherung **einmal wirklich zurückspielen**. Bis dahin war nur
geprüft, dass `deploy/backup.sh` Dateien schreibt.
## Ablauf der Probe
Mit einem eigenen Docker-Stapel, getrennt von allen anderen Daten:
1. Datenstand festgehalten (9 Hosts, 4 Kunden, 10 Laufzeiten), einen Kunden zur Wiedererkennung in
`Marke-192641` umbenannt, eine PDF-Datei in den Upload-Ordner gelegt
2. Sicherung angelegt
3. **Alle Daten zerstört** – `docker compose down -v`, also Datenbank- und Upload-Volume weg
4. Zurückgespielt und geprüft
**Ergebnis:** Datenbank vollständig zurück (die Marke war wieder da), die PDF-Datei lag mit den
richtigen Rechten im Upload-Ordner, Anmeldung mit dem alten Passwort funktionierte.
## Zwei Lücken, die erst die Probe gezeigt hat
1. **Die Sicherung ließ sich nur in eine leere Datenbank zurückspielen.** `pg_dump` ohne `--clean`
scheitert an bestehenden Tabellen – genau im Ernstfall, wenn man in eine laufende Installation
zurückspielen will. Jetzt `--clean --if-exists`; gegengeprüft gegen eine Datenbank mit Daten:
null Fehlerzeilen, der zwischenzeitlich geänderte Wert war wieder auf dem Stand der Sicherung.
2. **Für die hochgeladenen PDFs gab es keinen beschriebenen Weg.** Gesichert wurden sie, zurückspielen
musste man selbst herausfinden.
## Ergebnis
- `deploy/restore.sh`: hält die Dienste an, spielt Datenbank und PDFs zurück, startet wieder und prüft
die Erreichbarkeit. Fragt vorher nach, `--ja` überspringt das.
- Die `.env` wird bewusst **nicht** überschrieben: Sie trägt `APP_SECRET`, und ein falscher Wert dort
macht die gespeicherten Zugangsdaten unlesbar. Die passende Fassung liegt als `env-<Stand>.txt` daneben.
- `docs/betrieb/sicherung.md` beschreibt den geprobten Ablauf samt Handarbeit als Rückfallebene.
## Offen
Geprobt wurde der Docker-Weg. Für die Installation als Dienst ist derselbe Ablauf im Skript
vorgesehen, aber noch nicht unter echten Bedingungen gelaufen.
+4 -1
View File
@@ -46,4 +46,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
| [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min |
| [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min |
| | **Summe** | | | **169 h** | **22 h 53 min** |
| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min |
| [#044](044-eigenbedarf.md) | Eigene Hosts ohne Weiterberechnung | erledigt | v0.26.0 | 2 h | 0 h 40 min |
| [#045](045-wiederherstellung-geprobt.md) | Wiederherstellung proben und Skript dafür | erledigt | v0.27.0 | 2 h | 0 h 50 min |
| | **Summe** | | | **177 h** | **25 h 28 min** |
+98 -53
View File
@@ -1,72 +1,117 @@
# Roadmap
Stand: 19.09.2026, Fassung 0.17.0
Stand: 10.10.2026, Fassung 0.27.0
## Was steht
Die erste Fassung dieser Roadmap entstand vor dem produktiven Einsatz. Seitdem läuft ProxLedger mit
echten Daten, und das hat die Reihenfolge deutlich verändert: Was aus der Benutzung kam, war fast
immer wichtiger als das, was vorher auf der Liste stand.
Alles, was im ursprünglichen Auftrag stand, ist gebaut und in Betrieb erprobt:
## Was seit dem Produktivgang dazukam
- Agent für PVE, PBS und PMG; Push-Modell über systemd-Timer, Zwischenspeicher bei Ausfall
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
- Anmeldung lokal und per OIDC, Audit-Log, verschlüsselte Zugangsdaten, Härtung nach OWASP
- Auslieferung als Docker-Stack und als LXC-Container (eigener Installer und Helper-Script-Weg)
- Handbuch mit Bildern, 190 Tests, CI bei jedem Push
Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
## Was bis 1.0.0 fehlt
Ebenso die Fehler, die erst der Einsatz gezeigt hat: QDevice-Spalte im Cluster, PBS-Rahmentabelle,
Weiterleitungen auf die Lausch-Adresse, Standort beim Zuweisen gelöscht, Einkauf ohne Rabatt an drei
Stellen.
Nichts davon ist eine Baustelle im Code – es fehlt **Erprobung** und eine Handvoll Kanten:
---
| Punkt | Warum es zählt | Aufwand |
## Bis 1.0.0
Kein neuer Funktionsumfang – es fehlt Erprobung.
| Punkt | Warum | Aufwand |
|---|---|---|
| **Mail Gateway im Feldtest** (#023) | Der einzige Produktzweig, den nie ein echter Host bestätigt hat. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet – bei PMG kann es genauso sein. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
| **Ein Monat Dauerbetrieb** | Timer, Nachsendung, nächtliches Aufräumen und die Zusammenfassung laufen erst seit einem Tag. Ein Lizenzablauf in echt wäre der beste Beweis. | Zeit, keine Arbeit |
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
| **PMG im Feldtest** ([#023](issues/023-feldtest-pbs-pmg.md)) | Der einzige Produktzweig ohne echten Host. Bei PBS lag die Annahme daneben, und der Agent hätte dort nichts gemeldet. | 1 h, sobald ein Host da ist |
| **PBS mit aktiver Lizenz** | Bisher nur „keine Subscription“ geprüft. | 30 min |
| ~~Sicherung einmal zurückspielen~~ | **Erledigt am 10.10.2026** ([#045](issues/045-wiederherstellung-geprobt.md)) – dabei kamen zwei Lücken heraus. Offen bleibt die Probe für die Installation als Dienst. | – |
| **Ein Lizenzjahr durchlaufen lassen** | Warnung → Verlängerung → Einkauf → Kundenrechnung → bezahlt, einmal vollständig in echt. Die nächsten Abläufe stehen am 17.10.2026 an. | nur Zeit |
Danach ist 1.0.0 gerechtfertigt. Meine Empfehlung: erst laufen lassen, dann das Etikett vergeben.
---
## Nach 1.0 – Vorschläge, nach Nutzen sortiert
## Danach – nach Nutzen sortiert
### Lohnt sich am ehesten
### 1. Proxmox-Rechnung einlesen statt abtippen
1. **Verlängerung mit einem Klick.** Heute wird eine Laufzeit bestätigt und die Rechnung von Hand
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
2. **Rechnungsnummern automatisch.** Ein Schema wie `PL-{jahr}-{nummer}` mit fortlaufendem Zähler;
heute tippt man jede Nummer. (In #025 bewusst weggelassen, weil es eine Vergabelogik braucht.)
3. **Mehrere Benutzer mit Rollen.** Derzeit darf jeder Angemeldete alles – in `docs/sicherheit.md`
offen vermerkt. Für Buchhaltung (nur Rechnungen) und Technik (nur Hosts) wären zwei Rollen genug.
Dazu gehört eine Benutzerverwaltung in der Oberfläche statt auf der Kommandozeille.
4. **Währung und Zeitzone auch in der Browseransicht.** Beides steht schon in den Einstellungen, wirkt
aber nur in PDF, CSV und Benachrichtigungen; die Oberfläche rechnet fest in Euro.
Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
### Sinnvoll, wenn es wächst
**Aufwand:** mittel (PDF-Text auslesen, Zeilen erkennen, Zuordnung bestätigen lassen).
**Voraussetzung:** zwei, drei echte Rechnungen als Vorlage.
5. **Kundenportal oder Bericht per E-Mail.** Ein monatlicher Stand je Kunde, automatisch verschickt –
spart Rückfragen.
6. **Angebot vor der Verlängerung.** Aus der Jahresbahn heraus ein PDF-Angebot für das kommende
Lizenzjahr erzeugen.
7. **Import der Proxmox-Shop-Rechnungen.** Wer dort per Sammelrechnung kauft, tippt heute jede Position
ab. Ein CSV- oder PDF-Import mit Zuordnung über den Key wäre eine echte Ersparnis.
8. **Metriken für Prometheus** (`/metrics`): Hosts je Zustand, Tage bis zum nächsten Ablauf, Alter des
letzten Reports. Wer schon Grafana betreibt, sieht ProxLedger damit im gewohnten Bild.
9. **Zwei-Faktor-Anmeldung** für den lokalen Zugang. Heute lautet die Antwort „dann nimm OIDC“ – das
ist vertretbar, aber nicht für jeden.
### 2. Verlängerung mit einem Klick
### Nur falls der Bedarf entsteht
Heute wird eine Laufzeit bestätigt und die Folgelaufzeit von Hand angelegt. Ein Knopf „verlängern“,
der die nächste Laufzeit anlegt, den Einkauf vormerkt und die Kundenrechnung mit dem eingestellten
Aufschlag vorbereitet, nimmt bei jedem Lizenzjahr vier Schritte ab.
10. **Mandantenfähigkeit.** Mehrere Systemhäuser in einer Installation. Das ist ein Umbau, kein
Anbau – die Zugriffskontrolle müsste durchgängig auf Mandanten umgestellt werden.
11. **Agent für weitere Produkte** (Proxmox Datacenter Manager), sobald der Bedarf da ist.
12. **Hochverfügbarkeit.** Die Ratenbremse liegt im Prozessspeicher; für mehrere Instanzen müsste sie
in die Datenbank wandern. Bei einem Dashboard für ein Systemhaus ist das Aufwand ohne Gewinn.
**Aufwand:** klein bis mittel.
## Was ich bewusst nicht vorschlage
### 3. Kundenrechnung als Dokument
- **Pull statt Push.** Das Dashboard würde dann Zugänge zu allen Kundenhosts brauchen. Der jetzige Weg
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
ein IdP – dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt heute mit Bash und curl aus und läuft damit auf
jedem Proxmox-Host, auch auf einem alten.
ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
**Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
### 4. Oberflächentests mit echtem Browser
Die letzten sechs Fehler hat keiner der 251 Tests gefunden – alle kamen aus der Benutzung: Menü ließ
sich nicht schließen, Seite zoomte heraus, kein Weg zur Laufzeit, falscher Betrag in einer Tabelle.
Ein knappes Dutzend Abläufe mit Playwright (anmelden, Host zuweisen, Rechnung erfassen, auf dem
Telefon navigieren) würde genau diese Klasse abdecken.
**Aufwand:** mittel. **Nutzen:** steigt mit jeder weiteren Änderung.
### 5. Verlauf statt einer Notiz
Die Notiz am Host ist ein Textfeld – was wann passiert ist, verschwindet darin. Datierte Einträge
(„17.10.2026 · Sockets von 1 auf 2 erhöht“) wären bei Wechseln und Hardwaretausch nützlich.
**Aufwand:** klein.
### 6. Ablauf-Kalender abonnieren
Ein ICS-Feed mit allen Ablaufdaten, den man in Outlook oder Nextcloud einbindet. Dann stehen die
Verlängerungen im gewohnten Kalender, nicht nur im Dashboard.
**Aufwand:** klein.
### 7. Kundenzugang wirklich nutzen
Die Rolle *Kunde* existiert samt eingeschränkter Sicht, aber es gibt keinen Weg, jemanden einzuladen:
kein Einladungslink, keine erste Anmeldung ohne Passwort. Ohne das bleibt die Rolle Theorie.
**Aufwand:** klein bis mittel.
### 8. Metriken für Prometheus
`/metrics` mit Hosts je Zustand, Tagen bis zum nächsten Ablauf und Alter des letzten Reports. Wer
Grafana betreibt, sieht ProxLedger damit im gewohnten Bild – und merkt auch, wenn das Dashboard
selbst steht.
**Aufwand:** klein.
---
## Später, falls der Bedarf entsteht
- **Mandantenfähigkeit** – mehrere Systemhäuser in einer Installation. Ein Umbau, kein Anbau.
- **Agent für Proxmox Datacenter Manager**, sobald es dort Subscriptions gibt, die das rechtfertigen.
- **Mehrere Instanzen** (Hochverfügbarkeit). Die Ratenbremse liegt im Prozessspeicher und müsste in
die Datenbank wandern. Für ein Dashboard eines Systemhauses Aufwand ohne Gewinn.
- **Angebot vor der Verlängerung** als PDF aus der Jahresbahn heraus.
## Bewusst nicht
- **Pull statt Push.** Das Dashboard bräuchte Zugang zu allen Kundenhosts. Der jetzige Weg kommt ohne
gespeicherte root-Zugänge aus – ein Sicherheitsgewinn, den man nicht aufgeben sollte.
- **Eigene Rechteverwaltung mit Gruppen und Vererbung.** Wer das braucht, hat ein IdP; dafür gibt es OIDC.
- **Agent mit Abhängigkeiten** (Python, jq). Er kommt mit Bash und curl aus und läuft damit auf jedem
Proxmox-Host.
- **Hosts automatisch zusammenführen.** Beim Doppeleintrag müsste geraten werden, welcher Eintrag
welche Rechnung erbt. Die Warnung nennt beide; entscheiden soll der Mensch.
+175 -175
View File
@@ -1,12 +1,12 @@
{
"name": "proxledger",
"version": "0.19.1",
"version": "0.27.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "proxledger",
"version": "0.19.1",
"version": "0.27.0",
"hasInstallScript": true,
"dependencies": {
"@node-rs/argon2": "^2.2.1",
@@ -20,7 +20,7 @@
"bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"jose": "^6.2.12",
"next": "^16.3.8",
"next": "^16.4.2",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8",
@@ -35,11 +35,11 @@
"dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9",
"eslint-config-next": "^16.3.8",
"eslint-config-next": "^16.4.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7",
"prisma": "^7.10.0",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.1"
@@ -1029,9 +1029,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
@@ -1047,13 +1047,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.3"
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
@@ -1069,20 +1069,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.3"
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -1092,9 +1092,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
@@ -1108,9 +1108,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
@@ -1124,9 +1124,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
@@ -1143,9 +1143,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
@@ -1162,9 +1162,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
@@ -1181,9 +1181,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
@@ -1200,9 +1200,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
@@ -1219,9 +1219,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
@@ -1238,9 +1238,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
@@ -1257,9 +1257,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
@@ -1276,9 +1276,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
@@ -1297,13 +1297,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.3"
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
@@ -1322,13 +1322,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.3"
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
@@ -1347,13 +1347,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.3"
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
@@ -1372,13 +1372,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.3"
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
@@ -1397,13 +1397,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.3"
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
@@ -1422,13 +1422,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.3"
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
@@ -1447,13 +1447,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
@@ -1472,13 +1472,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
@@ -1492,16 +1492,16 @@
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.4"
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
@@ -1511,9 +1511,9 @@
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
@@ -1530,9 +1530,9 @@
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
@@ -1549,9 +1549,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
@@ -1646,15 +1646,15 @@
}
},
"node_modules/@next/env": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.2.tgz",
"integrity": "sha512-nTUTFH7G5/J/fmpkjVLegOkqyKOP0CEsJcMsyO4UCYeN1su+YMW/MfaSErnJskL7XM+UDK6aTvslxsRCyya/sA==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
"integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.2.tgz",
"integrity": "sha512-gCTWlw0e+Y6kajpFyKKgA195jUJCRhlzJE0lV7ufe8a+ghIsANWZ0/FiJwg6DuOG1+OvRbcZDcFBoRh3mNJx0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1695,9 +1695,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.2.tgz",
"integrity": "sha512-ONkhFvZNFWlsEHOM/81AtxdyuJP2KRYKYR0JxPwvcu1AuPMMMgcL+wW2luldJvMtZwKtUxoSkCychW5wIeoYSw==",
"cpu": [
"arm64"
],
@@ -1711,9 +1711,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.2.tgz",
"integrity": "sha512-luPCMbOsCoaF+kXJEUjmjmOZg1lC3BflhmUu+ZmW4tBu2vW/4AXak8JBFvePizGkxVmNiAw/r9ra+6UviCWtWQ==",
"cpu": [
"x64"
],
@@ -1727,9 +1727,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.2.tgz",
"integrity": "sha512-xhwFTAncJyaCNy5KqKKuKaIIdCy1/1ZV1s5S1G0hhT7wGMkDTlfJ6aQbfRFnIG/s3/kDrxQ8aycVe+c5QnP8ow==",
"cpu": [
"arm64"
],
@@ -1746,9 +1746,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.2.tgz",
"integrity": "sha512-T1PNmfbPcxr5ndtWUL/3wQtLXsz808lq6sc2GrNT5k2oquF7UyqZddHSuAewHVsWsGXe6nsl9HoN1YNriVjuSg==",
"cpu": [
"arm64"
],
@@ -1765,9 +1765,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.2.tgz",
"integrity": "sha512-N9cDUOCGtkJG5PNKDFZUF3xXndrJJcpLp121uAGjIGvO/U2p9tfdNOM9htj7+5f72l0m8rMw/cvYacUJBQoudg==",
"cpu": [
"x64"
],
@@ -1784,9 +1784,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.2.tgz",
"integrity": "sha512-yY2uh1YhX6ufuYy2FCbw1dqblbhI57GLYAJfCkqD9auTv6pLrH67Uccjc3QMKBeCYAG6lkgu08ExmX1bbjejjQ==",
"cpu": [
"x64"
],
@@ -1803,9 +1803,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.2.tgz",
"integrity": "sha512-j/ArWRIHpMMB6wYLMhr2puTguBt70lNU/Cmjl9p6dTDE8DGknYJMP9PdwPk2dqpCNL/KIX9yfngDvIesxJPmsg==",
"cpu": [
"arm64"
],
@@ -1819,9 +1819,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.2.tgz",
"integrity": "sha512-AA4edyWHDko3NCSo0x6GomsdgFb0oR1J3MXU1b5xt6FRFg4AucrMPW3S0hCe/9onU0O1BbpGQBsqo1r0Gneu1g==",
"cpu": [
"x64"
],
@@ -5889,21 +5889,21 @@
}
},
"node_modules/eslint-config-next": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
"integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.2.tgz",
"integrity": "sha512-P33vjcETMXc+qBjpF77TeJHZLzk6cPcTy8KjpxLucYB3mCIFIORMcZ+cBXcAiIYjE5RM7jyknEEOwMNdnx/ADQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@next/eslint-plugin-next": "16.3.8",
"@next/eslint-plugin-next": "16.4.2",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsx-a11y": "^6.10.0",
"eslint-plugin-react": "^7.37.0",
"eslint-plugin-react-hooks": "^7.0.0",
"eslint-plugin-react-hooks": "^7.1.0",
"globals": "16.4.0",
"typescript-eslint": "^8.46.0"
"typescript-eslint": "^8.56.0"
},
"peerDependencies": {
"eslint": ">=9.0.0",
@@ -8184,12 +8184,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "16.3.8",
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
"version": "16.4.2",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.2.tgz",
"integrity": "sha512-r+oBJojfefUjOiWkBhXgLS+igxX/7BWyK59uHnyOyplMSLtBZ+QLP3okrdYTbrfrbrmNTVWLBPaHDX9KRaIt3w==",
"license": "MIT",
"dependencies": {
"@next/env": "16.3.8",
"@next/env": "16.4.2",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -8203,15 +8203,15 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.3.8",
"@next/swc-darwin-x64": "16.3.8",
"@next/swc-linux-arm64-gnu": "16.3.8",
"@next/swc-linux-arm64-musl": "16.3.8",
"@next/swc-linux-x64-gnu": "16.3.8",
"@next/swc-linux-x64-musl": "16.3.8",
"@next/swc-win32-arm64-msvc": "16.3.8",
"@next/swc-win32-x64-msvc": "16.3.8",
"sharp": "^0.35.4"
"@next/swc-darwin-arm64": "16.4.2",
"@next/swc-darwin-x64": "16.4.2",
"@next/swc-linux-arm64-gnu": "16.4.2",
"@next/swc-linux-arm64-musl": "16.4.2",
"@next/swc-linux-x64-gnu": "16.4.2",
"@next/swc-linux-x64-musl": "16.4.2",
"@next/swc-win32-arm64-msvc": "16.4.2",
"@next/swc-win32-x64-msvc": "16.4.2",
"sharp": "^0.35.5"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
@@ -9422,9 +9422,9 @@
}
},
"node_modules/sharp": {
"version": "0.35.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
@@ -9439,31 +9439,31 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.4",
"@img/sharp-darwin-x64": "0.35.4",
"@img/sharp-freebsd-wasm32": "0.35.4",
"@img/sharp-libvips-darwin-arm64": "1.3.3",
"@img/sharp-libvips-darwin-x64": "1.3.3",
"@img/sharp-libvips-linux-arm": "1.3.3",
"@img/sharp-libvips-linux-arm64": "1.3.3",
"@img/sharp-libvips-linux-ppc64": "1.3.3",
"@img/sharp-libvips-linux-riscv64": "1.3.3",
"@img/sharp-libvips-linux-s390x": "1.3.3",
"@img/sharp-libvips-linux-x64": "1.3.3",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
"@img/sharp-linux-arm": "0.35.4",
"@img/sharp-linux-arm64": "0.35.4",
"@img/sharp-linux-ppc64": "0.35.4",
"@img/sharp-linux-riscv64": "0.35.4",
"@img/sharp-linux-s390x": "0.35.4",
"@img/sharp-linux-x64": "0.35.4",
"@img/sharp-linuxmusl-arm64": "0.35.4",
"@img/sharp-linuxmusl-x64": "0.35.4",
"@img/sharp-webcontainers-wasm32": "0.35.4",
"@img/sharp-win32-arm64": "0.35.4",
"@img/sharp-win32-ia32": "0.35.4",
"@img/sharp-win32-x64": "0.35.4"
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
@@ -9604,9 +9604,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.24.1",
"version": "0.27.1",
"private": true,
"scripts": {
"dev": "next dev",
@@ -41,7 +41,7 @@
"bootstrap-icons": "^1.13.1",
"croner": "^10.0.1",
"jose": "^6.2.12",
"next": "^16.3.8",
"next": "^16.4.2",
"nodemailer": "^10.0.10",
"openid-client": "^6.8.8",
"react": "19.2.8",
@@ -56,11 +56,11 @@
"dotenv": "^17.4.2",
"esbuild": "^0.28.2",
"eslint": "^9",
"eslint-config-next": "^16.3.8",
"eslint-config-next": "^16.4.2",
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.9.7",
"prisma": "^7.10.0",
"sharp": "^0.35.4",
"sharp": "^0.35.5",
"tsx": "^4.23.13",
"typescript": "^5",
"vitest": "^5.0.1"
@@ -71,6 +71,7 @@
},
"overrides": {
"mysql2": "^3.24.4",
"deepmerge-ts": "^8.0.0"
"deepmerge-ts": "^8.0.0",
"source-map-js": "^1.2.2"
}
}
@@ -0,0 +1,3 @@
-- Eigenbedarf: Kunden, die man selbst ist (#044).
-- Für sie gibt es keine Kundenrechnung – und damit auch keine Warnungen darüber und keine Marge.
ALTER TABLE "Kunde" ADD COLUMN "eigenbedarf" BOOLEAN NOT NULL DEFAULT false;
+3
View File
@@ -61,6 +61,9 @@ model Kunde {
ansprechpartner String?
email String?
telefon String?
/// Eigenbedarf: eigene Hosts, die nicht weiterberechnet werden. Für sie entfallen
/// Kundenrechnung, die zugehörigen Warnungen und die Marge (#044).
eigenbedarf Boolean @default(false)
notizen String?
erstelltAm DateTime @default(now())
geaendertAm DateTime @updatedAt
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
ansprechpartner: kunde.ansprechpartner,
email: kunde.email,
telefon: kunde.telefon,
eigenbedarf: kunde.eigenbedarf,
notizen: kunde.notizen,
}}
/>
+42 -8
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>
{/* 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>
+3
View File
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name}
</Link>
{kunde.eigenbedarf ? (
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
) : null}
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td>
<td className="pl-mono" data-label="Kundennummer">
+49 -2
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,11 +35,27 @@ 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);
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
return (
@@ -127,6 +145,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 ? (
@@ -192,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<Card
titel="Kundenrechnung (Verkauf)"
rahmen={verkauf?.erstellt ? "info" : "warning"}
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
werkzeuge={
eigenbedarf ? (
<span className="badge pl-bg-neutral">Eigenbedarf</span>
) : (
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
)
}
>
{eigenbedarf ? (
<p className="mb-0 text-body-secondary">
<strong>{laufzeit.host.kunde?.name}</strong> ist als Eigenbedarf gekennzeichnet – diese
Hosts werden nicht weiterberechnet. Deshalb gibt es hier keine Kundenrechnung, keine
Warnung darüber und keine Marge. Die Proxmox-Rechnung wird weiterhin geführt.
</p>
) : (
<KundenrechnungFormular
laufzeitId={laufzeit.id}
hostId={laufzeit.host.id}
@@ -213,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
anhangName: verkauf?.anhang?.dateiname ?? null,
}}
/>
)}
</Card>
</div>
</div>
+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"
+21
View File
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
ansprechpartner: string | null;
email: string | null;
telefon: string | null;
eigenbedarf: boolean;
notizen: string | null;
}
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
</div>
<div className="col-12">
<div className="form-check">
<input
className="form-check-input pl-touch-target"
type="checkbox"
id="eigenbedarf"
name="eigenbedarf"
value="an"
defaultChecked={werte?.eigenbedarf ?? false}
/>
<label className="form-check-label" htmlFor="eigenbedarf">
Eigenbedarf – diese Hosts werden nicht weiterberechnet
</label>
<div className="form-text">
Für solche Kunden entfällt die Kundenrechnung: keine Warnungen darüber, keine Marge. Die
Einkaufsrechnung von Proxmox wird weiterhin geführt.
</div>
</div>
</div>
<div className="col-12">
<label className="form-label" htmlFor="notizen">
Notizen
@@ -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}
</>
);
}
+2
View File
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
ansprechpartner: z.string().nullable(),
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
telefon: z.string().nullable(),
eigenbedarf: z.boolean().default(false),
notizen: z.string().nullable(),
});
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
email: leerZuNull(formular.get("email")),
telefon: leerZuNull(formular.get("telefon")),
eigenbedarf: formular.get("eigenbedarf") === "an",
notizen: leerZuNull(formular.get("notizen")),
});
+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.";
});
}
+8 -1
View File
@@ -19,7 +19,13 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
prisma.laufzeit.findMany({
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
host: {
select: {
hostname: true,
kundeId: true,
kunde: { select: { name: true, eigenbedarf: true } },
},
},
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
@@ -51,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
hostname: laufzeit.host.hostname,
kundeId: laufzeit.host.kundeId,
kundeName: laufzeit.host.kunde?.name ?? null,
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
key: laufzeit.key,
start: laufzeit.start,
ende: laufzeit.ende,
+9 -1
View File
@@ -41,6 +41,8 @@ export interface LaufzeitDaten {
hostId: string;
hostname: string;
kundeId: string | null;
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
kundeEigenbedarf: boolean;
kundeName: string | null;
key: string;
start: Date;
@@ -359,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
.filter(
(laufzeit) =>
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
)
.filter((laufzeit) => laufzeit.kundeId !== null)
.map((laufzeit) => ({
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
@@ -384,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
return kontext.laufzeiten.flatMap((laufzeit) => {
if (laufzeit.kundeEigenbedarf) return [];
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
if (offenSeit < tage) return [];
@@ -463,6 +469,8 @@ const kundeBezahltEinkaufOffen: Regel = {
standardSchwellwerte: {},
pruefe(kontext) {
return kontext.laufzeiten.flatMap((laufzeit) => {
// Beim Eigenbedarf gibt es keine Kundenzahlung, auf die man warten würde.
if (laufzeit.kundeEigenbedarf) return [];
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
return [
+3 -2
View File
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
},
orderBy: { start: "asc" },
include: {
host: { select: { id: true, hostname: true, kunde: { select: { name: true } } } },
host: { select: { id: true, hostname: true, kunde: { select: { name: true, eigenbedarf: true } } } },
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
@@ -111,7 +111,8 @@ export async function offeneAufgaben(grenze = 12) {
});
}
if (einkauf && !laufzeit.kundenPosition?.rechnung.erstellt) {
// Für Eigenbedarf gibt es keine Kundenrechnung zu schreiben (#044).
if (einkauf && !laufzeit.host.kunde?.eigenbedarf && !laufzeit.kundenPosition?.rechnung.erstellt) {
aufgaben.push({
id: `${laufzeit.id}-verkauf`,
titel: "Kundenrechnung erstellen",
+2
View File
@@ -67,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
...laufzeitSicht,
status: "BESTAETIGT",
proxmoxPosition: { isNot: null },
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
host: { kunde: { eigenbedarf: false } },
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
},
}),
+1
View File
@@ -35,6 +35,7 @@ export async function kundenListe() {
return {
id: kunde.id,
name: kunde.name,
eigenbedarf: kunde.eigenbedarf,
kundennummer: kunde.kundennummer,
ansprechpartner: kunde.ansprechpartner,
hosts: kunde._count.hosts,
+18 -5
View File
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
where,
orderBy: [{ start: "desc" }],
include: {
host: { select: { id: true, hostname: true, kunde: { select: { id: true, name: true } } } },
host: {
select: {
id: true,
hostname: true,
kunde: { select: { id: true, name: true, eigenbedarf: true } },
},
},
proxmoxPosition: { include: { rechnung: true } },
kundenPosition: { include: { rechnung: true } },
},
@@ -82,6 +88,8 @@ export async function rechnungsListe(filter: RechnungsFilter) {
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null;
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
// Eigene Hosts werden nicht weiterberechnet; eine Marge daraus wäre eine erfundene Zahl (#044).
const eigenbedarf = laufzeit.host.kunde?.eigenbedarf ?? false;
return {
id: laufzeit.id,
@@ -101,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
verkaufBetrag: verkauf,
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? false,
marge: einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
eigenbedarf,
marge: !eigenbedarf && einkauf !== null && verkauf !== null ? verkauf - einkauf : null,
};
});
// Eigenbedarf zählt beim Einkauf mit – es sind echte Kosten –, bleibt bei Verkauf und Marge
// aber außen vor.
const summen = zeilen.reduce(
(summe, zeile) => ({
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 0),
einkaufVerkauft: summe.einkaufVerkauft + (zeile.eigenbedarf ? 0 : (zeile.einkaufBetrag ?? 0)),
eigenbedarf: summe.eigenbedarf + (zeile.eigenbedarf ? (zeile.einkaufBetrag ?? 0) : 0),
}),
{ einkauf: 0, verkauf: 0 },
{ einkauf: 0, verkauf: 0, einkaufVerkauft: 0, eigenbedarf: 0 },
);
return {
zeilen,
summen: { ...summen, marge: summen.verkauf - summen.einkauf },
summen: { ...summen, marge: summen.verkauf - summen.einkaufVerkauft },
};
}
@@ -145,7 +158,7 @@ export async function laufzeitDetail(id: string) {
id: true,
hostname: true,
produkt: true,
kunde: { select: { id: true, name: true } },
kunde: { select: { id: true, name: true, eigenbedarf: true } },
},
},
proxmoxPosition: {
+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;
});
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, expect, it } from "vitest";
import { alleRegelnPruefen, type LaufzeitDaten, type RegelKontext } from "@/server/alerts/regeln";
/** Eigenbedarf: eigene Hosts ohne Weiterberechnung (#044). */
const JETZT = new Date("2026-10-04T12:00:00Z");
function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten {
return {
id: "l1",
hostId: "h1",
hostname: "hv-ste-pve-01",
kundeId: "k1",
kundeEigenbedarf: false,
kundeName: "Florian Wilde IT SysOp",
key: "pve2s-aaaa",
start: new Date("2026-06-03"),
ende: new Date("2027-06-02"),
einkaufNummer: "291017",
einkaufBetrag: 990,
einkaufBezahlt: true,
einkaufFaelligAm: new Date("2026-10-17"),
verkaufNummer: null,
verkaufBetrag: null,
verkaufErstellt: false,
verkaufBezahlt: false,
verkaufDatum: null,
...überschreibungen,
};
}
function kontext(laufzeiten: LaufzeitDaten[]): RegelKontext {
return { jetzt: JETZT, hosts: [], laufzeiten, einstellungen: new Map() };
}
function regeln(k: RegelKontext, name: string) {
return alleRegelnPruefen(k).filter((kandidat) => kandidat.regel === name);
}
describe("Eigenbedarf", () => {
it("mahnt bei einem normalen Kunden die fehlende Kundenrechnung an", () => {
expect(regeln(kontext([laufzeit()]), "kundenrechnung-fehlt")).toHaveLength(1);
});
it("schweigt beim Eigenbedarf – es gibt niemanden, dem man schreibt", () => {
expect(regeln(kontext([laufzeit({ kundeEigenbedarf: true })]), "kundenrechnung-fehlt")).toHaveLength(0);
});
it("mahnt beim Eigenbedarf keine unbezahlte Kundenrechnung an", () => {
const k = kontext([
laufzeit({
kundeEigenbedarf: true,
verkaufErstellt: true,
verkaufDatum: new Date("2026-01-01"),
verkaufBezahlt: false,
}),
]);
expect(regeln(k, "kundenrechnung-unbezahlt")).toHaveLength(0);
});
it("meldet beim Eigenbedarf nicht „Kunde hat bezahlt“", () => {
const k = kontext([
laufzeit({ kundeEigenbedarf: true, verkaufBezahlt: true, einkaufBezahlt: false }),
]);
expect(regeln(k, "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
});
it("lässt die Einkaufsseite unberührt", () => {
// Die Proxmox-Rechnung muss auch für eigene Hosts bezahlt werden.
const k = kontext([
laufzeit({
kundeEigenbedarf: true,
einkaufBezahlt: false,
einkaufFaelligAm: new Date("2026-10-06"),
}),
]);
expect(regeln(k, "proxmox-rechnung-faellig")).toHaveLength(1);
});
});
+128
View File
@@ -0,0 +1,128 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
/** Laufzeiten nachträglich ändern und zusammenführen (#043). */
vi.mock("@/server/auth/sitzungspflicht", () => ({
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
sichtbarerKunde: async () => null,
darfEinkaufSehen: async () => true,
angemeldet: async () => null,
}));
const { laufzeitBearbeiten, laufzeitenZusammenfuehren, RechnungsFehler } = await import(
"@/server/services/rechnungen"
);
async function host(name = "hv-pve-01") {
return testPrisma.host.create({ data: { agentId: `a-${name}`, hostname: name, produkt: "PVE" } });
}
async function laufzeit(hostId: string, start: string, key = "pve2s-aaaa") {
return testPrisma.laufzeit.create({
data: {
hostId,
key,
start: new Date(start),
ende: new Date(new Date(start).setUTCFullYear(new Date(start).getUTCFullYear() + 1)),
status: "VORSCHLAG",
},
});
}
async function mitEinkauf(laufzeitId: string, nummer: string) {
const rechnung = await testPrisma.proxmoxRechnung.create({
data: { nummer, datum: new Date("2026-09-17"), nettoBetrag: 990, ustSatz: 0 },
});
const position = await testPrisma.proxmoxRechnungPosition.create({
data: { rechnungId: rechnung.id, bezeichnung: "Position", nettoBetrag: 1100, rabatt: 110 },
});
await testPrisma.laufzeit.update({
where: { id: laufzeitId },
data: { proxmoxPosition: { connect: { id: position.id } } },
});
}
describe.runIf(datenbankVerfuegbar)("Laufzeit bearbeiten", () => {
beforeEach(async () => {
await datenbankLeeren();
});
afterAll(async () => {
await testPrisma.$disconnect();
});
it("ändert Key und Zeitraum und bestätigt dabei den Vorschlag", async () => {
const h = await host();
const l = await laufzeit(h.id, "2026-10-17");
await laufzeitBearbeiten(l.id, {
key: "pve2s-neu",
start: new Date("2026-10-18"),
ende: new Date("2027-10-17"),
notizen: "Datum laut Bestellung",
});
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
expect(danach.key).toBe("pve2s-neu");
expect(danach.start.toISOString().slice(0, 10)).toBe("2026-10-18");
expect(danach.status).toBe("BESTAETIGT");
});
it("weist ein Ende vor dem Beginn ab", async () => {
const h = await host();
const l = await laufzeit(h.id, "2026-10-17");
await expect(
laufzeitBearbeiten(l.id, { key: "pve2s-aaaa", start: new Date("2026-10-17"), ende: new Date("2026-10-01") }),
).rejects.toBeInstanceOf(RechnungsFehler);
});
it("weist ein Startdatum ab, das am selben Host schon belegt ist", async () => {
const h = await host();
const ersteLaufzeit = await laufzeit(h.id, "2026-10-17");
await laufzeit(h.id, "2025-10-17");
await expect(
laufzeitBearbeiten(ersteLaufzeit.id, {
key: "pve2s-aaaa",
start: new Date("2025-10-17"),
ende: new Date("2026-10-16"),
}),
).rejects.toBeInstanceOf(RechnungsFehler);
});
it("führt zwei Laufzeiten zusammen und nimmt die Rechnung mit", async () => {
const h = await host();
const ziel = await laufzeit(h.id, "2026-10-17");
const quelle = await laufzeit(h.id, "2026-10-18");
await mitEinkauf(quelle.id, "291017");
await laufzeitenZusammenfuehren(ziel.id, quelle.id);
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: ziel.id } });
expect(danach.proxmoxPositionId).not.toBeNull();
expect(danach.status).toBe("BESTAETIGT");
expect(await testPrisma.laufzeit.count()).toBe(1);
// Die Rechnung selbst bleibt erhalten – nur die doppelte Laufzeit verschwindet.
expect(await testPrisma.proxmoxRechnungPosition.count()).toBe(1);
});
it("verweigert das Zusammenführen, wenn beide eine Einkaufsrechnung haben", async () => {
const h = await host();
const ziel = await laufzeit(h.id, "2026-10-17");
const quelle = await laufzeit(h.id, "2026-10-18");
await mitEinkauf(ziel.id, "291017");
await mitEinkauf(quelle.id, "291018");
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
expect(await testPrisma.laufzeit.count()).toBe(2);
});
it("führt nichts über Hostgrenzen hinweg zusammen", async () => {
const [a, b] = await Promise.all([host("hv-pve-01"), host("hv-pve-02")]);
const ziel = await laufzeit(a.id, "2026-10-17");
const quelle = await laufzeit(b.id, "2026-10-17");
await expect(laufzeitenZusammenfuehren(ziel.id, quelle.id)).rejects.toBeInstanceOf(RechnungsFehler);
});
});
+1
View File
@@ -41,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
hostname: "pve-01",
kundeId: "k1",
kundeName: "Brandl Elektrotechnik GmbH",
kundeEigenbedarf: false,
key: "pve1c-aaaa",
start: inTagen(-100),
ende: inTagen(265),