Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8119aaad66 | ||
|
|
ee36f78cc3 | ||
|
|
38e674de34 | ||
|
|
b745910afa | ||
|
|
840e902b26 | ||
|
|
5e5028c53d | ||
|
|
83320f0443 | ||
|
|
f076f49631 | ||
|
|
8e5bf74a30 | ||
|
|
02845ed717 | ||
|
|
3657b60d16 | ||
|
|
f86a328b3c | ||
|
|
b2664de62f |
@@ -17,6 +17,48 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [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
|
||||||
|
- **Cluster mit QDevice meldeten falsche Knotennamen** (`A,V,NMW`, `NR`) und das QDevice als zusätzlichen Knoten: `pvecm nodes` hat dann eine Spalte mehr. Die Namensspalte wird jetzt aus der Kopfzeile bestimmt (#042)
|
||||||
|
- Agent-Version 0.2.2
|
||||||
|
|
||||||
|
## [0.24.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Dreizehnte Warnregel: Mehrere Hosts desselben Kunden tragen denselben Namen – mit IP-Adressen zum Unterscheiden (#041)
|
||||||
|
- Hosts lassen sich **stilllegen**: Sie verschwinden aus Listen, Zahlen, Suche und Warnungen, ihre Laufzeiten und Rechnungen bleiben erhalten (#041)
|
||||||
|
- Endgültiges Löschen für Hosts, an denen keine Laufzeit hängt (#041)
|
||||||
|
- Schalter „stillgelegte Hosts zeigen“ in der Host-Liste (#041)
|
||||||
|
|
||||||
|
## [0.23.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
|
||||||
|
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
|
||||||
|
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
|
||||||
|
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
|
||||||
|
|
||||||
## [0.22.2] – 2026-10-03
|
## [0.22.2] – 2026-10-03
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 21 h 03 min
|
**Gesamt:** 24 h 38 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -45,6 +45,11 @@
|
|||||||
| #037 Rabatt je Position | 0 h 55 min |
|
| #037 Rabatt je Position | 0 h 55 min |
|
||||||
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||||
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
||||||
|
| #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 |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -67,7 +72,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.22.2
|
**Version:** 0.26.1
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -204,6 +209,25 @@
|
|||||||
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
|
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
|
||||||
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
|
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
|
||||||
|
|
||||||
|
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
|
||||||
|
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
|
||||||
|
|
||||||
|
- Doppelte Hosts (#041): Zwei Einträge desselben Namens beim selben Kunden entstehen, wenn sich die Maschinenkennung ändert. Neue Warnregel plus die bis dahin fehlende Möglichkeit, einen Host überhaupt loszuwerden
|
||||||
|
- Bewusst kein automatisches Zusammenführen: Es müsste raten, welcher Eintrag welche Rechnung erbt
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #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)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
set -o nounset
|
set -o nounset
|
||||||
set -o pipefail
|
set -o pipefail
|
||||||
|
|
||||||
AGENT_VERSION="0.2.1"
|
AGENT_VERSION="0.2.2"
|
||||||
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
CONFIG_DATEI="${PROXLEDGER_CONFIG:-/etc/proxledger/agent.conf}"
|
||||||
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
SPOOL_VERZEICHNIS="${PROXLEDGER_SPOOL:-/var/lib/proxledger/spool}"
|
||||||
SPOOL_MAXIMUM=30
|
SPOOL_MAXIMUM=30
|
||||||
@@ -175,9 +175,24 @@ cluster_name() {
|
|||||||
|
|
||||||
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
# Namen aller Cluster-Mitglieder laut corosync. Auch Knoten ohne Agent stehen hier –
|
||||||
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
# daraus erkennt das Dashboard vergessene Knoten (#022).
|
||||||
|
# Namen aller Cluster-Knoten aus `pvecm nodes`.
|
||||||
|
#
|
||||||
|
# Die Spaltenzahl ist nicht fest: Mit einem QDevice steht zwischen Votes und Name noch dessen
|
||||||
|
# Zustand (A,V,NMW oder NR). Deshalb wird die Spalte "Name" aus der Kopfzeile bestimmt, statt
|
||||||
|
# blind die dritte zu nehmen. Die Zeile des QDevice selbst traegt die Nodeid 0 und ist kein Knoten.
|
||||||
cluster_mitglieder() {
|
cluster_mitglieder() {
|
||||||
if command -v pvecm >/dev/null 2>&1; then
|
if command -v pvecm >/dev/null 2>&1; then
|
||||||
pvecm nodes 2>/dev/null | awk '/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {print $3}' | sed 's/ *(local)//'
|
pvecm nodes 2>/dev/null | awk '
|
||||||
|
/Nodeid/ {
|
||||||
|
for (i = 1; i <= NF; i++) if ($i == "Name") spalte = i
|
||||||
|
next
|
||||||
|
}
|
||||||
|
/^[[:space:]]*[0-9]+[[:space:]]+[0-9]+/ {
|
||||||
|
if ($1 == 0) next
|
||||||
|
name = (spalte ? $spalte : $3)
|
||||||
|
sub(/\(.*/, "", name)
|
||||||
|
if (name != "") print name
|
||||||
|
}'
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,34 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
|
|||||||
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
||||||
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
||||||
|
|
||||||
|
### Gleichnamige Hosts auseinanderhalten
|
||||||
|
|
||||||
|
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
|
||||||
|
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
|
||||||
|
|
||||||
|
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
|
||||||
|
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
|
||||||
|
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
|
||||||
|
Host-Detail steht die Notiz oben bei den Stammdaten.
|
||||||
|
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
|
||||||
|
|
||||||
|
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
|
||||||
|
|
||||||
|
### Wenn ein Host doppelt erscheint
|
||||||
|
|
||||||
|
Steht derselbe Name zweimal beim selben Kunden, meldet ProxLedger das als Hinweis und nennt die
|
||||||
|
IP-Adressen beider Einträge. Zwei Fälle sind möglich:
|
||||||
|
|
||||||
|
- **Zwei verschiedene Maschinen** heißen gleich. Dann helfen Schlagworte beim Auseinanderhalten.
|
||||||
|
- **Dieselbe Maschine hat sich neu registriert** – etwa nach einer Neuinstallation oder weil ein
|
||||||
|
Container geklont wurde. Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am
|
||||||
|
Namen; ändert sie sich, entsteht ein zweiter Eintrag.
|
||||||
|
|
||||||
|
Den alten Eintrag legst du im Host-Detail still. Er verschwindet dann aus Listen, Zahlen und
|
||||||
|
Warnungen, seine Laufzeiten und Rechnungen bleiben aber erhalten – mit dem Schalter *stillgelegte
|
||||||
|
Hosts zeigen* findest du ihn wieder. **Endgültig löschen** geht nur, solange keine Laufzeit daran
|
||||||
|
hängt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Kunden und Standorte pflegen
|
## 5. Kunden und Standorte pflegen
|
||||||
@@ -209,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
|
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.
|
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
|
## 6. Laufzeiten und Rechnungen
|
||||||
@@ -251,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
|
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.
|
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
|
### Der übliche Ablauf
|
||||||
|
|
||||||
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 177 KiB After Width: | Height: | Size: 178 KiB |
@@ -0,0 +1,26 @@
|
|||||||
|
# #040 Schlagworte und Notizen sichtbar machen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.23.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
|
||||||
|
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
|
||||||
|
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
|
||||||
|
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
|
||||||
|
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
|
||||||
|
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
|
||||||
|
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
|
||||||
|
Notiztext; die Trefferzeile nennt die Schlagworte.
|
||||||
|
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
|
||||||
|
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# #041 Gleichnamige Hosts erkennen und stilllegen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.24.0
|
||||||
|
**Labels:** backend, ui, alerts
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian markierte in der Host-Liste zwei Paare: `hv-pve-01` und `hv-pve-02` standen jeweils **zweimal
|
||||||
|
beim selben Kunden** – einmal „Aktiv“ mit einer IP, einmal „Keine Subscription“ mit drei anderen.
|
||||||
|
|
||||||
|
## Was dahintersteckt
|
||||||
|
Hosts werden an ihrer Kennung aus `/etc/machine-id` erkannt, nicht am Namen. Ändert sich diese Kennung
|
||||||
|
– Neuinstallation, geklonter Container, zurückgespielte Sicherung –, meldet sich dieselbe Maschine als
|
||||||
|
**neuer** Host, und der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen sind
|
||||||
|
aber genauso möglich. Welcher Fall vorliegt, weiß nur der Betreiber.
|
||||||
|
|
||||||
|
## Was fehlte
|
||||||
|
1. Kein Hinweis auf den Doppeleintrag.
|
||||||
|
2. **Keine Möglichkeit, einen Host loszuwerden.** Ein Eintrag, der nicht mehr existiert, blieb für
|
||||||
|
immer in Listen, Zahlen und Warnungen stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- **Dreizehnte Warnregel `hostname-doppelt`:** meldet mehrere Hosts desselben Kunden mit gleichem
|
||||||
|
Namen und nennt die IP-Adressen zum Unterscheiden. Gleiche Namen bei **verschiedenen** Kunden sind
|
||||||
|
der Normalfall und bleiben still.
|
||||||
|
- **Stilllegen** (`archiviertAm`): Der Host verschwindet aus Listen, Zahlen, Suche und Warnungen;
|
||||||
|
seine offenen Warnungen werden geschlossen. Laufzeiten und Rechnungen bleiben erhalten.
|
||||||
|
- **Wieder aufnehmen** mit demselben Knopf.
|
||||||
|
- **Endgültig löschen** nur, solange keine Laufzeit am Host hängt – Buchhaltung wirft man nicht weg.
|
||||||
|
- In der Liste ein Schalter „stillgelegte Hosts zeigen“ und ein Abzeichen an der Zeile.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Stilllegen statt automatisch zusammenführen. Ein Zusammenführen müsste raten, welcher Eintrag welche
|
||||||
|
Rechnung erbt – im Zweifel zulasten der Buchhaltung. Die Warnung nennt beide Hosts mit IPs; die
|
||||||
|
Entscheidung trifft der Mensch.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# #042 Cluster-Knoten falsch gelesen, wenn ein QDevice im Spiel ist
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.24.1
|
||||||
|
**Labels:** bug, agent
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 20 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florians Cluster hat drei Knoten (`hv-pve-01`, `hv-pve-02`, `hv-pve-05`). ProxLedger zeigte vier
|
||||||
|
„Cluster-Knoten“: `A,V,NMW`, `A,V,NMW`, `NR` und `Qdevice`.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Der Agent nahm aus `pvecm nodes` blind die dritte Spalte als Namen. Ohne QDevice stimmt das:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Name
|
||||||
|
1 1 hv-rz5-pve-01 (local)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit QDevice steht dort aber dessen Zustand, und der Name rutscht eine Spalte weiter:
|
||||||
|
|
||||||
|
```
|
||||||
|
Nodeid Votes Qdevice Name
|
||||||
|
1 1 A,V,NMW hv-pve-01
|
||||||
|
0 1 Qdevice (votes 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
Gemeldet wurden damit die Zustandskürzel – und das QDevice selbst als vierter „Knoten“.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
Die Spalte „Name“ wird aus der Kopfzeile bestimmt, statt sie zu erraten. Die Zeile des QDevice trägt
|
||||||
|
die Nodeid 0 und wird übersprungen. Beide Ausgabeformen sind als Testfall hinterlegt.
|
||||||
|
|
||||||
|
## Folgen
|
||||||
|
Die Warnregel „Cluster-Knoten ohne Agent“ hatte dadurch Warnungen für Knoten erzeugt, die es nicht
|
||||||
|
gibt. Sie schließen sich von selbst, sobald die Agents mit der neuen Fassung gemeldet haben.
|
||||||
|
|
||||||
|
## Lehre
|
||||||
|
Dritter Fall in dieser Reihe: Eine Ausgabe, die beim Feldtest auf **einem** Cluster richtig gelesen
|
||||||
|
wurde, ist nicht auf jedem Cluster gleich. Spalten gehören über ihre Überschrift angesprochen, nicht
|
||||||
|
über ihre Position.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -43,4 +43,9 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
||||||
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
||||||
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
||||||
| | **Summe** | | | **163 h** | **21 h 03 min** |
|
| [#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 |
|
||||||
|
| [#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 |
|
||||||
|
| | **Summe** | | | **175 h** | **24 h 38 min** |
|
||||||
|
|||||||
@@ -1,72 +1,117 @@
|
|||||||
# Roadmap
|
# Roadmap
|
||||||
|
|
||||||
Stand: 19.09.2026, Fassung 0.17.0
|
Stand: 04.10.2026, Fassung 0.26.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
|
Rollen und Benutzerverwaltung, Passkeys, Einstellungen im Dashboard, Betriebsdokumentation, die
|
||||||
- Zuordnung Key → Host → Kunde, Laufzeiten je Lizenzjahr, Einkaufs- und Verkaufsrechnung mit Marge
|
mobile Bedienung, Schlagworte und Notizen in der Übersicht, Stilllegen doppelter Hosts, Laufzeiten
|
||||||
- Elf Warnregeln, Benachrichtigungen per E-Mail und Webhook, Zusammenfassung
|
anlegen/bearbeiten/zusammenführen, Rabatt je Rechnungsposition, Eigenbedarf.
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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 |
|
| **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 Subscription** (#023) | Bisher nur der Fall „keine Subscription“ geprüft. Ablaufdatum, Sockel und Level sind auf PBS ungeprüft. | 30 min |
|
| **PBS mit aktiver Lizenz** | Bisher nur „keine Subscription“ geprü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** | Eine Sicherung, die nie zurückgespielt wurde, ist keine. | 30 min |
|
||||||
| **Sicherung einmal zurückspielen** | `deploy/backup.sh` schreibt Sicherungen. Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. | 30 min |
|
| **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
|
Die Rechnungen von Proxmox haben eine feste Form: je Key eine Zeile mit Zeitraum und Betrag, dazu die
|
||||||
erfasst. Ein Knopf „verlängern“, der die Folgelaufzeit anlegt, den Einkauf vormerkt und die
|
Rabattzeilen. Ein Import, der das PDF liest und die Positionen den Laufzeiten über den Key zuordnet,
|
||||||
Kundenrechnung mit dem eingestellten Aufschlag vorbereitet, spart bei jedem Lizenzjahr Klicks.
|
spart bei jeder Sammelrechnung das meiste Tippen – und vermeidet Zahlendreher.
|
||||||
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.
|
|
||||||
|
|
||||||
### 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 –
|
### 2. Verlängerung mit einem Klick
|
||||||
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.
|
|
||||||
|
|
||||||
### 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
|
**Aufwand:** klein bis mittel.
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
ProxLedger führt die Kundenrechnung bisher nur als Datensatz – geschrieben wird sie woanders. Mit
|
||||||
kommt ohne gespeicherte root-Zugänge aus; das ist ein Sicherheitsgewinn, den man nicht aufgeben sollte.
|
Nummernkreis, Firmenangaben (stehen schon in den Einstellungen) und PDF-Ausgabe wäre der Kreis
|
||||||
- **Eigene Benutzer- und Rechteverwaltung mit Gruppen, Vererbung und allem.** Wer das braucht, hat schon
|
geschlossen. Zu klären wäre, ob das die vorhandene Buchhaltung ersetzen soll oder nur ergänzt.
|
||||||
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
|
**Aufwand:** mittel. **Rückfrage zuerst:** Soll ProxLedger Rechnungen *schreiben* oder nur *führen*?
|
||||||
jedem Proxmox-Host, auch auf einem alten.
|
|
||||||
|
### 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.
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.19.1",
|
"version": "0.26.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.19.1",
|
"version": "0.26.0",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@node-rs/argon2": "^2.2.1",
|
"@node-rs/argon2": "^2.2.1",
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"next": "^16.3.8",
|
"next": "^16.4.0",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -35,11 +35,11 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.3.8",
|
"eslint-config-next": "^16.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -1029,9 +1029,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-arm64": {
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==",
|
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1047,13 +1047,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3"
|
"@img/sharp-libvips-darwin-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-darwin-x64": {
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==",
|
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1069,20 +1069,20 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3"
|
"@img/sharp-libvips-darwin-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-freebsd-wasm32": {
|
"node_modules/@img/sharp-freebsd-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==",
|
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"freebsd"
|
"freebsd"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1092,9 +1092,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==",
|
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1108,9 +1108,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-darwin-x64": {
|
"node_modules/@img/sharp-libvips-darwin-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==",
|
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1124,9 +1124,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm": {
|
"node_modules/@img/sharp-libvips-linux-arm": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
|
||||||
"integrity": "sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==",
|
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1143,9 +1143,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-arm64": {
|
"node_modules/@img/sharp-libvips-linux-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==",
|
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1162,9 +1162,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
"node_modules/@img/sharp-libvips-linux-ppc64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
|
||||||
"integrity": "sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==",
|
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1181,9 +1181,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
"node_modules/@img/sharp-libvips-linux-riscv64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
|
||||||
"integrity": "sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==",
|
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1200,9 +1200,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-s390x": {
|
"node_modules/@img/sharp-libvips-linux-s390x": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
|
||||||
"integrity": "sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==",
|
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1219,9 +1219,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linux-x64": {
|
"node_modules/@img/sharp-libvips-linux-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==",
|
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1238,9 +1238,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
|
||||||
"integrity": "sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==",
|
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1257,9 +1257,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
|
||||||
"integrity": "sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==",
|
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1276,9 +1276,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm": {
|
"node_modules/@img/sharp-linux-arm": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
|
||||||
"integrity": "sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==",
|
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1297,13 +1297,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3"
|
"@img/sharp-libvips-linux-arm": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-arm64": {
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==",
|
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1322,13 +1322,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3"
|
"@img/sharp-libvips-linux-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-ppc64": {
|
"node_modules/@img/sharp-linux-ppc64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
|
||||||
"integrity": "sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==",
|
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1347,13 +1347,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3"
|
"@img/sharp-libvips-linux-ppc64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-riscv64": {
|
"node_modules/@img/sharp-linux-riscv64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
|
||||||
"integrity": "sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==",
|
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1372,13 +1372,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3"
|
"@img/sharp-libvips-linux-riscv64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-s390x": {
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
|
||||||
"integrity": "sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==",
|
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1397,13 +1397,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3"
|
"@img/sharp-libvips-linux-s390x": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linux-x64": {
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==",
|
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1422,13 +1422,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3"
|
"@img/sharp-libvips-linux-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-arm64": {
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==",
|
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1447,13 +1447,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-linuxmusl-x64": {
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==",
|
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1472,13 +1472,13 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3"
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-wasm32": {
|
"node_modules/@img/sharp-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==",
|
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
|
||||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1492,16 +1492,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-webcontainers-wasm32": {
|
"node_modules/@img/sharp-webcontainers-wasm32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
|
||||||
"integrity": "sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==",
|
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@img/sharp-wasm32": "0.35.4"
|
"@img/sharp-wasm32": "0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
@@ -1511,9 +1511,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-arm64": {
|
"node_modules/@img/sharp-win32-arm64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
|
||||||
"integrity": "sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==",
|
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1530,9 +1530,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-ia32": {
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
|
||||||
"integrity": "sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==",
|
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1549,9 +1549,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@img/sharp-win32-x64": {
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
|
||||||
"integrity": "sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==",
|
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1646,15 +1646,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
|
||||||
"integrity": "sha512-Al9zqHVV7TJv0eFuOU4U7Lvv74PTih4Ch63sk2xCIpSTkE3udFnaOcnzP2lQVymiL7yS9Cj2iClUXlR3EQ5sEw==",
|
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@next/eslint-plugin-next": {
|
"node_modules/@next/eslint-plugin-next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.4.0.tgz",
|
||||||
"integrity": "sha512-eCR9RcLTZrVS+K3+VOhi0xMdUUGNuriCHHhU1ZoABC/QQ/Gp0OWbLKQxV2/9DkmihYdYRcQWFmZXW8M1AMUXYw==",
|
"integrity": "sha512-ffeflB/dL8RrvWejQximw01pOpKHN692Y3FjDB82DCd0YVvXvBD6vY7mcZp27yvSVFVM6wA73xVodEKF4spizA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1695,9 +1695,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-arm64": {
|
"node_modules/@next/swc-darwin-arm64": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
|
||||||
"integrity": "sha512-2JPRMh2nmQG5CiL7cXGL9AGwnPWJQ//cTtAUCT+w511QHk79SYz3LGv/pc5X643B/WEO0rvu3Yww0hqwt3kgeA==",
|
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1711,9 +1711,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-darwin-x64": {
|
"node_modules/@next/swc-darwin-x64": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
|
||||||
"integrity": "sha512-GZtCCOBKJ4leVIT/Th0llWKhD1ca92lzbQiS5R5ON9QkoiFnilFsebDae1JU2a3HWoKMEmEZWGs1AGLavVM72Q==",
|
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1727,9 +1727,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
|
||||||
"integrity": "sha512-O659ygeQYqneJ1fBKMpFxIFqYkYswu8IAS1OCKK/4f3ZgJJm1dRz4fVJZRi/kLLWjnBKnebOePA4WNv+sV1pVA==",
|
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1746,9 +1746,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-arm64-musl": {
|
"node_modules/@next/swc-linux-arm64-musl": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
|
||||||
"integrity": "sha512-dSjKSyWpzxoO1d3DIZZcP4XJcNaKeLmxQMFOiYl5vuBRMmweIqnAhty8tAmRsvTss779cK1FtYnDMj40e4TQlg==",
|
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1765,9 +1765,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-gnu": {
|
"node_modules/@next/swc-linux-x64-gnu": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
|
||||||
"integrity": "sha512-lbqOuz3RPRcv+o9msNsJw5x4+Y1ZwPTs6vmL6DCf7i0fZfvng/F59wyeDwqHIvV0mK//RBy/jJkZ+nCKsSMXjQ==",
|
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1784,9 +1784,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-linux-x64-musl": {
|
"node_modules/@next/swc-linux-x64-musl": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
|
||||||
"integrity": "sha512-+316WswI8ScVgZeUd+1KGaXkHhaYQzCjvH/05TZSpJ8zBizb1a4G7DtO7F12jcBIqMOtsz9ji1t48fmKtzqsGA==",
|
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1803,9 +1803,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
|
||||||
"integrity": "sha512-ji0gd4kMYUxO+1fJBIbiBVRCjzG/lloiyCccnlebvb1ZJ5qXCPZqYg4Jl1DrrixnWNMKylzgpmMWx0yNDYXlzw==",
|
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1819,9 +1819,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@next/swc-win32-x64-msvc": {
|
"node_modules/@next/swc-win32-x64-msvc": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
|
||||||
"integrity": "sha512-WcTlaKt/TWkh5kUjdJcUmB1XgZ+1c6fz4Y9fDHL73YNSdGaUWjceeWrrlwF0nv19iABYWC4iAq1oX1w4Bn0vfg==",
|
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -5889,21 +5889,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-config-next": {
|
"node_modules/eslint-config-next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.4.0.tgz",
|
||||||
"integrity": "sha512-81vovwMe6NGnoFsl0KUJWzlS+y239i3fdsxs49gSEX3pDQW2cuKy+fxUPXNkQqqx6OCPheYPtBlSufc9aZ0k+w==",
|
"integrity": "sha512-2RHLYswzc+hNRktWAjIL6XAvwb1cKcjLawJ5KWywAxn/JivNHtG42ZigL1yobVXDqsimuuL2F9jcEcnHFzbppQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/eslint-plugin-next": "16.3.8",
|
"@next/eslint-plugin-next": "16.4.0",
|
||||||
"eslint-import-resolver-node": "^0.3.6",
|
"eslint-import-resolver-node": "^0.3.6",
|
||||||
"eslint-import-resolver-typescript": "^3.5.2",
|
"eslint-import-resolver-typescript": "^3.5.2",
|
||||||
"eslint-plugin-import": "^2.32.0",
|
"eslint-plugin-import": "^2.32.0",
|
||||||
"eslint-plugin-jsx-a11y": "^6.10.0",
|
"eslint-plugin-jsx-a11y": "^6.10.0",
|
||||||
"eslint-plugin-react": "^7.37.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",
|
"globals": "16.4.0",
|
||||||
"typescript-eslint": "^8.46.0"
|
"typescript-eslint": "^8.56.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"eslint": ">=9.0.0",
|
"eslint": ">=9.0.0",
|
||||||
@@ -8184,12 +8184,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/next": {
|
"node_modules/next": {
|
||||||
"version": "16.3.8",
|
"version": "16.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/next/-/next-16.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
|
||||||
"integrity": "sha512-U7QEZaTini6wKrb8A8hqLLqYQyCetegKjCpJOyxk642vWoMoU1x5PyZCJFvgYgiptA8xc5j/9xYlZFO7w9Sjmw==",
|
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@next/env": "16.3.8",
|
"@next/env": "16.4.0",
|
||||||
"@swc/helpers": "0.5.23",
|
"@swc/helpers": "0.5.23",
|
||||||
"baseline-browser-mapping": "^2.9.19",
|
"baseline-browser-mapping": "^2.9.19",
|
||||||
"caniuse-lite": "^1.0.30001579",
|
"caniuse-lite": "^1.0.30001579",
|
||||||
@@ -8203,14 +8203,14 @@
|
|||||||
"node": ">=20.9.0"
|
"node": ">=20.9.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@next/swc-darwin-arm64": "16.3.8",
|
"@next/swc-darwin-arm64": "16.4.0",
|
||||||
"@next/swc-darwin-x64": "16.3.8",
|
"@next/swc-darwin-x64": "16.4.0",
|
||||||
"@next/swc-linux-arm64-gnu": "16.3.8",
|
"@next/swc-linux-arm64-gnu": "16.4.0",
|
||||||
"@next/swc-linux-arm64-musl": "16.3.8",
|
"@next/swc-linux-arm64-musl": "16.4.0",
|
||||||
"@next/swc-linux-x64-gnu": "16.3.8",
|
"@next/swc-linux-x64-gnu": "16.4.0",
|
||||||
"@next/swc-linux-x64-musl": "16.3.8",
|
"@next/swc-linux-x64-musl": "16.4.0",
|
||||||
"@next/swc-win32-arm64-msvc": "16.3.8",
|
"@next/swc-win32-arm64-msvc": "16.4.0",
|
||||||
"@next/swc-win32-x64-msvc": "16.3.8",
|
"@next/swc-win32-x64-msvc": "16.4.0",
|
||||||
"sharp": "^0.35.4"
|
"sharp": "^0.35.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9422,9 +9422,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sharp": {
|
"node_modules/sharp": {
|
||||||
"version": "0.35.4",
|
"version": "0.35.5",
|
||||||
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.4.tgz",
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
|
||||||
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
|
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -9439,31 +9439,31 @@
|
|||||||
"url": "https://opencollective.com/libvips"
|
"url": "https://opencollective.com/libvips"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@img/sharp-darwin-arm64": "0.35.4",
|
"@img/sharp-darwin-arm64": "0.35.5",
|
||||||
"@img/sharp-darwin-x64": "0.35.4",
|
"@img/sharp-darwin-x64": "0.35.5",
|
||||||
"@img/sharp-freebsd-wasm32": "0.35.4",
|
"@img/sharp-freebsd-wasm32": "0.35.5",
|
||||||
"@img/sharp-libvips-darwin-arm64": "1.3.3",
|
"@img/sharp-libvips-darwin-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-darwin-x64": "1.3.3",
|
"@img/sharp-libvips-darwin-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm": "1.3.3",
|
"@img/sharp-libvips-linux-arm": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-arm64": "1.3.3",
|
"@img/sharp-libvips-linux-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-ppc64": "1.3.3",
|
"@img/sharp-libvips-linux-ppc64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-riscv64": "1.3.3",
|
"@img/sharp-libvips-linux-riscv64": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-s390x": "1.3.3",
|
"@img/sharp-libvips-linux-s390x": "1.3.4",
|
||||||
"@img/sharp-libvips-linux-x64": "1.3.3",
|
"@img/sharp-libvips-linux-x64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-arm64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
|
||||||
"@img/sharp-libvips-linuxmusl-x64": "1.3.3",
|
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
|
||||||
"@img/sharp-linux-arm": "0.35.4",
|
"@img/sharp-linux-arm": "0.35.5",
|
||||||
"@img/sharp-linux-arm64": "0.35.4",
|
"@img/sharp-linux-arm64": "0.35.5",
|
||||||
"@img/sharp-linux-ppc64": "0.35.4",
|
"@img/sharp-linux-ppc64": "0.35.5",
|
||||||
"@img/sharp-linux-riscv64": "0.35.4",
|
"@img/sharp-linux-riscv64": "0.35.5",
|
||||||
"@img/sharp-linux-s390x": "0.35.4",
|
"@img/sharp-linux-s390x": "0.35.5",
|
||||||
"@img/sharp-linux-x64": "0.35.4",
|
"@img/sharp-linux-x64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-arm64": "0.35.4",
|
"@img/sharp-linuxmusl-arm64": "0.35.5",
|
||||||
"@img/sharp-linuxmusl-x64": "0.35.4",
|
"@img/sharp-linuxmusl-x64": "0.35.5",
|
||||||
"@img/sharp-webcontainers-wasm32": "0.35.4",
|
"@img/sharp-webcontainers-wasm32": "0.35.5",
|
||||||
"@img/sharp-win32-arm64": "0.35.4",
|
"@img/sharp-win32-arm64": "0.35.5",
|
||||||
"@img/sharp-win32-ia32": "0.35.4",
|
"@img/sharp-win32-ia32": "0.35.5",
|
||||||
"@img/sharp-win32-x64": "0.35.4"
|
"@img/sharp-win32-x64": "0.35.5"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@types/node": {
|
"@types/node": {
|
||||||
@@ -9604,9 +9604,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.22.2",
|
"version": "0.26.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"bootstrap-icons": "^1.13.1",
|
"bootstrap-icons": "^1.13.1",
|
||||||
"croner": "^10.0.1",
|
"croner": "^10.0.1",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"next": "^16.3.8",
|
"next": "^16.4.0",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.10",
|
||||||
"openid-client": "^6.8.8",
|
"openid-client": "^6.8.8",
|
||||||
"react": "19.2.8",
|
"react": "19.2.8",
|
||||||
@@ -56,11 +56,11 @@
|
|||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"esbuild": "^0.28.2",
|
"esbuild": "^0.28.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "^16.3.8",
|
"eslint-config-next": "^16.4.0",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.9.7",
|
"prettier": "^3.9.7",
|
||||||
"prisma": "^7.10.0",
|
"prisma": "^7.10.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.13",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "^5.0.1"
|
||||||
@@ -71,6 +71,7 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"mysql2": "^3.24.4",
|
"mysql2": "^3.24.4",
|
||||||
"deepmerge-ts": "^8.0.0"
|
"deepmerge-ts": "^8.0.0",
|
||||||
|
"source-map-js": "^1.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Hosts stilllegen, statt sie zu löschen (#041).
|
||||||
|
-- Ein stillgelegter Host verschwindet aus Listen, Zahlen und Warnungen; seine Laufzeiten und
|
||||||
|
-- Rechnungen bleiben erhalten, weil sie zur Buchhaltung gehören.
|
||||||
|
ALTER TABLE "Host" ADD COLUMN "archiviertAm" TIMESTAMP(3);
|
||||||
|
CREATE INDEX "Host_archiviertAm_idx" ON "Host"("archiviertAm");
|
||||||
@@ -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;
|
||||||
@@ -61,6 +61,9 @@ model Kunde {
|
|||||||
ansprechpartner String?
|
ansprechpartner String?
|
||||||
email String?
|
email String?
|
||||||
telefon 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?
|
notizen String?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
geaendertAm DateTime @updatedAt
|
geaendertAm DateTime @updatedAt
|
||||||
@@ -134,6 +137,10 @@ model Host {
|
|||||||
subNextduedate DateTime? @db.Date
|
subNextduedate DateTime? @db.Date
|
||||||
subChecktime DateTime?
|
subChecktime DateTime?
|
||||||
|
|
||||||
|
/// Stillgelegt: Der Host erscheint nicht mehr in Listen, Zahlen und Warnungen – seine
|
||||||
|
/// Geschichte samt Laufzeiten und Rechnungen bleibt aber erhalten (#041).
|
||||||
|
archiviertAm DateTime?
|
||||||
|
|
||||||
erstmalsGesehenAm DateTime @default(now())
|
erstmalsGesehenAm DateTime @default(now())
|
||||||
letzterReportAm DateTime?
|
letzterReportAm DateTime?
|
||||||
erstelltAm DateTime @default(now())
|
erstelltAm DateTime @default(now())
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { PRODUKT_TEXT } from "@/lib/anzeige";
|
|||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
||||||
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
||||||
|
import { hostArchivieren, hostLoeschen } from "@/server/actions/hosts";
|
||||||
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
||||||
import { hostMitDetails } from "@/server/queries/hostliste";
|
import { hostMitDetails } from "@/server/queries/hostliste";
|
||||||
import { meldungsGrenze } from "@/server/queries/hosts";
|
import { meldungsGrenze } from "@/server/queries/hosts";
|
||||||
@@ -108,6 +109,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
||||||
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
||||||
{meldetNicht ? <KeineMeldungBadge /> : null}
|
{meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{host.archiviertAm ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span className="badge pl-bg-neutral">Stillgelegt</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
|
||||||
|
Hosts ist das die Stelle, an der man sie braucht (#040). */}
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
|
||||||
|
{host.notizen}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Zeile bezeichnung="Kunde">
|
<Zeile bezeichnung="Kunde">
|
||||||
@@ -177,6 +204,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
kundeId={host.kundeId}
|
kundeId={host.kundeId}
|
||||||
standortId={host.standortId}
|
standortId={host.standortId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Stilllegen statt löschen: Laufzeiten und Rechnungen gehören zur Buchhaltung (#041). */}
|
||||||
|
<div className="mt-3 pt-3 border-top d-flex flex-wrap gap-2">
|
||||||
|
<form action={hostArchivieren}>
|
||||||
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
|
<button type="submit" className="btn btn-sm btn-outline-secondary">
|
||||||
|
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{host.laufzeiten.length === 0 ? (
|
||||||
|
<form action={hostLoeschen}>
|
||||||
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
|
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||||
|
Endgültig löschen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="form-text">
|
||||||
|
{host.archiviertAm
|
||||||
|
? `Stillgelegt am ${formatDatum(host.archiviertAm)} – erscheint nicht in Listen, Zahlen und Warnungen.`
|
||||||
|
: host.laufzeiten.length === 0
|
||||||
|
? "Stilllegen blendet den Host aus; löschen geht nur, solange keine Laufzeit daran hängt."
|
||||||
|
: "An diesem Host hängen Laufzeiten – er lässt sich nur stilllegen, nicht löschen."}
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const filter: HostFilter = {
|
const filter: HostFilter = {
|
||||||
q: einzeln(roh.q),
|
q: einzeln(roh.q),
|
||||||
kunde: einzeln(roh.kunde),
|
kunde: einzeln(roh.kunde),
|
||||||
|
tag: einzeln(roh.tag),
|
||||||
|
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
|
||||||
status: einzeln(roh.status),
|
status: einzeln(roh.status),
|
||||||
produkt: einzeln(roh.produkt),
|
produkt: einzeln(roh.produkt),
|
||||||
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
||||||
@@ -85,6 +87,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const basis: Record<string, string | undefined> = {
|
const basis: Record<string, string | undefined> = {
|
||||||
q: filter.q,
|
q: filter.q,
|
||||||
kunde: filter.kunde,
|
kunde: filter.kunde,
|
||||||
|
tag: filter.tag,
|
||||||
|
archiviert: filter.archiviert,
|
||||||
status: filter.status,
|
status: filter.status,
|
||||||
produkt: filter.produkt,
|
produkt: filter.produkt,
|
||||||
ablauf: filter.ablauf,
|
ablauf: filter.ablauf,
|
||||||
@@ -117,7 +121,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
|
<HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{host.archiviertAm ? (
|
||||||
|
<span className="badge pl-bg-neutral ms-2">Stillgelegt</span>
|
||||||
|
) : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
|
||||||
|
<i
|
||||||
|
className="bi bi-sticky ms-2 text-body-secondary"
|
||||||
|
title={host.notizen}
|
||||||
|
aria-label={`Notiz: ${host.notizen}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-1 d-flex flex-wrap gap-1">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function KundeBearbeitenSeite({ params }: { params: Promise
|
|||||||
ansprechpartner: kunde.ansprechpartner,
|
ansprechpartner: kunde.ansprechpartner,
|
||||||
email: kunde.email,
|
email: kunde.email,
|
||||||
telefon: kunde.telefon,
|
telefon: kunde.telefon,
|
||||||
|
eigenbedarf: kunde.eigenbedarf,
|
||||||
notizen: kunde.notizen,
|
notizen: kunde.notizen,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
|
|
||||||
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
<Card titel="Laufzeiten und Rechnungen" rahmen="info" tabelle>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<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">Einkauf</th>
|
||||||
<th scope="col">Kundenrechnung</th>
|
<th scope="col">Kundenrechnung</th>
|
||||||
<th scope="col">Verkauf</th>
|
<th scope="col">Verkauf</th>
|
||||||
|
<th scope="col">Marge</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{laufzeiten.map((laufzeit) => (
|
{laufzeiten.map((laufzeit) => (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
{/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
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 ? (
|
{laufzeit.vorschlag ? (
|
||||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>{laufzeit.hostname}</td>
|
<td data-label="Host">
|
||||||
<td>
|
<Link href={`/hosts/${laufzeit.hostId}`} className="text-decoration-none">
|
||||||
|
{laufzeit.hostname}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
<CopyableCode wert={laufzeit.einkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
<EinkaufBadge bezahlt={laufzeit.einkauf ? laufzeit.einkauf.bezahlt : null} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(laufzeit.einkauf?.nettoBetrag)}</td>
|
<td data-label="Einkauf">
|
||||||
<td>
|
{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} />
|
<CopyableCode wert={laufzeit.verkauf?.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -241,12 +261,26 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{laufzeiten.length === 0 ? (
|
{laufzeiten.length === 0 ? (
|
||||||
<tr>
|
<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.
|
Noch keine Laufzeiten erfasst.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export default async function KundenSeite() {
|
|||||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||||
{kunde.name}
|
{kunde.name}
|
||||||
</Link>
|
</Link>
|
||||||
|
{kunde.eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral ms-2">Eigenbedarf</span>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-mono" data-label="Kundennummer">
|
<td className="pl-mono" data-label="Kundennummer">
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format";
|
|||||||
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen";
|
||||||
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen";
|
||||||
import { laufzeitDetail } from "@/server/queries/rechnungen";
|
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 }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
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;
|
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||||
|
|
||||||
// Vorgaben aus den Einstellungen füllen leere Felder vor (#025).
|
// 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 =
|
const verkaufVorschlag =
|
||||||
verkaufBetrag ??
|
verkaufBetrag ??
|
||||||
(einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100);
|
(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)}`;
|
const zeitraum = `${formatDatum(laufzeit.start)} – ${formatDatum(laufzeit.ende)}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,6 +145,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
{marge === null ? "–" : formatEuro(marge)}
|
{marge === null ? "–" : formatEuro(marge)}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{einkauf && einkauf.positionen.length > 1 ? (
|
{einkauf && einkauf.positionen.length > 1 ? (
|
||||||
@@ -192,11 +227,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
<Card
|
<Card
|
||||||
titel="Kundenrechnung (Verkauf)"
|
titel="Kundenrechnung (Verkauf)"
|
||||||
rahmen={verkauf?.erstellt ? "info" : "warning"}
|
rahmen={eigenbedarf ? "keiner" : verkauf?.erstellt ? "info" : "warning"}
|
||||||
werkzeuge={
|
werkzeuge={
|
||||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
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
|
<KundenrechnungFormular
|
||||||
laufzeitId={laufzeit.id}
|
laufzeitId={laufzeit.id}
|
||||||
hostId={laufzeit.host.id}
|
hostId={laufzeit.host.id}
|
||||||
@@ -213,6 +259,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
anhangName: verkauf?.anhang?.dateiname ?? null,
|
anhangName: verkauf?.anhang?.dateiname ?? null,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode";
|
|||||||
import { InfoBox } from "@/components/ui/SmallBox";
|
import { InfoBox } from "@/components/ui/SmallBox";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
|
import { formatDatum, formatEuro, tageBis } from "@/lib/format";
|
||||||
|
import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen";
|
||||||
import {
|
import {
|
||||||
anzahlVorschlaege,
|
anzahlVorschlaege,
|
||||||
laufzeitJahre,
|
laufzeitJahre,
|
||||||
@@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
q: einzeln(roh.q),
|
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),
|
rechnungsListe(filter),
|
||||||
laufzeitJahre(),
|
laufzeitJahre(),
|
||||||
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }),
|
||||||
anzahlVorschlaege(),
|
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();
|
const heute = new Date();
|
||||||
@@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<Card
|
||||||
titel="Laufzeiten und Rechnungen"
|
titel="Laufzeiten und Rechnungen"
|
||||||
rahmen="primary"
|
rahmen="primary"
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
*/
|
*/
|
||||||
export function HostFilterLeiste({
|
export function HostFilterLeiste({
|
||||||
kunden,
|
kunden,
|
||||||
|
tags,
|
||||||
werte,
|
werte,
|
||||||
}: {
|
}: {
|
||||||
kunden: { id: string; name: string }[];
|
kunden: { id: string; name: string }[];
|
||||||
|
tags: string[];
|
||||||
werte: Record<string, string | undefined>;
|
werte: Record<string, string | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
|
|||||||
router.push(`${pathname}?${neu.toString()}`);
|
router.push(`${pathname}?${neu.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter(
|
const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "archiviert", "q"].filter(
|
||||||
(schluessel) => werte[schluessel],
|
(schluessel) => werte[schluessel],
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="col-6 col-md-2">
|
||||||
|
<label className="visually-hidden" htmlFor="tag">
|
||||||
|
Schlagwort
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="tag"
|
||||||
|
value={werte.tag ?? ""}
|
||||||
|
onChange={(e) => setzen("tag", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Alle Schlagworte</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>
|
||||||
|
{tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="col-6 col-md-2">
|
<div className="col-6 col-md-2">
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
@@ -135,6 +158,20 @@ export function HostFilterLeiste({
|
|||||||
nur mit offenen Rechnungspunkten
|
nur mit offenen Rechnungspunkten
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-check">
|
||||||
|
<input
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="archiviert"
|
||||||
|
checked={werte.archiviert === "ja"}
|
||||||
|
onChange={(e) => setzen("archiviert", e.target.checked ? "ja" : "")}
|
||||||
|
/>
|
||||||
|
<label className="form-check-label small" htmlFor="archiviert">
|
||||||
|
stillgelegte Hosts zeigen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{aktiveFilter > 0 ? (
|
{aktiveFilter > 0 ? (
|
||||||
<Link href="/hosts" className="small">
|
<Link href="/hosts" className="small">
|
||||||
Filter zurücksetzen ({aktiveFilter})
|
Filter zurücksetzen ({aktiveFilter})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface KundeFormularWerte {
|
|||||||
ansprechpartner: string | null;
|
ansprechpartner: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
telefon: string | null;
|
telefon: string | null;
|
||||||
|
eigenbedarf: boolean;
|
||||||
notizen: string | null;
|
notizen: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +99,26 @@ export function KundeFormular({ werte }: { werte?: KundeFormularWerte }) {
|
|||||||
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
<input id="telefon" name="telefon" className="form-control" defaultValue={werte?.telefon ?? ""} />
|
||||||
</div>
|
</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">
|
<div className="col-12">
|
||||||
<label className="form-label" htmlFor="notizen">
|
<label className="form-label" htmlFor="notizen">
|
||||||
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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -55,8 +55,14 @@ export function eigeneAdresse(request: Request, pfad: string, eingetragen?: stri
|
|||||||
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
|
* Containername); im Browser landet man damit auf einer Seite, die es nicht gibt.
|
||||||
*/
|
*/
|
||||||
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
|
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
|
||||||
const { headers } = await import("next/headers");
|
let kopfzeilen: Headers;
|
||||||
const kopfzeilen = await headers();
|
try {
|
||||||
|
const { headers } = await import("next/headers");
|
||||||
|
kopfzeilen = await headers();
|
||||||
|
} catch {
|
||||||
|
// Außerhalb einer Anfrage (Tests, Skripte) gibt es keine Kopfzeilen – dann bleibt der Pfad relativ.
|
||||||
|
return pfad;
|
||||||
|
}
|
||||||
|
|
||||||
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
|
// Die Anfrage selbst gibt es hier nicht; eine leere mit den echten Kopfzeilen genügt.
|
||||||
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
|
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { eigenerPfad } from "@/lib/adresse";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { protokolliere } from "@/server/audit";
|
import { protokolliere } from "@/server/audit";
|
||||||
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
|
||||||
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
|
|||||||
revalidatePath("/kunden");
|
revalidatePath("/kunden");
|
||||||
revalidatePath("/");
|
revalidatePath("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legt einen Host still: Er verschwindet aus Listen, Zahlen und Warnungen, bleibt aber mit seiner
|
||||||
|
* Geschichte erhalten (#041). Gedacht für Maschinen, die es nicht mehr gibt – oder für einen
|
||||||
|
* doppelten Eintrag, der entstanden ist, weil sich die Maschinenkennung geändert hat.
|
||||||
|
*/
|
||||||
|
export async function hostArchivieren(formular: FormData) {
|
||||||
|
await sitzungErforderlich("fachlichSchreiben");
|
||||||
|
|
||||||
|
const hostId = formular.get("hostId")?.toString();
|
||||||
|
if (!hostId) return;
|
||||||
|
|
||||||
|
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { archiviertAm: true } });
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
const stilllegen = host.archiviertAm === null;
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: hostId },
|
||||||
|
data: { archiviertAm: stilllegen ? new Date() : null },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Offene Warnungen eines stillgelegten Hosts schließen – sonst bleiben sie für immer stehen.
|
||||||
|
if (stilllegen) {
|
||||||
|
await prisma.alert.updateMany({
|
||||||
|
where: { hostId, erledigtAm: null },
|
||||||
|
data: { erledigtAm: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await protokolliere(stilllegen ? "host.stillgelegt" : "host.reaktiviert", "Host", hostId, {});
|
||||||
|
revalidatePath(`/hosts/${hostId}`);
|
||||||
|
revalidatePath("/hosts");
|
||||||
|
revalidatePath("/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht einen Host endgültig – nur, solange keine Laufzeit daran hängt. Sobald Rechnungen im
|
||||||
|
* Spiel sind, bleibt nur das Stilllegen: Buchhaltung wirft man nicht weg.
|
||||||
|
*/
|
||||||
|
export async function hostLoeschen(formular: FormData): Promise<void> {
|
||||||
|
await sitzungErforderlich("fachlichSchreiben");
|
||||||
|
|
||||||
|
const hostId = formular.get("hostId")?.toString();
|
||||||
|
if (!hostId) return;
|
||||||
|
|
||||||
|
const laufzeiten = await prisma.laufzeit.count({ where: { hostId } });
|
||||||
|
if (laufzeiten > 0) return;
|
||||||
|
|
||||||
|
const host = await prisma.host.findUnique({ where: { id: hostId }, select: { hostname: true } });
|
||||||
|
if (!host) return;
|
||||||
|
|
||||||
|
await prisma.host.delete({ where: { id: hostId } });
|
||||||
|
await protokolliere("host.geloescht", "Host", hostId, { hostname: host.hostname });
|
||||||
|
|
||||||
|
revalidatePath("/hosts");
|
||||||
|
revalidatePath("/");
|
||||||
|
redirect(await eigenerPfad("/hosts"));
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const kundeSchema = z.object({
|
|||||||
ansprechpartner: z.string().nullable(),
|
ansprechpartner: z.string().nullable(),
|
||||||
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
email: z.union([z.string().email("Keine gültige E-Mail-Adresse."), z.null()]),
|
||||||
telefon: z.string().nullable(),
|
telefon: z.string().nullable(),
|
||||||
|
eigenbedarf: z.boolean().default(false),
|
||||||
notizen: z.string().nullable(),
|
notizen: z.string().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export async function kundeSpeichern(_vorher: AktionsErgebnis, formular: FormDat
|
|||||||
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
ansprechpartner: leerZuNull(formular.get("ansprechpartner")),
|
||||||
email: leerZuNull(formular.get("email")),
|
email: leerZuNull(formular.get("email")),
|
||||||
telefon: leerZuNull(formular.get("telefon")),
|
telefon: leerZuNull(formular.get("telefon")),
|
||||||
|
eigenbedarf: formular.get("eigenbedarf") === "an",
|
||||||
notizen: leerZuNull(formular.get("notizen")),
|
notizen: leerZuNull(formular.get("notizen")),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
einkaufBezahltSetzen,
|
einkaufBezahltSetzen,
|
||||||
kundenrechnungErfassen,
|
kundenrechnungErfassen,
|
||||||
laufzeitAnlegen,
|
laufzeitAnlegen,
|
||||||
|
laufzeitBearbeiten,
|
||||||
|
laufzeitenZusammenfuehren,
|
||||||
laufzeitBestaetigen,
|
laufzeitBestaetigen,
|
||||||
laufzeitVerwerfen,
|
laufzeitVerwerfen,
|
||||||
proxmoxRechnungErfassen,
|
proxmoxRechnungErfassen,
|
||||||
@@ -173,3 +175,46 @@ export async function laufzeitAnlegenAktion(
|
|||||||
return "Laufzeit angelegt.";
|
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.";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { tokenAusHeader } from "@/lib/tokens";
|
|||||||
import { AgentApiFehler } from "./service";
|
import { AgentApiFehler } from "./service";
|
||||||
|
|
||||||
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
/** Version des Agent-Skripts, die das Dashboard ausliefert (siehe #005). */
|
||||||
export const AGENT_VERSION = "0.2.1";
|
export const AGENT_VERSION = "0.2.2";
|
||||||
|
|
||||||
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
export function fehlerAntwort(status: number, code: string, nachricht: string, extra?: HeadersInit) {
|
||||||
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
return Response.json({ ok: false, code, fehler: nachricht }, { status, headers: extra });
|
||||||
|
|||||||
@@ -9,15 +9,23 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
|
|||||||
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
|
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
|
||||||
const [hosts, laufzeiten, einstellungen] = await Promise.all([
|
const [hosts, laufzeiten, einstellungen] = await Promise.all([
|
||||||
prisma.host.findMany({
|
prisma.host.findMany({
|
||||||
|
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
|
||||||
|
where: { archiviertAm: null },
|
||||||
include: {
|
include: {
|
||||||
kunde: { select: { name: true } },
|
kunde: { select: { name: true } },
|
||||||
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
|
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.laufzeit.findMany({
|
prisma.laufzeit.findMany({
|
||||||
where: { status: "BESTAETIGT" },
|
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
|
||||||
include: {
|
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 } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { include: { rechnung: true } },
|
kundenPosition: { include: { rechnung: true } },
|
||||||
},
|
},
|
||||||
@@ -40,6 +48,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
|||||||
subLizenzierteSockets: host.subLizenzierteSockets,
|
subLizenzierteSockets: host.subLizenzierteSockets,
|
||||||
sockets: host.sockets,
|
sockets: host.sockets,
|
||||||
letzterReportAm: host.letzterReportAm,
|
letzterReportAm: host.letzterReportAm,
|
||||||
|
ips: host.ips,
|
||||||
serverIds: host.serverIdHistorie,
|
serverIds: host.serverIdHistorie,
|
||||||
})),
|
})),
|
||||||
laufzeiten: laufzeiten.map((laufzeit) => ({
|
laufzeiten: laufzeiten.map((laufzeit) => ({
|
||||||
@@ -48,6 +57,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
|
|||||||
hostname: laufzeit.host.hostname,
|
hostname: laufzeit.host.hostname,
|
||||||
kundeId: laufzeit.host.kundeId,
|
kundeId: laufzeit.host.kundeId,
|
||||||
kundeName: laufzeit.host.kunde?.name ?? null,
|
kundeName: laufzeit.host.kunde?.name ?? null,
|
||||||
|
kundeEigenbedarf: laufzeit.host.kunde?.eigenbedarf ?? false,
|
||||||
key: laufzeit.key,
|
key: laufzeit.key,
|
||||||
start: laufzeit.start,
|
start: laufzeit.start,
|
||||||
ende: laufzeit.ende,
|
ende: laufzeit.ende,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface HostDaten {
|
|||||||
subLizenzierteSockets: number | null;
|
subLizenzierteSockets: number | null;
|
||||||
sockets: number | null;
|
sockets: number | null;
|
||||||
letzterReportAm: Date | null;
|
letzterReportAm: Date | null;
|
||||||
|
ips: string[];
|
||||||
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
|
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +41,8 @@ export interface LaufzeitDaten {
|
|||||||
hostId: string;
|
hostId: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
kundeId: string | null;
|
kundeId: string | null;
|
||||||
|
/** Eigene Hosts werden nicht weiterberechnet – die Verkaufsseite entfällt (#044). */
|
||||||
|
kundeEigenbedarf: boolean;
|
||||||
kundeName: string | null;
|
kundeName: string | null;
|
||||||
key: string;
|
key: string;
|
||||||
start: Date;
|
start: Date;
|
||||||
@@ -358,7 +361,10 @@ const kundenrechnungFehlt: Regel = {
|
|||||||
standardSchwellwerte: {},
|
standardSchwellwerte: {},
|
||||||
pruefe(kontext) {
|
pruefe(kontext) {
|
||||||
return kontext.laufzeiten
|
return kontext.laufzeiten
|
||||||
.filter((laufzeit) => laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt)
|
.filter(
|
||||||
|
(laufzeit) =>
|
||||||
|
laufzeit.einkaufNummer !== null && !laufzeit.verkaufErstellt && !laufzeit.kundeEigenbedarf,
|
||||||
|
)
|
||||||
.filter((laufzeit) => laufzeit.kundeId !== null)
|
.filter((laufzeit) => laufzeit.kundeId !== null)
|
||||||
.map((laufzeit) => ({
|
.map((laufzeit) => ({
|
||||||
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
fingerprint: `kundenrechnung-fehlt:${laufzeit.id}`,
|
||||||
@@ -383,6 +389,7 @@ const kundenrechnungUnbezahlt: Regel = {
|
|||||||
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
const tage = schwellwert(kontext, "kundenrechnung-unbezahlt", "tage", 30);
|
||||||
|
|
||||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||||
|
if (laufzeit.kundeEigenbedarf) return [];
|
||||||
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
if (!laufzeit.verkaufErstellt || laufzeit.verkaufBezahlt || !laufzeit.verkaufDatum) return [];
|
||||||
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
const offenSeit = -tageBis(laufzeit.verkaufDatum, kontext.jetzt);
|
||||||
if (offenSeit < tage) return [];
|
if (offenSeit < tage) return [];
|
||||||
@@ -405,6 +412,51 @@ const kundenrechnungUnbezahlt: Regel = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwei Hosts desselben Kunden mit demselben Namen (#041).
|
||||||
|
*
|
||||||
|
* Das ist entweder gewollt – zwei Maschinen in getrennten Netzen heißen eben gleich – oder ein
|
||||||
|
* Doppeleintrag: Hat eine Maschine ihre Kennung aus `/etc/machine-id` verloren (Neuinstallation,
|
||||||
|
* geklonter Container), registriert sie sich als neuer Host, und der alte Eintrag bleibt stehen.
|
||||||
|
* Welcher Fall vorliegt, weiß nur der Betreiber – deshalb ein Hinweis mit den IP-Adressen zum
|
||||||
|
* Unterscheiden und nicht mehr.
|
||||||
|
*/
|
||||||
|
const hostnameDoppelt: Regel = {
|
||||||
|
name: "hostname-doppelt",
|
||||||
|
beschreibung: "Mehrere Hosts desselben Kunden tragen denselben Namen",
|
||||||
|
standardSchwellwerte: {},
|
||||||
|
pruefe(kontext) {
|
||||||
|
const gruppen = new Map<string, HostDaten[]>();
|
||||||
|
for (const host of kontext.hosts) {
|
||||||
|
// Ohne Kunde ist der Vergleich wenig wert: Gleiche Namen sind dort der Normalfall.
|
||||||
|
if (!host.kundeId) continue;
|
||||||
|
const schluessel = `${host.kundeId}:${host.hostname.toLowerCase()}`;
|
||||||
|
gruppen.set(schluessel, [...(gruppen.get(schluessel) ?? []), host]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...gruppen.values()]
|
||||||
|
.filter((gruppe) => gruppe.length > 1)
|
||||||
|
.map((gruppe) => {
|
||||||
|
const beschreibungen = gruppe.map(
|
||||||
|
(host) => `${host.ips.join(", ") || "keine IP gemeldet"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Der Fingerabdruck hängt an Kunde und Name, nicht an den einzelnen Hosts – sonst gäbe
|
||||||
|
// es für jede Maschine eine eigene Warnung über denselben Sachverhalt.
|
||||||
|
fingerprint: `hostname-doppelt:${gruppe[0].kundeId}:${gruppe[0].hostname.toLowerCase()}`,
|
||||||
|
regel: "hostname-doppelt",
|
||||||
|
schwere: "INFO" as AlertSchwere,
|
||||||
|
titel: `${gruppe.length} Hosts heißen „${gruppe[0].hostname}“`,
|
||||||
|
beschreibung: `${kundenHinweis(gruppe[0].kundeName)} · ${beschreibungen.join(" | ")} · Entweder getrennte Maschinen – dann helfen Schlagworte – oder ein Doppeleintrag nach Neuinstallation; den alten Host dann stilllegen`,
|
||||||
|
hostId: gruppe[0].id,
|
||||||
|
kundeId: gruppe[0].kundeId,
|
||||||
|
laufzeitId: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
|
* Der übliche Ablauf: Erst kommt die unbezahlte Rechnung von Proxmox, dann stellt man dem Kunden
|
||||||
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
|
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
|
||||||
@@ -417,6 +469,8 @@ const kundeBezahltEinkaufOffen: Regel = {
|
|||||||
standardSchwellwerte: {},
|
standardSchwellwerte: {},
|
||||||
pruefe(kontext) {
|
pruefe(kontext) {
|
||||||
return kontext.laufzeiten.flatMap((laufzeit) => {
|
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 [];
|
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -450,6 +504,7 @@ export const REGELN: Regel[] = [
|
|||||||
kundenrechnungFehlt,
|
kundenrechnungFehlt,
|
||||||
kundenrechnungUnbezahlt,
|
kundenrechnungUnbezahlt,
|
||||||
kundeBezahltEinkaufOffen,
|
kundeBezahltEinkaufOffen,
|
||||||
|
hostnameDoppelt,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Wendet alle aktiven Regeln an. */
|
/** Wendet alle aktiven Regeln an. */
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
|
|||||||
const einkaufSichtbar = await darfEinkaufSehen();
|
const einkaufSichtbar = await darfEinkaufSehen();
|
||||||
|
|
||||||
const hosts = await prisma.host.findMany({
|
const hosts = await prisma.host.findMany({
|
||||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||||
orderBy: { subNextduedate: "asc" },
|
orderBy: { subNextduedate: "asc" },
|
||||||
include: {
|
include: {
|
||||||
kunde: { select: { id: true, name: true } },
|
kunde: { select: { id: true, name: true } },
|
||||||
@@ -72,7 +72,7 @@ export async function offeneAufgaben(grenze = 12) {
|
|||||||
},
|
},
|
||||||
orderBy: { start: "asc" },
|
orderBy: { start: "asc" },
|
||||||
include: {
|
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 } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { 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({
|
aufgaben.push({
|
||||||
id: `${laufzeit.id}-verkauf`,
|
id: `${laufzeit.id}-verkauf`,
|
||||||
titel: "Kundenrechnung erstellen",
|
titel: "Kundenrechnung erstellen",
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
|||||||
export interface HostFilter {
|
export interface HostFilter {
|
||||||
q?: string;
|
q?: string;
|
||||||
kunde?: string;
|
kunde?: string;
|
||||||
|
/** Schlagwort, nach dem gefiltert wird (#040). */
|
||||||
|
tag?: string;
|
||||||
|
/** Stillgelegte Hosts mitzeigen (#041). */
|
||||||
|
archiviert?: "ja";
|
||||||
status?: string;
|
status?: string;
|
||||||
produkt?: string;
|
produkt?: string;
|
||||||
ablauf?: "30" | "14" | "abgelaufen";
|
ablauf?: "30" | "14" | "abgelaufen";
|
||||||
@@ -20,6 +24,13 @@ export const SEITENGROESSE = 25;
|
|||||||
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
||||||
const und: Prisma.HostWhereInput[] = [];
|
const und: Prisma.HostWhereInput[] = [];
|
||||||
|
|
||||||
|
// Stillgelegte Hosts erscheinen nur, wenn ausdrücklich danach gefragt wird.
|
||||||
|
if (filter.archiviert !== "ja") und.push({ archiviertAm: null });
|
||||||
|
|
||||||
|
if (filter.tag) {
|
||||||
|
und.push({ tags: { some: { name: filter.tag } } });
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.q && filter.q.trim().length > 0) {
|
if (filter.q && filter.q.trim().length > 0) {
|
||||||
const suche = filter.q.trim();
|
const suche = filter.q.trim();
|
||||||
und.push({
|
und.push({
|
||||||
@@ -30,6 +41,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
|||||||
{ subKey: { contains: suche, mode: "insensitive" } },
|
{ subKey: { contains: suche, mode: "insensitive" } },
|
||||||
{ ips: { has: suche } },
|
{ ips: { has: suche } },
|
||||||
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
|
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
|
||||||
|
{ notizen: { contains: suche, mode: "insensitive" } },
|
||||||
|
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
|
||||||
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
|
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
|
||||||
{
|
{
|
||||||
laufzeiten: {
|
laufzeiten: {
|
||||||
@@ -113,6 +126,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
|
|||||||
include: {
|
include: {
|
||||||
kunde: { select: { id: true, name: true } },
|
kunde: { select: { id: true, name: true } },
|
||||||
standort: { select: { name: true } },
|
standort: { select: { name: true } },
|
||||||
|
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -181,5 +195,12 @@ export async function filterOptionen() {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
});
|
});
|
||||||
return { kunden };
|
|
||||||
|
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: { hosts: { some: kundeId ? { kundeId } : {} } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return { kunden, tags: tags.map((tag) => tag.name) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
|
|||||||
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
|
return new Date(jetzt.getTime() - MELDUNG_FRIST_STUNDEN * 3_600_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Stillgelegte Hosts zählen nirgends mit (#041). */
|
||||||
|
const NICHT_ARCHIVIERT = { archiviertAm: null } as const;
|
||||||
|
|
||||||
export async function hostsOhneKunde() {
|
export async function hostsOhneKunde() {
|
||||||
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
|
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
|
||||||
// noch niemandem, dürfen also auch niemandem gezeigt werden.
|
// noch niemandem, dürfen also auch niemandem gezeigt werden.
|
||||||
if (await sichtbarerKunde()) return [];
|
if (await sichtbarerKunde()) return [];
|
||||||
|
|
||||||
return prisma.host.findMany({
|
return prisma.host.findMany({
|
||||||
where: { kundeId: null },
|
where: { kundeId: null, ...NICHT_ARCHIVIERT },
|
||||||
orderBy: { erstmalsGesehenAm: "desc" },
|
orderBy: { erstmalsGesehenAm: "desc" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hostAnzahl() {
|
export async function hostAnzahl() {
|
||||||
const kundeId = await sichtbarerKunde();
|
const kundeId = await sichtbarerKunde();
|
||||||
return prisma.host.count({ where: kundeId ? { kundeId } : {} });
|
return prisma.host.count({ where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
/** Kennzahlen für Dashboard-Kacheln und Sidebar-Badges. */
|
||||||
export async function kennzahlen(jetzt: Date = new Date()) {
|
export async function kennzahlen(jetzt: Date = new Date()) {
|
||||||
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
|
||||||
const kundeId = await sichtbarerKunde();
|
const kundeId = await sichtbarerKunde();
|
||||||
const hostSicht = kundeId ? { kundeId } : {};
|
const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
|
||||||
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
|
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
|
||||||
|
|
||||||
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
|
||||||
prisma.host.count({ where: hostSicht }),
|
prisma.host.count({ where: hostSicht }),
|
||||||
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
|
prisma.host.count({ where: { ...hostSicht, subStatus: "ACTIVE" } }),
|
||||||
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null } }),
|
kundeId ? Promise.resolve(0) : prisma.host.count({ where: { kundeId: null, ...NICHT_ARCHIVIERT } }),
|
||||||
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
prisma.host.count({ where: { ...hostSicht, subNextduedate: { gte: jetzt, lte: in30Tagen } } }),
|
||||||
prisma.host.count({
|
prisma.host.count({
|
||||||
where: {
|
where: {
|
||||||
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
|||||||
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
|
OR: [{ letzterReportAm: null }, { letzterReportAm: { lt: meldungsGrenze(jetzt) } }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.alert.count({ where: { erledigtAm: null, ...(kundeId ? { host: { kundeId } } : {}) } }),
|
prisma.alert.count({
|
||||||
|
where: {
|
||||||
|
erledigtAm: null,
|
||||||
|
OR: [{ hostId: null }, { host: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) } }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
const [offeneEinkaufsrechnungen, fehlendeKundenrechnungen] = await Promise.all([
|
||||||
@@ -59,6 +67,8 @@ export async function kennzahlen(jetzt: Date = new Date()) {
|
|||||||
...laufzeitSicht,
|
...laufzeitSicht,
|
||||||
status: "BESTAETIGT",
|
status: "BESTAETIGT",
|
||||||
proxmoxPosition: { isNot: null },
|
proxmoxPosition: { isNot: null },
|
||||||
|
// Eigenbedarf wird nicht weiterberechnet – hier ist nichts offen (#044).
|
||||||
|
host: { kunde: { eigenbedarf: false } },
|
||||||
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
OR: [{ kundenPosition: null }, { kundenPosition: { rechnung: { erstellt: false } } }],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -94,7 +104,7 @@ export async function suchIndex() {
|
|||||||
|
|
||||||
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
|
||||||
prisma.host.findMany({
|
prisma.host.findMany({
|
||||||
where: kundeId ? { kundeId } : {},
|
where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
@@ -103,7 +113,9 @@ export async function suchIndex() {
|
|||||||
subKey: true,
|
subKey: true,
|
||||||
ips: true,
|
ips: true,
|
||||||
produkt: true,
|
produkt: true,
|
||||||
|
notizen: true,
|
||||||
kunde: { select: { name: true } },
|
kunde: { select: { name: true } },
|
||||||
|
tags: { select: { name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.kunde.findMany({
|
prisma.kunde.findMany({
|
||||||
@@ -128,9 +140,17 @@ export async function suchIndex() {
|
|||||||
titel: host.hostname,
|
titel: host.hostname,
|
||||||
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
||||||
host.subKey ? ` · ${host.subKey}` : ""
|
host.subKey ? ` · ${host.subKey}` : ""
|
||||||
}`,
|
}${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
|
||||||
href: `/hosts/${host.id}`,
|
href: `/hosts/${host.id}`,
|
||||||
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips]
|
begriffe: [
|
||||||
|
host.hostname,
|
||||||
|
host.fqdn,
|
||||||
|
host.serverId,
|
||||||
|
host.subKey,
|
||||||
|
host.notizen,
|
||||||
|
...host.tags.map((tag) => tag.name),
|
||||||
|
...host.ips,
|
||||||
|
]
|
||||||
.filter((w): w is string => Boolean(w))
|
.filter((w): w is string => Boolean(w))
|
||||||
.map((w) => w.toLowerCase()),
|
.map((w) => w.toLowerCase()),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
|
|||||||
const nurKunde = await sichtbarerKunde();
|
const nurKunde = await sichtbarerKunde();
|
||||||
|
|
||||||
const hosts = await prisma.host.findMany({
|
const hosts = await prisma.host.findMany({
|
||||||
where: { subNextduedate: { not: null }, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
where: { subNextduedate: { not: null }, archiviertAm: null, ...(nurKunde ? { kundeId: nurKunde } : {}) },
|
||||||
orderBy: [{ subNextduedate: "asc" }],
|
orderBy: [{ subNextduedate: "asc" }],
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function kundenListe() {
|
|||||||
return {
|
return {
|
||||||
id: kunde.id,
|
id: kunde.id,
|
||||||
name: kunde.name,
|
name: kunde.name,
|
||||||
|
eigenbedarf: kunde.eigenbedarf,
|
||||||
kundennummer: kunde.kundennummer,
|
kundennummer: kunde.kundennummer,
|
||||||
ansprechpartner: kunde.ansprechpartner,
|
ansprechpartner: kunde.ansprechpartner,
|
||||||
hosts: kunde._count.hosts,
|
hosts: kunde._count.hosts,
|
||||||
|
|||||||
@@ -67,7 +67,13 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
where,
|
where,
|
||||||
orderBy: [{ start: "desc" }],
|
orderBy: [{ start: "desc" }],
|
||||||
include: {
|
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 } },
|
proxmoxPosition: { include: { rechnung: true } },
|
||||||
kundenPosition: { 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)
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const verkauf = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : 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 {
|
return {
|
||||||
id: laufzeit.id,
|
id: laufzeit.id,
|
||||||
@@ -101,21 +109,26 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
verkaufBetrag: verkauf,
|
verkaufBetrag: verkauf,
|
||||||
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
verkaufErstellt: laufzeit.kundenPosition?.rechnung.erstellt ?? false,
|
||||||
verkaufBezahlt: laufzeit.kundenPosition?.rechnung.bezahlt ?? 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(
|
const summen = zeilen.reduce(
|
||||||
(summe, zeile) => ({
|
(summe, zeile) => ({
|
||||||
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
|
einkauf: summe.einkauf + (zeile.einkaufBetrag ?? 0),
|
||||||
verkauf: summe.verkauf + (zeile.verkaufBetrag ?? 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 {
|
return {
|
||||||
zeilen,
|
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,
|
id: true,
|
||||||
hostname: true,
|
hostname: true,
|
||||||
produkt: true,
|
produkt: true,
|
||||||
kunde: { select: { id: true, name: true } },
|
kunde: { select: { id: true, name: true, eigenbedarf: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
proxmoxPosition: {
|
proxmoxPosition: {
|
||||||
|
|||||||
@@ -324,3 +324,104 @@ export async function laufzeitAnlegen(daten: {
|
|||||||
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
|
await protokolliere("laufzeit.angelegt", "Laufzeit", laufzeit.id, daten);
|
||||||
return laufzeit;
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -241,6 +241,25 @@ productname: Proxmox "VE" \\ Subscription mit Tab`,
|
|||||||
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
|
expect(report.host.clusterMitglieder).toEqual(["hv-rz5-pve-01", "hv-rz5-pve-02", "hv-rz5-pve-03"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("liest die Knoten auch mit QDevice-Spalte richtig", () => {
|
||||||
|
// Mit einem QDevice steht zwischen Votes und Name dessen Zustand. Vorher landeten
|
||||||
|
// „A,V,NMW“ und „NR“ als Knotennamen im Report (#042).
|
||||||
|
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||||
|
befehlErsetzen(
|
||||||
|
"pvecm",
|
||||||
|
`Membership information
|
||||||
|
----------------------
|
||||||
|
Nodeid Votes Qdevice Name
|
||||||
|
1 1 A,V,NMW hv-pve-01
|
||||||
|
2 1 A,V,NMW hv-pve-02 (local)
|
||||||
|
3 1 NR hv-pve-05
|
||||||
|
0 1 Qdevice (votes 1)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const report = reportErzeugen() as { host: { clusterMitglieder: string[] } };
|
||||||
|
expect(report.host.clusterMitglieder).toEqual(["hv-pve-01", "hv-pve-02", "hv-pve-05"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
|
it("meldet eine leere Liste, wenn der Host in keinem Cluster ist", () => {
|
||||||
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
befehlErsetzen("pvesubscription", PVE_SUBSCRIPTION);
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
|
|||||||
|
|
||||||
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
|
||||||
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
|
||||||
expect(einstellungen).toHaveLength(12);
|
expect(einstellungen).toHaveLength(13);
|
||||||
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
|
||||||
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Hosts stilllegen statt löschen – und löschen nur, wenn nichts daran hängt (#041). */
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
|
||||||
|
vi.mock("next/navigation", () => ({ redirect: () => {} }));
|
||||||
|
|
||||||
|
const { hostArchivieren, hostLoeschen } = await import("@/server/actions/hosts");
|
||||||
|
const { hostListe } = await import("@/server/queries/hostliste");
|
||||||
|
|
||||||
|
function formular(werte: Record<string, string>) {
|
||||||
|
const daten = new FormData();
|
||||||
|
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function host(hostname = "hv-pve-01") {
|
||||||
|
return testPrisma.host.create({ data: { agentId: `a-${hostname}`, hostname, produkt: "PVE" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Hosts stilllegen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt einen stillgelegten Host aus der Liste", async () => {
|
||||||
|
const h = await host();
|
||||||
|
expect((await hostListe({})).gesamt).toBe(1);
|
||||||
|
|
||||||
|
await hostArchivieren(formular({ hostId: h.id }));
|
||||||
|
|
||||||
|
expect((await hostListe({})).gesamt).toBe(0);
|
||||||
|
expect((await hostListe({ archiviert: "ja" })).gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schließt beim Stilllegen die offenen Warnungen", async () => {
|
||||||
|
const h = await host();
|
||||||
|
await testPrisma.alert.create({
|
||||||
|
data: {
|
||||||
|
regel: "status-nicht-aktiv",
|
||||||
|
fingerprint: `status-nicht-aktiv:${h.id}`,
|
||||||
|
schwere: "WARNUNG",
|
||||||
|
titel: "Keine Subscription gefunden",
|
||||||
|
beschreibung: "…",
|
||||||
|
hostId: h.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostArchivieren(formular({ hostId: h.id }));
|
||||||
|
|
||||||
|
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: h.id } });
|
||||||
|
expect(alert.erledigtAm).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt einen Host auf Wunsch wieder auf", async () => {
|
||||||
|
const h = await host();
|
||||||
|
await hostArchivieren(formular({ hostId: h.id }));
|
||||||
|
await hostArchivieren(formular({ hostId: h.id }));
|
||||||
|
|
||||||
|
expect((await hostListe({})).gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löscht einen Host ohne Laufzeiten", async () => {
|
||||||
|
const h = await host();
|
||||||
|
await hostLoeschen(formular({ hostId: h.id }));
|
||||||
|
|
||||||
|
expect(await testPrisma.host.count()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löscht keinen Host, an dem eine Laufzeit hängt", async () => {
|
||||||
|
const h = await host();
|
||||||
|
await testPrisma.laufzeit.create({
|
||||||
|
data: {
|
||||||
|
hostId: h.id,
|
||||||
|
key: "pve1c-aaaa",
|
||||||
|
start: new Date("2026-01-01"),
|
||||||
|
ende: new Date("2026-12-31"),
|
||||||
|
status: "BESTAETIGT",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostLoeschen(formular({ hostId: h.id }));
|
||||||
|
|
||||||
|
// Buchhaltung bleibt: Der Host ist noch da und lässt sich nur stilllegen.
|
||||||
|
expect(await testPrisma.host.count()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Schlagworte und Notizen in Liste, Filter und Suche (#040). */
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
sitzungErforderlich: async () => {
|
||||||
|
throw new Error("in diesem Test nicht vorgesehen");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { hostListe, filterOptionen } = await import("@/server/queries/hostliste");
|
||||||
|
const { suchIndex } = await import("@/server/queries/hosts");
|
||||||
|
|
||||||
|
async function hosts() {
|
||||||
|
// Zwei Hosts mit demselben Namen bei verschiedenen Kunden – genau der Fall aus der Praxis.
|
||||||
|
const [kundeA, kundeB] = await Promise.all([
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await testPrisma.host.create({
|
||||||
|
data: {
|
||||||
|
agentId: "a-1",
|
||||||
|
hostname: "hv-pve-01",
|
||||||
|
produkt: "PVE",
|
||||||
|
kundeId: kundeA.id,
|
||||||
|
notizen: "Steht im Serverraum links",
|
||||||
|
tags: { connectOrCreate: [{ where: { name: "rechenzentrum" }, create: { name: "rechenzentrum" } }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testPrisma.host.create({
|
||||||
|
data: {
|
||||||
|
agentId: "a-2",
|
||||||
|
hostname: "hv-pve-01",
|
||||||
|
produkt: "PVE",
|
||||||
|
kundeId: kundeB.id,
|
||||||
|
tags: { connectOrCreate: [{ where: { name: "aussenstelle" }, create: { name: "aussenstelle" } }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Schlagworte und Notizen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await hosts();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert die Schlagworte zu jedem Host mit", async () => {
|
||||||
|
const liste = await hostListe({});
|
||||||
|
const namen = liste.hosts.flatMap((host) => host.tags.map((tag) => tag.name)).sort();
|
||||||
|
expect(namen).toEqual(["aussenstelle", "rechenzentrum"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Schlagwort", async () => {
|
||||||
|
const liste = await hostListe({ tag: "aussenstelle" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
expect(liste.hosts[0].kunde?.name).toBe("Kunde B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findet einen Host über sein Schlagwort", async () => {
|
||||||
|
const liste = await hostListe({ q: "rechenzentrum" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
expect(liste.hosts[0].kunde?.name).toBe("Kunde A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findet einen Host über seine Notiz", async () => {
|
||||||
|
const liste = await hostListe({ q: "serverraum" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bietet nur vergebene Schlagworte zur Auswahl an", async () => {
|
||||||
|
const { tags } = await filterOptionen();
|
||||||
|
expect(tags).toEqual(["aussenstelle", "rechenzentrum"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt Schlagworte und Notizen in die globale Suche auf", async () => {
|
||||||
|
const eintraege = await suchIndex();
|
||||||
|
const treffer = eintraege.filter((eintrag) => eintrag.begriffe.includes("rechenzentrum"));
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0].untertitel).toContain("rechenzentrum");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
|
|||||||
subLizenzierteSockets: 1,
|
subLizenzierteSockets: 1,
|
||||||
sockets: 1,
|
sockets: 1,
|
||||||
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
|
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
|
||||||
|
ips: ["10.0.0.10"],
|
||||||
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
|
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
|
||||||
...überschreibungen,
|
...überschreibungen,
|
||||||
};
|
};
|
||||||
@@ -40,6 +41,7 @@ function laufzeit(überschreibungen: Partial<LaufzeitDaten> = {}): LaufzeitDaten
|
|||||||
hostname: "pve-01",
|
hostname: "pve-01",
|
||||||
kundeId: "k1",
|
kundeId: "k1",
|
||||||
kundeName: "Brandl Elektrotechnik GmbH",
|
kundeName: "Brandl Elektrotechnik GmbH",
|
||||||
|
kundeEigenbedarf: false,
|
||||||
key: "pve1c-aaaa",
|
key: "pve1c-aaaa",
|
||||||
start: inTagen(-100),
|
start: inTagen(-100),
|
||||||
ende: inTagen(265),
|
ende: inTagen(265),
|
||||||
@@ -72,9 +74,9 @@ function ausRegel(k: RegelKontext, name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("Warnregeln", () => {
|
describe("Warnregeln", () => {
|
||||||
it("prüft alle zwölf Regeln, wenn nichts abgeschaltet ist", () => {
|
it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
|
||||||
expect(REGELN).toHaveLength(12);
|
expect(REGELN).toHaveLength(13);
|
||||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(12);
|
expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Subscription läuft ab", () => {
|
describe("Subscription läuft ab", () => {
|
||||||
@@ -414,4 +416,57 @@ describe("Warnregeln", () => {
|
|||||||
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Gleicher Hostname beim selben Kunden", () => {
|
||||||
|
it("meldet zwei gleichnamige Hosts desselben Kunden – mit IPs zum Unterscheiden", () => {
|
||||||
|
const k = kontext({
|
||||||
|
hosts: [
|
||||||
|
host({ id: "h1", hostname: "hv-pve-01", ips: ["10.10.5.32"] }),
|
||||||
|
host({ id: "h2", hostname: "hv-pve-01", ips: ["10.10.12.33", "10.10.5.11"] }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0].titel).toBe("2 Hosts heißen „hv-pve-01“");
|
||||||
|
expect(treffer[0].beschreibung).toContain("10.10.5.32");
|
||||||
|
expect(treffer[0].beschreibung).toContain("10.10.12.33");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt bei gleichem Namen in verschiedenen Kundennetzen", () => {
|
||||||
|
const k = kontext({
|
||||||
|
hosts: [
|
||||||
|
host({ id: "h1", hostname: "hv-pve-01", kundeId: "k1" }),
|
||||||
|
host({ id: "h2", hostname: "hv-pve-01", kundeId: "k2" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt bei Hosts ohne Kunde", () => {
|
||||||
|
const k = kontext({
|
||||||
|
hosts: [
|
||||||
|
host({ id: "h1", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
|
||||||
|
host({ id: "h2", hostname: "hv-pve-01", kundeId: null, kundeName: null }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("erzeugt für drei gleichnamige Hosts nur eine Warnung", () => {
|
||||||
|
const k = kontext({
|
||||||
|
hosts: [
|
||||||
|
host({ id: "h1", hostname: "hv-pve-01" }),
|
||||||
|
host({ id: "h2", hostname: "hv-pve-01" }),
|
||||||
|
host({ id: "h3", hostname: "HV-PVE-01" }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "hostname-doppelt");
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0].titel).toContain("3 Hosts");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||