Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b745910afa | ||
|
|
840e902b26 | ||
|
|
5e5028c53d | ||
|
|
83320f0443 | ||
|
|
f076f49631 | ||
|
|
8e5bf74a30 | ||
|
|
02845ed717 | ||
|
|
3657b60d16 | ||
|
|
f86a328b3c | ||
|
|
b2664de62f | ||
|
|
dfb2118bac | ||
|
|
335fc47496 | ||
|
|
e570d30019 | ||
|
|
c5293f5915 | ||
|
|
c1177067e6 | ||
|
|
e50fb30ab3 | ||
|
|
97f6450f1f | ||
|
|
ca2af0c2fc | ||
|
|
9c873a4069 | ||
|
|
09bb98235b |
@@ -17,6 +17,94 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [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
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
|
||||||
|
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
|
||||||
|
|
||||||
|
## [0.22.1] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
|
||||||
|
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
|
||||||
|
|
||||||
|
## [0.22.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- **Der Rabatt gehört jetzt zur Rechnungsposition statt zur Rechnung** – so, wie Proxmox abrechnet: je Subscription-Key eine Zeile mit Listenpreis und eine mit „Client Discount“. Nur so bleibt er bei einer Sammelrechnung dem richtigen Host zugeordnet (#037)
|
||||||
|
- Der Rechnungsbetrag ist die Summe der Positionen nach Abzug ihrer Rabatte; Marge und Summen rechnen mit dem tatsächlich gezahlten Betrag (#037)
|
||||||
|
- Eigener Steuersatz für den Einkauf, Vorgabe 0 % wegen Reverse Charge (#037)
|
||||||
|
- Das Betragsfeld heißt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird (#037)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Zwölfte Warnregel `kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen – der Moment, an dem man selbst zahlt (#037)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- Der Rabatt wurde bisher **nirgends abgezogen**, weder in der Summe noch in der Marge (#037)
|
||||||
|
|
||||||
|
## [0.21.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
|
||||||
|
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
|
||||||
|
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
|
||||||
|
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
|
||||||
|
|
||||||
|
## [0.20.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
|
||||||
|
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
|
||||||
|
|
||||||
## [0.20.1] – 2026-10-03
|
## [0.20.1] – 2026-10-03
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 16 h 58 min
|
**Gesamt:** 24 h 38 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -39,6 +39,17 @@
|
|||||||
| #031 Update für beide Varianten | 0 h 45 min |
|
| #031 Update für beide Varianten | 0 h 45 min |
|
||||||
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
||||||
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
||||||
|
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||||
|
| #035 Tabellen als Karten | 0 h 50 min |
|
||||||
|
| #036 Standort am Host | 0 h 30 min |
|
||||||
|
| #037 Rabatt je Position | 0 h 55 min |
|
||||||
|
| #038 Überbreite auf dem Handy | 0 h 40 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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -61,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.20.1
|
**Version:** 0.26.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -179,6 +190,40 @@
|
|||||||
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
||||||
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
||||||
|
|
||||||
|
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
|
||||||
|
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
|
||||||
|
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
|
||||||
|
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
|
||||||
|
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
|
||||||
|
|
||||||
|
- Eine echte Proxmox-Rechnung hat drei Fehler im Rechnungsteil aufgedeckt (#037): Rabatt an der falschen Ebene, nirgends abgezogen, und 19 % USt als Vorgabe, wo Reverse Charge gilt
|
||||||
|
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
|
||||||
|
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
|
||||||
|
|
||||||
|
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
|
||||||
|
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
||||||
|
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
||||||
|
|
||||||
|
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
### 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
|
||||||
|
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
|
||||||
|
|
||||||
## 4a. Cluster
|
## 4a. Cluster
|
||||||
|
|
||||||
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
||||||
@@ -189,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
|
||||||
@@ -206,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
|
||||||
@@ -248,6 +289,39 @@ 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
|
||||||
|
|
||||||
|
1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer.
|
||||||
|
2. Die Kundenrechnung wird geschrieben und verschickt.
|
||||||
|
3. Der Kunde zahlt. Jetzt meldet ProxLedger: *Kunde hat bezahlt – Proxmox-Rechnung noch offen*.
|
||||||
|
4. Man zahlt bei Proxmox, setzt den Haken „bezahlt“ und lädt die **PAID**-Fassung des PDFs hoch – sie
|
||||||
|
ersetzt die alte.
|
||||||
|
|
||||||
|
### Die Felder der Proxmox-Rechnung
|
||||||
|
|
||||||
|
| Feld | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| Rechnungsnummer | Nummer von Proxmox. **Dieselbe Nummer bei einer weiteren Laufzeit** hängt diese als Position an dieselbe Rechnung – so werden Sammelrechnungen über mehrere Hosts abgebildet. |
|
||||||
|
| Listenpreis netto | Der Betrag dieser **einen** Laufzeit, wie er auf der Rechnung steht (z. B. 1.100 €). |
|
||||||
|
| Rabatt | Der „Client Discount“ dieser Laufzeit (z. B. 110 €). Gerechnet wird mit Listenpreis minus Rabatt. |
|
||||||
|
| USt | Bei Proxmox in aller Regel 0 – Reverse Charge nach Art. 196. Die Vorgabe steht unter *Einstellungen → System*. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. Warnungen
|
## 7. Warnungen
|
||||||
@@ -473,6 +547,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
|||||||
|
|
||||||
## Anhang: dunkles Design und Mobilansicht
|
## Anhang: dunkles Design und Mobilansicht
|
||||||
|
|
||||||
|
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
|
||||||
|
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
|
||||||
|
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
|
||||||
|
übrigen Angaben stehen auf der jeweiligen Detailseite.
|
||||||
|
|
||||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||||
Kopfleiste umschalten.
|
Kopfleiste umschalten.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 196 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: 104 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 86 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: 168 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
# #034 Bedienung auf dem Handy
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.2
|
||||||
|
**Labels:** ui, bug
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
|
||||||
|
rechts die Hälfte abgeschnitten war.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
|
||||||
|
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
|
||||||
|
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
|
||||||
|
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
|
||||||
|
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
|
||||||
|
- Schließt beim Navigieren und mit Escape
|
||||||
|
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
|
||||||
|
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
|
||||||
|
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
|
||||||
|
- Fünf Tests für die Zustandslogik
|
||||||
|
|
||||||
|
## Nicht geändert
|
||||||
|
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
|
||||||
|
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# #035 Tabellen auf dem Handy als Karten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
|
||||||
|
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
|
||||||
|
brachen Buchstabe für Buchstabe um.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
|
||||||
|
ihre Beschriftung aus `data-label` davor
|
||||||
|
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
|
||||||
|
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
|
||||||
|
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
|
||||||
|
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
|
||||||
|
|
||||||
|
## Warum Karten statt Scrollen
|
||||||
|
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
|
||||||
|
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
|
||||||
|
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
|
||||||
|
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
|
||||||
|
das Kästchen behält seine Größe.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# #036 Standort am Host setzen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Den Standort bei den Hosts kann man nicht einstellen.“
|
||||||
|
|
||||||
|
## Was wirklich los war
|
||||||
|
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
|
||||||
|
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
|
||||||
|
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
|
||||||
|
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
|
||||||
|
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
|
||||||
|
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
|
||||||
|
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
|
||||||
|
Zuordnung wäre schlimmer als keine
|
||||||
|
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# #037 Rabatt je Position und der tatsächliche Ablauf
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.0
|
||||||
|
**Labels:** backend, ui
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 55 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian zeigte eine echte Proxmox-Rechnung (#291017) und beschrieb den Ablauf:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-3ef3be4d75 € 1.100,00
|
||||||
|
Proxmox VE Standard Subscription 2 CPUs/year - pve2s-b7a0d516af € 1.100,00
|
||||||
|
Client Discount - … pve2s-3ef3be4d75 € -110,00
|
||||||
|
Client Discount - … pve2s-b7a0d516af € -110,00
|
||||||
|
Sub Total € 1.980,00
|
||||||
|
```
|
||||||
|
|
||||||
|
Dazu: Man bekommt die Rechnung zuerst als **UNPAID**. Erst wenn der Kunde seine Rechnung bezahlt hat,
|
||||||
|
zahlt man selbst bei Proxmox – und bekommt dieselbe Rechnung dann als **PAID** mit Transaktion.
|
||||||
|
|
||||||
|
## Was falsch war
|
||||||
|
1. **Der Rabatt hing an der Rechnung, nicht an der Position.** Bei einer Sammelrechnung über zwei
|
||||||
|
Hosts ließe sich ein Rabatt keinem Host zuordnen; die Marge je Host wäre falsch.
|
||||||
|
2. **Er wurde nirgends abgezogen** – weder in der Summe noch in der Marge. Ein Feld, das aussieht wie
|
||||||
|
eine Rechengröße und keine ist.
|
||||||
|
3. **Vorgabe 19 % USt** für den Einkauf. Proxmox sitzt in Österreich und rechnet gegenüber Unternehmen
|
||||||
|
mit USt-IdNr. über Reverse Charge ab – richtig sind 0 %.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- `rabatt` wandert an `ProxmoxRechnungPosition`; die Migration verteilt vorhandene Werte anteilig auf
|
||||||
|
die Positionen und rechnet die Rechnungssummen neu. Nichts geht verloren.
|
||||||
|
- Rechnungsbetrag = Summe der Positionen **nach Abzug ihrer Rabatte** – wie das „Sub Total“ auf der
|
||||||
|
echten Rechnung.
|
||||||
|
- Marge und alle Summen rechnen mit dem Betrag nach Abzug.
|
||||||
|
- Das Formular heißt jetzt „Listenpreis netto“ und zeigt beim Tippen, womit gerechnet wird.
|
||||||
|
- Eigener Steuersatz für den Einkauf, Vorgabe 0 (Reverse Charge).
|
||||||
|
- Die Sammelrechnung listet je Position „1.100,00 € − 110,00 € = 990,00 €“.
|
||||||
|
|
||||||
|
## Neue Warnregel
|
||||||
|
`kunde-bezahlt-einkauf-offen`: Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen. Genau der
|
||||||
|
Zustand aus Florians Ablauf – das Geld ist da, die Rechnung auch, jetzt kann gezahlt werden. Bewusst
|
||||||
|
unabhängig vom Fälligkeitsdatum, denn die Reihenfolge ist Absicht und keine Nachlässigkeit.
|
||||||
|
|
||||||
|
## Zum PDF
|
||||||
|
Die PAID-Fassung ersetzt beim erneuten Hochladen einfach die UNPAID-Fassung – gleiche Rechnungsnummer,
|
||||||
|
gleiche Rechnung. Gleiche Dateien werden an ihrer Prüfsumme erkannt und nicht doppelt abgelegt.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# #038 Seite wird auf dem Handy herausgezoomt
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.1
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
|
||||||
|
Host-Detailansicht saß verschoben im Bild.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
|
||||||
|
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
|
||||||
|
|
||||||
|
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
|
||||||
|
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
|
||||||
|
`table-responsive` – anders als die übrigen.
|
||||||
|
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
|
||||||
|
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
|
||||||
|
- Die drei Tabellen bekommen ihre Hülle
|
||||||
|
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
|
||||||
|
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
|
||||||
|
wurde die Host-Detailansicht gegengeprüft
|
||||||
|
|
||||||
|
## Nachtrag zu #037
|
||||||
|
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
|
||||||
|
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
|
||||||
|
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.2
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
|
||||||
|
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Zwei Stellen:
|
||||||
|
|
||||||
|
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
|
||||||
|
die Rechnungsliste dorthin.
|
||||||
|
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
|
||||||
|
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
|
||||||
|
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
|
||||||
|
zwischen 768 und 992 px, wo die Tabelle am engsten ist
|
||||||
|
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
|
||||||
|
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
|
||||||
|
|
||||||
|
## Gelernt
|
||||||
|
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
|
||||||
|
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
|
||||||
|
Klasse (`pl-spalte-laufzeit`).
|
||||||
@@ -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.
|
||||||
@@ -37,4 +37,15 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
||||||
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
||||||
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
||||||
| | **Summe** | | | **151 h** | **16 h 58 min** |
|
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
|
||||||
|
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
|
||||||
|
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 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 |
|
||||||
|
| [#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 |
|
||||||
|
| [#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,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.20.1",
|
"version": "0.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Rabatt gehört an die Position, nicht an die Rechnung (#037).
|
||||||
|
--
|
||||||
|
-- Proxmox weist den Rabatt je Subscription-Key als eigene Zeile aus. Bei einer Sammelrechnung
|
||||||
|
-- über mehrere Hosts ließe sich ein Rabatt an der Rechnung keinem Host zuordnen – und die Marge
|
||||||
|
-- je Host wäre falsch.
|
||||||
|
|
||||||
|
ALTER TABLE "ProxmoxRechnungPosition" ADD COLUMN "rabatt" DECIMAL(12,2);
|
||||||
|
|
||||||
|
-- Vorhandene Rabatte von der Rechnung auf ihre Positionen verteilen, anteilig zum Nettobetrag.
|
||||||
|
-- Bei nur einer Position entspricht das genau dem bisherigen Wert.
|
||||||
|
UPDATE "ProxmoxRechnungPosition" AS p
|
||||||
|
SET "rabatt" = ROUND(r."rabatt" * p."nettoBetrag" / NULLIF(s."summe", 0), 2)
|
||||||
|
FROM "ProxmoxRechnung" AS r,
|
||||||
|
(SELECT "rechnungId", SUM("nettoBetrag") AS "summe"
|
||||||
|
FROM "ProxmoxRechnungPosition"
|
||||||
|
GROUP BY "rechnungId") AS s
|
||||||
|
WHERE p."rechnungId" = r."id"
|
||||||
|
AND s."rechnungId" = r."id"
|
||||||
|
AND r."rabatt" IS NOT NULL;
|
||||||
|
|
||||||
|
-- Der Rechnungsbetrag ist ab jetzt die Summe der Positionen nach Abzug ihrer Rabatte.
|
||||||
|
UPDATE "ProxmoxRechnung" AS r
|
||||||
|
SET "nettoBetrag" = COALESCE((
|
||||||
|
SELECT SUM(p."nettoBetrag" - COALESCE(p."rabatt", 0))
|
||||||
|
FROM "ProxmoxRechnungPosition" AS p
|
||||||
|
WHERE p."rechnungId" = r."id"
|
||||||
|
), r."nettoBetrag");
|
||||||
|
|
||||||
|
ALTER TABLE "ProxmoxRechnung" DROP COLUMN "rabatt";
|
||||||
|
|
||||||
|
-- Einkauf bei Proxmox läuft über Reverse Charge; 19 % war als Vorgabe irreführend.
|
||||||
|
ALTER TABLE "ProxmoxRechnung" ALTER COLUMN "ustSatz" SET DEFAULT 0.00;
|
||||||
@@ -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())
|
||||||
@@ -261,9 +268,10 @@ model ProxmoxRechnung {
|
|||||||
nummer String @unique
|
nummer String @unique
|
||||||
datum DateTime @db.Date
|
datum DateTime @db.Date
|
||||||
faelligAm DateTime? @db.Date
|
faelligAm DateTime? @db.Date
|
||||||
|
/// Summe der Positionen nach Abzug ihrer Rabatte – wird beim Speichern nachgeführt.
|
||||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||||
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
|
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
|
||||||
rabatt Decimal? @db.Decimal(12, 2)
|
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
|
||||||
zahlungsart String?
|
zahlungsart String?
|
||||||
bezahlt Boolean @default(false)
|
bezahlt Boolean @default(false)
|
||||||
bezahltAm DateTime? @db.Date
|
bezahltAm DateTime? @db.Date
|
||||||
@@ -284,7 +292,10 @@ model ProxmoxRechnungPosition {
|
|||||||
rechnungId String
|
rechnungId String
|
||||||
bezeichnung String
|
bezeichnung String
|
||||||
menge Int @default(1)
|
menge Int @default(1)
|
||||||
|
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
|
||||||
nettoBetrag Decimal @db.Decimal(12, 2)
|
nettoBetrag Decimal @db.Decimal(12, 2)
|
||||||
|
/// Rabatt dieser Position. Proxmox weist ihn je Key als eigene Zeile aus (#037).
|
||||||
|
rabatt Decimal? @db.Decimal(12, 2)
|
||||||
|
|
||||||
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
rechnung ProxmoxRechnung @relation(fields: [rechnungId], references: [id], onDelete: Cascade)
|
||||||
laufzeit Laufzeit?
|
laufzeit Laufzeit?
|
||||||
|
|||||||
@@ -166,7 +166,6 @@ async function seedDemo() {
|
|||||||
faelligAm: datum(vorlage.faelligAm),
|
faelligAm: datum(vorlage.faelligAm),
|
||||||
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
|
||||||
ustSatz: vorlage.ustSatz,
|
ustSatz: vorlage.ustSatz,
|
||||||
rabatt: vorlage.rabatt ?? null,
|
|
||||||
zahlungsart: vorlage.zahlungsart ?? null,
|
zahlungsart: vorlage.zahlungsart ?? null,
|
||||||
bezahlt: vorlage.bezahlt,
|
bezahlt: vorlage.bezahlt,
|
||||||
bezahltAm: datum(vorlage.bezahltAm),
|
bezahltAm: datum(vorlage.bezahltAm),
|
||||||
@@ -175,6 +174,8 @@ async function seedDemo() {
|
|||||||
id: `prp-${l.id}`,
|
id: `prp-${l.id}`,
|
||||||
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
|
||||||
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
|
||||||
|
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
|
||||||
|
rabatt: vorlage.rabatt ?? null,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
|||||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||||
const ALLE = process.argv.includes("--alle");
|
const ALLE = process.argv.includes("--alle");
|
||||||
|
|
||||||
|
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
|
||||||
|
const MOBIL_ZUSATZ = process.argv
|
||||||
|
.filter((wert, i) => process.argv[i - 1] === "--mobil")
|
||||||
|
.map((eintrag) => {
|
||||||
|
const [name, ...rest] = eintrag.split("=");
|
||||||
|
return { name, pfad: rest.join("="), titel: "Handy" };
|
||||||
|
});
|
||||||
|
|
||||||
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
|
||||||
const ZUSATZ = process.argv
|
const ZUSATZ = process.argv
|
||||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||||
@@ -187,6 +195,11 @@ async function main() {
|
|||||||
console.log(` ${seite.name}.png – ${seite.titel}`);
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const seite of MOBIL_ZUSATZ) {
|
||||||
|
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
|
||||||
|
console.log(` ${seite.name}.png – ${seite.titel}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (ALLE) {
|
if (ALLE) {
|
||||||
const dunkel = profilVorbereiten("dunkel", true);
|
const dunkel = profilVorbereiten("dunkel", true);
|
||||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||||
@@ -195,7 +208,8 @@ async function main() {
|
|||||||
console.log(` ${seite}-dunkel.png`);
|
console.log(` ${seite}-dunkel.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seite of ["dashboard", "hosts"]) {
|
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
|
||||||
|
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
|
||||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
||||||
console.log(` mobil-${seite}.png`);
|
console.log(` mobil-${seite}.png`);
|
||||||
|
|||||||
@@ -16,7 +16,8 @@ import { Tabs } from "@/components/ui/Tabs";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
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 { hostKundeSetzen } from "@/server/actions/host-detail";
|
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";
|
||||||
@@ -43,7 +44,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
const kunden = await prisma.kunde.findMany({
|
const kunden = await prisma.kunde.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
||||||
@@ -104,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">
|
||||||
@@ -167,30 +198,38 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
||||||
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
||||||
|
|
||||||
<form action={hostKundeSetzen} className="mt-3">
|
<KundeStandortFormular
|
||||||
|
hostId={host.id}
|
||||||
|
kunden={kunden}
|
||||||
|
kundeId={host.kundeId}
|
||||||
|
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} />
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
<label className="form-label small" htmlFor="kundeId">
|
<button type="submit" className="btn btn-sm btn-outline-secondary">
|
||||||
Kunde ändern
|
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
|
||||||
</label>
|
|
||||||
<div className="input-group input-group-sm">
|
|
||||||
<select
|
|
||||||
id="kundeId"
|
|
||||||
name="kundeId"
|
|
||||||
className="form-select"
|
|
||||||
defaultValue={host.kundeId ?? ""}
|
|
||||||
>
|
|
||||||
<option value="">– nicht zugeordnet –</option>
|
|
||||||
{kunden.map((kunde) => (
|
|
||||||
<option key={kunde.id} value={kunde.id}>
|
|
||||||
{kunde.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<button type="submit" className="btn btn-outline-primary">
|
|
||||||
Setzen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</form>
|
</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>
|
||||||
|
|
||||||
@@ -239,7 +278,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
inhalt: (
|
inhalt: (
|
||||||
<>
|
<>
|
||||||
<h3 className="h6">Keys</h3>
|
<h3 className="h6">Keys</h3>
|
||||||
<table className="table table-striped align-middle mb-4">
|
<div className="table-responsive mb-4">
|
||||||
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
@@ -266,8 +306,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="h6">Server-IDs</h3>
|
<h3 className="h6">Server-IDs</h3>
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -295,6 +337,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</div>
|
||||||
{host.serverIdHistorie.length > 1 ? (
|
{host.serverIdHistorie.length > 1 ? (
|
||||||
<p className="small pl-text-warning mt-2 mb-0">
|
<p className="small pl-text-warning mt-2 mb-0">
|
||||||
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
|
||||||
@@ -356,7 +399,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
icon: "bi-receipt",
|
icon: "bi-receipt",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0 pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<th scope="col">Laufzeit</th>
|
||||||
@@ -369,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{host.laufzeiten.map((laufzeit) => {
|
{host.laufzeiten.map((laufzeit) => {
|
||||||
|
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
const einkauf = laufzeit.proxmoxPosition
|
const einkauf = laufzeit.proxmoxPosition
|
||||||
? Number(laufzeit.proxmoxPosition.nettoBetrag)
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
|
||||||
|
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const verkauf = laufzeit.kundenPosition
|
const verkauf = laufzeit.kundenPosition
|
||||||
? Number(laufzeit.kundenPosition.nettoBetrag)
|
? Number(laufzeit.kundenPosition.nettoBetrag)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
|
<Link
|
||||||
|
href={`/rechnungen/${laufzeit.id}`}
|
||||||
|
className="fw-semibold text-decoration-none"
|
||||||
|
>
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||||
|
</Link>
|
||||||
{laufzeit.status === "VORSCHLAG" ? (
|
{laufzeit.status === "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>
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge
|
<EinkaufBadge
|
||||||
@@ -395,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(einkauf)}</td>
|
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -405,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(verkauf)}</td>
|
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
|
||||||
<td>
|
<td data-label="Marge">
|
||||||
{einkauf !== null && verkauf !== null
|
{einkauf !== null && verkauf !== null
|
||||||
? formatEuro(verkauf - einkauf)
|
? formatEuro(verkauf - einkauf)
|
||||||
: "–"}
|
: "–"}
|
||||||
@@ -443,6 +493,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
titel: "Tokens",
|
titel: "Tokens",
|
||||||
icon: "bi-key-fill",
|
icon: "bi-key-fill",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -490,6 +541,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
</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 ? (
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
|
|||||||
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<div style={{ minWidth: "820px" }}>
|
<div className="pl-timeline">
|
||||||
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
|
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
|
||||||
{monate.map((monat) => (
|
{monate.map((monat) => (
|
||||||
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
||||||
{monat}
|
{monat}
|
||||||
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
|
|||||||
: "var(--pl-info)";
|
: "var(--pl-info)";
|
||||||
return (
|
return (
|
||||||
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
||||||
<div className="small text-truncate" style={{ width: "13rem" }}>
|
<div className="small text-truncate pl-timeline-name">
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="small text-body-secondary mb-0">
|
<p className="small text-body-secondary mb-0">
|
||||||
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
<span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
|
||||||
|
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
||||||
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
|
|||||||
tabelle
|
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">Ablauf</th>
|
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col">Proxmox</th>
|
||||||
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{naechste90Tage.map((eintrag) => (
|
{naechste90Tage.map((eintrag) => (
|
||||||
<tr key={eintrag.hostId}>
|
<tr key={eintrag.hostId}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Ablauf">
|
||||||
|
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde">
|
||||||
{eintrag.kunde ? (
|
{eintrag.kunde ? (
|
||||||
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
||||||
{eintrag.kunde.name}
|
{eintrag.kunde.name}
|
||||||
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
|
|||||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Key">
|
||||||
<CopyableCode wert={eintrag.key} />
|
<CopyableCode wert={eintrag.key} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox">
|
||||||
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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
|
||||||
|
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)}
|
{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>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<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">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
@@ -56,26 +56,31 @@ export default async function KundenSeite() {
|
|||||||
const punkte = rechnungspunkte.get(kunde.id);
|
const punkte = rechnungspunkte.get(kunde.id);
|
||||||
return (
|
return (
|
||||||
<tr key={kunde.id}>
|
<tr key={kunde.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<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">{kunde.kundennummer}</td>
|
<td className="pl-mono" data-label="Kundennummer">
|
||||||
<td>{kunde.hosts}</td>
|
{kunde.kundennummer}
|
||||||
<td>{kunde.aktiv}</td>
|
</td>
|
||||||
<td>
|
<td data-label="Hosts">{kunde.hosts}</td>
|
||||||
|
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
|
||||||
|
<td data-label="Nächster Ablauf">
|
||||||
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Warnungen">
|
||||||
{kunde.offeneAlerts > 0 ? (
|
{kunde.offeneAlerts > 0 ? (
|
||||||
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge farbe="success">keine</Badge>
|
<Badge farbe="success">keine</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Rechnungen">
|
||||||
{punkte && punkte.offen > 0 ? (
|
{punkte && punkte.offen > 0 ? (
|
||||||
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
|
|||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
|
||||||
|
per Seitwärtsscrollen ins Bild (#034). */}
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
<th scope="col">Key</th>
|
Kunde
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-lg-table-cell">
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
<th scope="col">Ablauf</th>
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
<th scope="col">Kundenrechnung</th>
|
Proxmox
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
|
Kundenrechnung
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
|
||||||
|
<div className="small text-body-secondary d-md-none">
|
||||||
|
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-md-table-cell">
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||||
{host.kunde.name}
|
{host.kunde.name}
|
||||||
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-lg-table-cell">
|
||||||
<CopyableCode wert={host.subKey} />
|
<CopyableCode wert={host.subKey} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -25,16 +27,35 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
|
|
||||||
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
|
||||||
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
|
||||||
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
|
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
||||||
|
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
|
||||||
|
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
|
||||||
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
|
||||||
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 (
|
||||||
@@ -109,7 +130,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
<hr />
|
<hr />
|
||||||
<dl className="row mb-0">
|
<dl className="row mb-0">
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
|
<dd className="col-7">
|
||||||
|
{formatEuro(einkaufBetrag)}
|
||||||
|
{einkaufRabatt > 0 ? (
|
||||||
|
<span className="small text-body-secondary d-block">
|
||||||
|
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
|
||||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||||
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
|
||||||
@@ -117,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 ? (
|
||||||
@@ -135,7 +180,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
) : (
|
) : (
|
||||||
position.bezeichnung
|
position.bezeichnung
|
||||||
)}{" "}
|
)}{" "}
|
||||||
· {formatEuro(Number(position.nettoBetrag))}
|
·{" "}
|
||||||
|
{position.rabatt ? (
|
||||||
|
<>
|
||||||
|
{formatEuro(Number(position.nettoBetrag))} − {formatEuro(Number(position.rabatt))}{" "}
|
||||||
|
= <strong>{formatEuro(Number(position.nettoBetrag) - Number(position.rabatt))}</strong>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
formatEuro(Number(position.nettoBetrag))
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -156,12 +209,13 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
|||||||
nummer: einkauf?.nummer ?? null,
|
nummer: einkauf?.nummer ?? null,
|
||||||
datum: einkauf?.datum.toISOString() ?? null,
|
datum: einkauf?.datum.toISOString() ?? null,
|
||||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||||
nettoBetrag: einkaufBetrag,
|
nettoBetrag: einkaufListenpreis,
|
||||||
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||||
rabatt:
|
rabatt:
|
||||||
einkauf?.rabatt === null || einkauf?.rabatt === undefined
|
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||||
|
laufzeit.proxmoxPosition?.rabatt === undefined
|
||||||
? null
|
? null
|
||||||
: Number(einkauf.rabatt),
|
: Number(laufzeit.proxmoxPosition.rabatt),
|
||||||
zahlungsart: einkauf?.zahlungsart ?? null,
|
zahlungsart: einkauf?.zahlungsart ?? null,
|
||||||
bezahlt: einkauf?.bezahlt ?? false,
|
bezahlt: einkauf?.bezahlt ?? false,
|
||||||
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
|
||||||
@@ -173,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={
|
||||||
|
eigenbedarf ? (
|
||||||
|
<span className="badge pl-bg-neutral">Eigenbedarf</span>
|
||||||
|
) : (
|
||||||
<VerkaufBadge erstellt={verkauf?.erstellt ?? false} bezahlt={verkauf?.bezahlt ?? false} />
|
<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}
|
||||||
@@ -194,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"
|
||||||
@@ -197,14 +216,14 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<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">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
<th scope="col" className="d-none d-md-table-cell">
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
Host
|
Host
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="d-none d-lg-table-cell">
|
<th scope="col" className="pl-spalte-laufzeit">
|
||||||
Laufzeit
|
Laufzeit
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Proxmox-Rechnung</th>
|
<th scope="col">Proxmox-Rechnung</th>
|
||||||
@@ -234,7 +253,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
|
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
|
||||||
return (
|
return (
|
||||||
<tr key={zeile.id}>
|
<tr key={zeile.id}>
|
||||||
<td>
|
{/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
|
||||||
|
<td className="d-md-none pl-karten-kopf">
|
||||||
|
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
|
||||||
|
{zeile.hostname}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde">
|
||||||
{zeile.kunde ? (
|
{zeile.kunde ? (
|
||||||
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
|
||||||
{zeile.kunde.name}
|
{zeile.kunde.name}
|
||||||
@@ -248,13 +273,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
{zeile.hostname}
|
{zeile.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-lg-table-cell">
|
<td className="pl-spalte-laufzeit" data-label="Laufzeit">
|
||||||
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
||||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||||
</Link>
|
</Link>
|
||||||
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
|
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={zeile.einkaufNummer} />
|
<CopyableCode wert={zeile.einkaufNummer} />
|
||||||
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
|
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
|
||||||
<div className="small pl-text-danger">
|
<div className="small pl-text-danger">
|
||||||
@@ -262,8 +287,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
|
<td className="d-none d-xl-table-cell" data-label="Einkauf">
|
||||||
<td className="text-center">
|
{formatEuro(zeile.einkaufBetrag)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center" data-label="Einkauf bezahlt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -273,11 +300,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
titel="Proxmox-Rechnung bezahlt"
|
titel="Proxmox-Rechnung bezahlt"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={zeile.verkaufNummer} />
|
<CopyableCode wert={zeile.verkaufNummer} />
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
|
<td className="d-none d-xl-table-cell" data-label="Verkauf">
|
||||||
<td className="text-center">
|
{formatEuro(zeile.verkaufBetrag)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center" data-label="Rechnung erstellt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -286,7 +315,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
titel="Kundenrechnung erstellt"
|
titel="Kundenrechnung erstellt"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center" data-label="Vom Kunden bezahlt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -297,6 +326,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
data-label="Marge"
|
||||||
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
||||||
>
|
>
|
||||||
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
||||||
@@ -306,7 +336,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
})}
|
})}
|
||||||
{zeilen.length === 0 ? (
|
{zeilen.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="text-center text-body-secondary py-4">
|
<td colSpan={12} className="text-center text-body-secondary py-4">
|
||||||
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
|
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -186,6 +186,19 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
|
||||||
|
* damit sie auch per Tastatur erreichbar ist.
|
||||||
|
*/
|
||||||
|
.pl-menu-hintergrund {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
|
||||||
|
padding: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Inhaltsbereich ---------- */
|
/* ---------- Inhaltsbereich ---------- */
|
||||||
|
|
||||||
.app-content-header {
|
.app-content-header {
|
||||||
@@ -369,10 +382,170 @@ body {
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zeitleiste der Jahresbahn: Breiten als Klassen, damit sie auf dem Telefon kleiner ausfallen. */
|
||||||
|
.pl-timeline {
|
||||||
|
min-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-kopf {
|
||||||
|
margin-left: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-name {
|
||||||
|
width: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
|
||||||
|
.pl-klappliste {
|
||||||
|
width: 22rem;
|
||||||
|
max-width: calc(100vw - 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Laufzeit-Spalte ist auf dem Telefon der einzige Weg zu den Rechnungsformularen, deshalb
|
||||||
|
* bleibt sie dort sichtbar. Zwischen 768 und 992 px ist die Tabelle am engsten – dort entfällt sie.
|
||||||
|
* Bewusst eine eigene Klasse statt der Bootstrap-Hilfsklassen: deren `display: … !important`
|
||||||
|
* überschreibt das Kartenlayout (#039).
|
||||||
|
*/
|
||||||
|
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||||
|
.pl-spalte-laufzeit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobil ---------- */
|
/* ---------- Mobil ---------- */
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
.pl-touch-target {
|
.pl-touch-target {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein Kontrollkästchen darf nicht selbst 44 px hoch werden – sonst steht in der Zeile ein
|
||||||
|
* großer leerer Kasten. Die Fläche zum Treffen bekommt stattdessen sein Umfeld (#035).
|
||||||
|
*/
|
||||||
|
.pl-haken {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-haken .form-check-input {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Breite Tabellen auf dem Telefon: Jede Zeile wird zur Karte, jede Zelle bekommt ihre
|
||||||
|
* Beschriftung aus `data-label` davor. Das ersetzt das Scrollen nach rechts, bei dem die
|
||||||
|
* Hälfte der Angaben unsichtbar bleibt (#035).
|
||||||
|
*
|
||||||
|
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||||
|
*/
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/*
|
||||||
|
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
|
||||||
|
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
|
||||||
|
* statt am Knopf (#038).
|
||||||
|
*/
|
||||||
|
.pl-klappliste {
|
||||||
|
position: fixed;
|
||||||
|
top: 3.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
|
||||||
|
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
|
||||||
|
*/
|
||||||
|
.pl-mono {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle,
|
||||||
|
.pl-karten-tabelle tbody,
|
||||||
|
.pl-karten-tabelle tr,
|
||||||
|
.pl-karten-tabelle td {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle tr {
|
||||||
|
padding: 0.35rem 0.25rem;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle tr:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle td {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
text-align: right;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle td::before {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
text-align: left;
|
||||||
|
content: attr(data-label);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Die erste Zelle trägt den Namen – sie steht als Überschrift der Karte für sich. */
|
||||||
|
.pl-karten-tabelle td.pl-karten-kopf {
|
||||||
|
display: block;
|
||||||
|
padding-top: 0.6rem;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-karten-tabelle td.pl-karten-kopf::before {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lange Namen und Schlüssel sollen umbrechen, aber nicht Buchstabe für Buchstabe. */
|
||||||
|
.pl-karten-tabelle td a,
|
||||||
|
.pl-karten-tabelle td .pl-mono {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
word-break: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auf dem Telefon bleibt mehr Platz für die Monate, wenn die Namensspalte schrumpft. */
|
||||||
|
.pl-timeline {
|
||||||
|
min-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-kopf {
|
||||||
|
margin-left: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-name {
|
||||||
|
width: 7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kartenkopf mit Knöpfen: untereinander statt nebeneinander über den Rand hinaus. */
|
||||||
|
.card-header {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .card-tools {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,8 +119,8 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
|||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
name="steuersatz"
|
name="steuersatz"
|
||||||
beschriftung="Steuersatz in Prozent"
|
beschriftung="Steuersatz Verkauf in Prozent"
|
||||||
hinweis="Vorgabe für neue Rechnungspositionen."
|
hinweis="Vorgabe für neue Kundenrechnungen."
|
||||||
fehler={fehler.steuersatz}
|
fehler={fehler.steuersatz}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
|
|||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
|
|
||||||
|
<Feld
|
||||||
|
name="steuersatzEinkauf"
|
||||||
|
beschriftung="Steuersatz Einkauf in Prozent"
|
||||||
|
hinweis="Für die Proxmox-Rechnungen. 0 bei Reverse Charge – der Normalfall mit USt-IdNr."
|
||||||
|
fehler={fehler.steuersatzEinkauf}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
className="form-control"
|
||||||
|
id="steuersatzEinkauf"
|
||||||
|
name="steuersatzEinkauf"
|
||||||
|
defaultValue={werte.steuersatzEinkauf}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
|
||||||
<Feld
|
<Feld
|
||||||
name="zahlungszielTage"
|
name="zahlungszielTage"
|
||||||
beschriftung="Zahlungsziel in Tagen"
|
beschriftung="Zahlungsziel in Tagen"
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { hostKundeSetzen } from "@/server/actions/host-detail";
|
||||||
|
|
||||||
|
export interface KundeMitStandorten {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
standorte: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kunde und Standort eines Hosts ändern (#036).
|
||||||
|
*
|
||||||
|
* Beides gehört in **ein** Formular: Die Standorte hängen am Kunden, und ein Formular, das nur den
|
||||||
|
* Kunden sendet, würde den Standort beim Speichern mitlöschen – genau das passierte vorher.
|
||||||
|
*/
|
||||||
|
export function KundeStandortFormular({
|
||||||
|
hostId,
|
||||||
|
kunden,
|
||||||
|
kundeId,
|
||||||
|
standortId,
|
||||||
|
}: {
|
||||||
|
hostId: string;
|
||||||
|
kunden: KundeMitStandorten[];
|
||||||
|
kundeId: string | null;
|
||||||
|
standortId: string | null;
|
||||||
|
}) {
|
||||||
|
const [gewaehlterKunde, setGewaehlterKunde] = useState(kundeId ?? "");
|
||||||
|
const standorte = kunden.find((kunde) => kunde.id === gewaehlterKunde)?.standorte ?? [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={hostKundeSetzen} className="mt-3">
|
||||||
|
<input type="hidden" name="hostId" value={hostId} />
|
||||||
|
|
||||||
|
<label className="form-label small" htmlFor="kundeId">
|
||||||
|
Kunde ändern
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="kundeId"
|
||||||
|
name="kundeId"
|
||||||
|
className="form-select form-select-sm mb-2"
|
||||||
|
value={gewaehlterKunde}
|
||||||
|
onChange={(e) => setGewaehlterKunde(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– nicht zugeordnet –</option>
|
||||||
|
{kunden.map((kunde) => (
|
||||||
|
<option key={kunde.id} value={kunde.id}>
|
||||||
|
{kunde.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="form-label small" htmlFor="standortId">
|
||||||
|
Standort
|
||||||
|
</label>
|
||||||
|
<div className="input-group input-group-sm">
|
||||||
|
<select
|
||||||
|
id="standortId"
|
||||||
|
name="standortId"
|
||||||
|
className="form-select"
|
||||||
|
defaultValue={standortId ?? ""}
|
||||||
|
disabled={standorte.length === 0}
|
||||||
|
// `key` erzwingt ein frisches Feld, wenn der Kunde wechselt – sonst bliebe die alte Auswahl stehen.
|
||||||
|
key={gewaehlterKunde}
|
||||||
|
>
|
||||||
|
<option value="">– keiner –</option>
|
||||||
|
{standorte.map((standort) => (
|
||||||
|
<option key={standort.id} value={standort.id}>
|
||||||
|
{standort.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button type="submit" className="btn btn-outline-primary">
|
||||||
|
Setzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{gewaehlterKunde && standorte.length === 0 ? (
|
||||||
|
<div className="form-text">
|
||||||
|
Für diesen Kunden gibt es noch keinen Standort. Anlegen lässt er sich auf der Kundenseite.
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||||
|
import { menuUmschalten } from "./menuZustand";
|
||||||
|
|
||||||
export interface KopfleistenAlert {
|
export interface KopfleistenAlert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,16 +59,9 @@ export function Header({
|
|||||||
return () => window.removeEventListener("keydown", tasten);
|
return () => window.removeEventListener("keydown", tasten);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
|
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
|
||||||
const sidebarUmschalten = useCallback(() => {
|
const sidebarUmschalten = useCallback(() => {
|
||||||
const body = document.body;
|
menuUmschalten();
|
||||||
if (window.innerWidth < 992) {
|
|
||||||
body.classList.toggle("sidebar-open");
|
|
||||||
body.classList.remove("sidebar-collapse");
|
|
||||||
} else {
|
|
||||||
body.classList.toggle("sidebar-collapse");
|
|
||||||
body.classList.remove("sidebar-open");
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function themaUmschalten() {
|
function themaUmschalten() {
|
||||||
@@ -146,8 +140,7 @@ export function Header({
|
|||||||
) : null}
|
) : null}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||||||
style={{ width: "22rem" }}
|
|
||||||
>
|
>
|
||||||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||||||
<div className="dropdown-divider" />
|
<div className="dropdown-divider" />
|
||||||
@@ -212,7 +205,9 @@ export function Header({
|
|||||||
<i className="bi bi-person-circle" aria-hidden="true" />
|
<i className="bi bi-person-circle" aria-hidden="true" />
|
||||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||||
</button>
|
</button>
|
||||||
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}>
|
<div
|
||||||
|
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
|
||||||
|
>
|
||||||
<span className="dropdown-header">
|
<span className="dropdown-header">
|
||||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
|
import { menuSchliessen, useMenuOffen } from "./menuZustand";
|
||||||
|
|
||||||
interface MenuEintrag {
|
interface MenuEintrag {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -30,6 +31,21 @@ export function Sidebar({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [suche, setSuche] = useState("");
|
const [suche, setSuche] = useState("");
|
||||||
|
const menuOffen = useMenuOffen();
|
||||||
|
|
||||||
|
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
|
||||||
|
useEffect(() => {
|
||||||
|
menuSchliessen();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Escape schließt, wie bei jedem anderen Overlay auch.
|
||||||
|
useEffect(() => {
|
||||||
|
function taste(ereignis: KeyboardEvent) {
|
||||||
|
if (ereignis.key === "Escape") menuSchliessen();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", taste);
|
||||||
|
return () => window.removeEventListener("keydown", taste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const haupt: MenuEintrag[] = [
|
const haupt: MenuEintrag[] = [
|
||||||
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
||||||
@@ -105,11 +121,30 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Fläche hinter dem Menü: antippen schließt. Ohne sie bleibt das Menü auf dem Handy stehen. */}
|
||||||
|
{menuOffen ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pl-menu-hintergrund d-lg-none"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
onClick={menuSchliessen}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
||||||
<div className="sidebar-brand">
|
<div className="sidebar-brand d-flex align-items-center justify-content-between">
|
||||||
<Link href="/" className="brand-link">
|
<Link href="/" className="brand-link">
|
||||||
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
onClick={menuSchliessen}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-sidebar-user">
|
<div className="pl-sidebar-user">
|
||||||
@@ -148,5 +183,6 @@ export function Sidebar({
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
|
||||||
|
*
|
||||||
|
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
|
||||||
|
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
|
||||||
|
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
|
||||||
|
* Kopfzeile und Menü gemeinsam hören.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHMAL = 992;
|
||||||
|
|
||||||
|
let offen = false;
|
||||||
|
const zuhoerer = new Set<() => void>();
|
||||||
|
|
||||||
|
function melden() {
|
||||||
|
for (const ruf of zuhoerer) ruf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
|
||||||
|
function klassenSetzen() {
|
||||||
|
const body = document.body;
|
||||||
|
body.classList.toggle("sidebar-open", offen);
|
||||||
|
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
|
||||||
|
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuUmschalten() {
|
||||||
|
if (window.innerWidth >= SCHMAL) {
|
||||||
|
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
|
||||||
|
document.body.classList.toggle("sidebar-collapse");
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offen = !offen;
|
||||||
|
document.body.classList.remove("sidebar-collapse");
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuSchliessen() {
|
||||||
|
if (!offen) return;
|
||||||
|
offen = false;
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abonnieren(ruf: () => void) {
|
||||||
|
zuhoerer.add(ruf);
|
||||||
|
return () => {
|
||||||
|
zuhoerer.delete(ruf);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMenuOffen(): boolean {
|
||||||
|
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
|
||||||
|
return useSyncExternalStore(
|
||||||
|
abonnieren,
|
||||||
|
() => offen,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useActionState } from "react";
|
import { useActionState, useState } from "react";
|
||||||
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
|
||||||
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
import type { AktionsErgebnis } from "@/server/actions/kunden";
|
||||||
|
import { formatEuro } from "@/lib/format";
|
||||||
|
|
||||||
const LEER: AktionsErgebnis = {};
|
const LEER: AktionsErgebnis = {};
|
||||||
|
|
||||||
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
|
|||||||
}) {
|
}) {
|
||||||
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
|
const [ergebnis, aktion, laeuft] = useActionState(proxmoxRechnungSpeichern, LEER);
|
||||||
|
|
||||||
|
// Listenpreis und Rabatt im Zustand, damit der tatsächlich gezahlte Betrag sofort danebensteht.
|
||||||
|
const [netto, setNetto] = useState(werte.nettoBetrag === null ? "" : String(werte.nettoBetrag));
|
||||||
|
const [rabatt, setRabatt] = useState(werte.rabatt === null ? "" : String(werte.rabatt));
|
||||||
|
const zahl = (wert: string) => Number(wert.replace(",", ".")) || 0;
|
||||||
|
const nettoZahl = zahl(netto);
|
||||||
|
const rabattZahl = zahl(rabatt);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form action={aktion}>
|
<form action={aktion}>
|
||||||
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
<input type="hidden" name="laufzeitId" value={laufzeitId} />
|
||||||
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
|
|||||||
|
|
||||||
<div className="col-md-3">
|
<div className="col-md-3">
|
||||||
<label className="form-label" htmlFor="px-netto">
|
<label className="form-label" htmlFor="px-netto">
|
||||||
Netto (€) *
|
Listenpreis netto (€) *
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
id="px-netto"
|
id="px-netto"
|
||||||
name="nettoBetrag"
|
name="nettoBetrag"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.nettoBetrag ?? ""}
|
value={netto}
|
||||||
|
onChange={(e) => setNetto(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-2">
|
<div className="col-md-2">
|
||||||
<label className="form-label" htmlFor="px-ust">
|
<label className="form-label" htmlFor="px-ust">
|
||||||
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
|
|||||||
name="rabatt"
|
name="rabatt"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
inputMode="decimal"
|
inputMode="decimal"
|
||||||
defaultValue={werte.rabatt ?? ""}
|
value={rabatt}
|
||||||
|
onChange={(e) => setRabatt(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<div className="form-text">
|
||||||
|
{rabattZahl > 0 ? (
|
||||||
|
<>
|
||||||
|
Gerechnet wird mit <strong>{formatEuro(nettoZahl - rabattZahl)}</strong>.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Proxmox weist ihn je Key als eigene Zeile aus („Client Discount“)."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-md-4">
|
<div className="col-md-4">
|
||||||
<label className="form-label" htmlFor="px-zahlungsart">
|
<label className="form-label" htmlFor="px-zahlungsart">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function StatusHaken({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="d-inline-flex align-items-center justify-content-center">
|
<span className="pl-haken d-inline-flex align-items-center justify-content-center">
|
||||||
{laeuft ? (
|
{laeuft ? (
|
||||||
<span
|
<span
|
||||||
className="spinner-border spinner-border-sm text-body-secondary"
|
className="spinner-border spinner-border-sm text-body-secondary"
|
||||||
@@ -47,7 +47,7 @@ export function StatusHaken({
|
|||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input pl-touch-target"
|
className="form-check-input"
|
||||||
checked={wert}
|
checked={wert}
|
||||||
disabled={deaktiviert}
|
disabled={deaktiviert}
|
||||||
onChange={(e) => umschalten(e.target.checked)}
|
onChange={(e) => umschalten(e.target.checked)}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
let kopfzeilen: Headers;
|
||||||
|
try {
|
||||||
const { headers } = await import("next/headers");
|
const { headers } = await import("next/headers");
|
||||||
const kopfzeilen = await 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);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
|
|||||||
|
|
||||||
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
waehrung: text(formular, "waehrung") || vorher.waehrung,
|
||||||
steuersatz: zahl(formular, "steuersatz"),
|
steuersatz: zahl(formular, "steuersatz"),
|
||||||
|
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
|
||||||
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
zahlungszielTage: zahl(formular, "zahlungszielTage"),
|
||||||
margeProzent: zahl(formular, "margeProzent"),
|
margeProzent: zahl(formular, "margeProzent"),
|
||||||
|
|
||||||
|
|||||||
@@ -57,8 +57,24 @@ export async function hostKundeSetzen(formular: FormData) {
|
|||||||
const standortId = formular.get("standortId")?.toString() || null;
|
const standortId = formular.get("standortId")?.toString() || null;
|
||||||
if (!hostId) return;
|
if (!hostId) return;
|
||||||
|
|
||||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
|
// Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
|
||||||
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
|
// gewechselt hat –, wird der Standort verworfen statt eine falsche Zuordnung zu speichern.
|
||||||
|
const gueltigerStandort =
|
||||||
|
standortId && kundeId
|
||||||
|
? ((await prisma.standort.findFirst({
|
||||||
|
where: { id: standortId, kundeId },
|
||||||
|
select: { id: true },
|
||||||
|
})) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: hostId },
|
||||||
|
data: { kundeId, standortId: gueltigerStandort?.id ?? null },
|
||||||
|
});
|
||||||
|
await protokolliere("host.zugewiesen", "Host", hostId, {
|
||||||
|
kundeId,
|
||||||
|
standortId: gueltigerStandort?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
if (kundeId) {
|
if (kundeId) {
|
||||||
await prisma.alert.updateMany({
|
await prisma.alert.updateMany({
|
||||||
|
|||||||
@@ -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,85 @@ 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
|
||||||
|
* seine Rechnung, und **nachdem der Kunde gezahlt hat**, zahlt man selbst bei Proxmox. Hat der Kunde
|
||||||
|
* bezahlt und die Einkaufsrechnung ist noch offen, liegt das Geld da und die Rechnung auch – das ist
|
||||||
|
* der Moment zum Zahlen, unabhängig vom Fälligkeitsdatum (#037).
|
||||||
|
*/
|
||||||
|
const kundeBezahltEinkaufOffen: Regel = {
|
||||||
|
name: "kunde-bezahlt-einkauf-offen",
|
||||||
|
beschreibung: "Der Kunde hat bezahlt, die Proxmox-Rechnung ist noch offen",
|
||||||
|
standardSchwellwerte: {},
|
||||||
|
pruefe(kontext) {
|
||||||
|
return kontext.laufzeiten.flatMap((laufzeit) => {
|
||||||
|
// Beim Eigenbedarf gibt es keine Kundenzahlung, auf die man warten würde.
|
||||||
|
if (laufzeit.kundeEigenbedarf) return [];
|
||||||
|
if (!laufzeit.verkaufBezahlt || laufzeit.einkaufBezahlt !== false) return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
fingerprint: `kunde-bezahlt-einkauf-offen:${laufzeit.id}`,
|
||||||
|
regel: "kunde-bezahlt-einkauf-offen",
|
||||||
|
schwere: "INFO" as AlertSchwere,
|
||||||
|
titel: "Kunde hat bezahlt – Proxmox-Rechnung noch offen",
|
||||||
|
beschreibung: `${laufzeit.hostname} · ${kundenHinweis(laufzeit.kundeName)} · Einkauf ${
|
||||||
|
laufzeit.einkaufNummer ?? "ohne Nummer"
|
||||||
|
} über ${formatEuro(laufzeit.einkaufBetrag)} netto ist noch nicht bezahlt`,
|
||||||
|
hostId: laufzeit.hostId,
|
||||||
|
kundeId: laufzeit.kundeId,
|
||||||
|
laufzeitId: laufzeit.id,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const REGELN: Regel[] = [
|
export const REGELN: Regel[] = [
|
||||||
subscriptionLaeuftAb,
|
subscriptionLaeuftAb,
|
||||||
statusNichtAktiv,
|
statusNichtAktiv,
|
||||||
@@ -417,6 +503,8 @@ export const REGELN: Regel[] = [
|
|||||||
proxmoxRechnungFaellig,
|
proxmoxRechnungFaellig,
|
||||||
kundenrechnungFehlt,
|
kundenrechnungFehlt,
|
||||||
kundenrechnungUnbezahlt,
|
kundenrechnungUnbezahlt,
|
||||||
|
kundeBezahltEinkaufOffen,
|
||||||
|
hostnameDoppelt,
|
||||||
];
|
];
|
||||||
|
|
||||||
/** Wendet alle aktiven Regeln an. */
|
/** Wendet alle aktiven Regeln an. */
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export const einstellungenSchema = z.object({
|
|||||||
|
|
||||||
// --- Kaufmännisches ---
|
// --- Kaufmännisches ---
|
||||||
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
|
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
|
||||||
|
/** Steuersatz der Kundenrechnungen. */
|
||||||
steuersatz: z.number().min(0).max(100).default(19),
|
steuersatz: z.number().min(0).max(100).default(19),
|
||||||
|
/**
|
||||||
|
* Steuersatz der Proxmox-Rechnungen. Vorgabe 0: Proxmox sitzt in Österreich und rechnet
|
||||||
|
* gegenüber Unternehmen mit USt-IdNr. über Reverse Charge ab (#037).
|
||||||
|
*/
|
||||||
|
steuersatzEinkauf: z.number().min(0).max(100).default(0),
|
||||||
zahlungszielTage: z.number().int().min(0).max(120).default(14),
|
zahlungszielTage: z.number().int().min(0).max(120).default(14),
|
||||||
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
|
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
|
||||||
margeProzent: z.number().min(0).max(500).default(20),
|
margeProzent: z.number().min(0).max(500).default(20),
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -87,7 +88,11 @@ export async function laufzeitenVonKunde(kundeId: string) {
|
|||||||
einkaufSichtbar && laufzeit.proxmoxPosition
|
einkaufSichtbar && laufzeit.proxmoxPosition
|
||||||
? {
|
? {
|
||||||
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
nummer: laufzeit.proxmoxPosition.rechnung.nummer,
|
||||||
nettoBetrag: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
// Nach Abzug des Rabatts – siehe #037.
|
||||||
|
nettoBetrag:
|
||||||
|
Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0),
|
||||||
|
listenpreis: Number(laufzeit.proxmoxPosition.nettoBetrag),
|
||||||
|
rabatt: Number(laufzeit.proxmoxPosition.rabatt ?? 0),
|
||||||
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
bezahlt: laufzeit.proxmoxPosition.rechnung.bezahlt,
|
||||||
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
@@ -76,9 +82,14 @@ export async function rechnungsListe(filter: RechnungsFilter) {
|
|||||||
const zeilen = laufzeiten.map((laufzeit) => {
|
const zeilen = laufzeiten.map((laufzeit) => {
|
||||||
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
|
// Einkaufspreis und Marge sind nichts für ein Kundenkonto. Sie werden hier entfernt,
|
||||||
// nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
|
// nicht erst in der Anzeige – sonst stünden sie im ausgelieferten HTML.
|
||||||
|
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird.
|
||||||
const einkauf =
|
const einkauf =
|
||||||
einkaufSichtbar && laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
|
einkaufSichtbar && laufzeit.proxmoxPosition
|
||||||
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) - Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
|
: 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,
|
||||||
@@ -98,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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,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: {
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
|
||||||
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
if (!eingabe.nummer.trim()) throw new RechnungsFehler("Bitte eine Rechnungsnummer angeben.");
|
||||||
|
|
||||||
const { steuersatz: vorgabeSteuersatz } = await einstellungen();
|
// Einkauf bei Proxmox: eigener Satz, Vorgabe 0 wegen Reverse Charge.
|
||||||
|
const { steuersatzEinkauf: vorgabeSteuersatz } = await einstellungen();
|
||||||
const nummer = eingabe.nummer.trim();
|
const nummer = eingabe.nummer.trim();
|
||||||
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
const bezeichnung = `${laufzeit.key} · ${laufzeit.host.hostname} · ${laufzeit.start.toISOString().slice(0, 10)} bis ${laufzeit.ende.toISOString().slice(0, 10)}`;
|
||||||
|
|
||||||
@@ -82,7 +83,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
datum: eingabe.datum,
|
datum: eingabe.datum,
|
||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
@@ -97,7 +97,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
faelligAm: eingabe.faelligAm,
|
faelligAm: eingabe.faelligAm,
|
||||||
nettoBetrag: eingabe.nettoBetrag,
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
|
||||||
rabatt: eingabe.rabatt ?? null,
|
|
||||||
zahlungsart: eingabe.zahlungsart ?? null,
|
zahlungsart: eingabe.zahlungsart ?? null,
|
||||||
bezahlt: eingabe.bezahlt,
|
bezahlt: eingabe.bezahlt,
|
||||||
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
|
||||||
@@ -109,10 +108,20 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
const position = laufzeit.proxmoxPosition
|
const position = laufzeit.proxmoxPosition
|
||||||
? await tx.proxmoxRechnungPosition.update({
|
? await tx.proxmoxRechnungPosition.update({
|
||||||
where: { id: laufzeit.proxmoxPosition.id },
|
where: { id: laufzeit.proxmoxPosition.id },
|
||||||
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
|
data: {
|
||||||
|
rechnungId: rechnung.id,
|
||||||
|
bezeichnung,
|
||||||
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
|
rabatt: eingabe.rabatt ?? null,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
: await tx.proxmoxRechnungPosition.create({
|
: await tx.proxmoxRechnungPosition.create({
|
||||||
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
|
data: {
|
||||||
|
rechnungId: rechnung.id,
|
||||||
|
bezeichnung,
|
||||||
|
nettoBetrag: eingabe.nettoBetrag,
|
||||||
|
rabatt: eingabe.rabatt ?? null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.laufzeit.update({
|
await tx.laufzeit.update({
|
||||||
@@ -120,9 +129,11 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
|
|||||||
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
|
data: { proxmoxPositionId: position.id, status: "BESTAETIGT" },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Der Rechnungsbetrag ist immer die Summe der Positionen.
|
// Der Rechnungsbetrag ist immer die Summe der Positionen – nach Abzug ihrer Rabatte.
|
||||||
|
// Genau so steht es auch auf der Rechnung von Proxmox: Listenpreise und Rabatte als eigene
|
||||||
|
// Zeilen, darunter die Summe.
|
||||||
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
|
const positionen = await tx.proxmoxRechnungPosition.findMany({ where: { rechnungId: rechnung.id } });
|
||||||
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag), 0);
|
const summe = positionen.reduce((s, p) => s + Number(p.nettoBetrag) - Number(p.rabatt ?? 0), 0);
|
||||||
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
|
await tx.proxmoxRechnung.update({ where: { id: rechnung.id }, data: { nettoBetrag: summe } });
|
||||||
|
|
||||||
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
|
await protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
|
||||||
@@ -313,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(11);
|
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,102 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rabatt je Position und Sammelrechnung über mehrere Hosts – so, wie Proxmox abrechnet (#037):
|
||||||
|
* je Key eine Zeile mit Listenpreis und eine Zeile mit „Client Discount“.
|
||||||
|
*/
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { proxmoxRechnungErfassen } = await import("@/server/services/rechnungen");
|
||||||
|
|
||||||
|
async function laufzeit(key: string) {
|
||||||
|
const kunde = await testPrisma.kunde.upsert({
|
||||||
|
where: { kundennummer: "K-1" },
|
||||||
|
update: {},
|
||||||
|
create: { name: "Wilde IT", kundennummer: "K-1" },
|
||||||
|
});
|
||||||
|
const host = await testPrisma.host.create({
|
||||||
|
data: { agentId: `a-${key}`, hostname: `hv-${key}`, produkt: "PVE", kundeId: kunde.id },
|
||||||
|
});
|
||||||
|
return testPrisma.laufzeit.create({
|
||||||
|
data: {
|
||||||
|
hostId: host.id,
|
||||||
|
key,
|
||||||
|
start: new Date("2026-10-17"),
|
||||||
|
ende: new Date("2027-10-16"),
|
||||||
|
status: "VORSCHLAG",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const eingabe = {
|
||||||
|
nummer: "291017",
|
||||||
|
datum: new Date("2026-09-17"),
|
||||||
|
faelligAm: new Date("2026-10-17"),
|
||||||
|
nettoBetrag: 1100,
|
||||||
|
rabatt: 110,
|
||||||
|
ustSatz: 0,
|
||||||
|
bezahlt: false,
|
||||||
|
bezahltAm: null,
|
||||||
|
zahlungsart: null,
|
||||||
|
anhangId: null,
|
||||||
|
notizen: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Proxmox-Rechnung mit Rabatt", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("legt den Rabatt an der Position ab, nicht an der Rechnung", async () => {
|
||||||
|
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||||
|
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||||
|
|
||||||
|
const position = await testPrisma.proxmoxRechnungPosition.findFirstOrThrow({});
|
||||||
|
expect(Number(position.nettoBetrag)).toBe(1100);
|
||||||
|
expect(Number(position.rabatt)).toBe(110);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechnet die Rechnungssumme als Listenpreise minus Rabatte", async () => {
|
||||||
|
// Genau der Fall aus der echten Rechnung 291017: zwei Keys, je 1100 minus 110 = 1980.
|
||||||
|
const ersteLaufzeit = await laufzeit("pve2s-3ef3be4d75");
|
||||||
|
const zweiteLaufzeit = await laufzeit("pve2s-b7a0d516af");
|
||||||
|
|
||||||
|
await proxmoxRechnungErfassen(ersteLaufzeit.id, eingabe);
|
||||||
|
await proxmoxRechnungErfassen(zweiteLaufzeit.id, eingabe);
|
||||||
|
|
||||||
|
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({
|
||||||
|
where: { nummer: "291017" },
|
||||||
|
include: { positionen: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(rechnung.positionen).toHaveLength(2);
|
||||||
|
expect(Number(rechnung.nettoBetrag)).toBe(1980);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bestätigt die Laufzeit beim Erfassen", async () => {
|
||||||
|
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||||
|
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||||
|
|
||||||
|
const danach = await testPrisma.laufzeit.findUniqueOrThrow({ where: { id: l.id } });
|
||||||
|
expect(danach.status).toBe("BESTAETIGT");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("führt die Summe nach, wenn sich ein Betrag ändert", async () => {
|
||||||
|
const l = await laufzeit("pve2s-3ef3be4d75");
|
||||||
|
await proxmoxRechnungErfassen(l.id, eingabe);
|
||||||
|
await proxmoxRechnungErfassen(l.id, { ...eingabe, nettoBetrag: 1200, rabatt: 0 });
|
||||||
|
|
||||||
|
const rechnung = await testPrisma.proxmoxRechnung.findUniqueOrThrow({ where: { nummer: "291017" } });
|
||||||
|
expect(Number(rechnung.nettoBetrag)).toBe(1200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Zuweisung von Kunde und Standort an einen Host (#036). */
|
||||||
|
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: () => {} }));
|
||||||
|
|
||||||
|
const { hostKundeSetzen } = await import("@/server/actions/host-detail");
|
||||||
|
|
||||||
|
async function aufbau() {
|
||||||
|
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" } }),
|
||||||
|
]);
|
||||||
|
const [standortA, standortB] = await Promise.all([
|
||||||
|
testPrisma.standort.create({ data: { kundeId: kundeA.id, name: "Rechenzentrum A" } }),
|
||||||
|
testPrisma.standort.create({ data: { kundeId: kundeB.id, name: "Rechenzentrum B" } }),
|
||||||
|
]);
|
||||||
|
const host = await testPrisma.host.create({
|
||||||
|
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE" },
|
||||||
|
});
|
||||||
|
return { kundeA, kundeB, standortA, standortB, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formular(werte: Record<string, string>) {
|
||||||
|
const daten = new FormData();
|
||||||
|
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Kunde und Standort setzen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Kunde und Standort zusammen", async () => {
|
||||||
|
const { kundeA, standortA, host } = await aufbau();
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBe(kundeA.id);
|
||||||
|
expect(danach.standortId).toBe(standortA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft einen Standort, der zu einem anderen Kunden gehört", async () => {
|
||||||
|
const { kundeA, standortB, host } = await aufbau();
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortB.id }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBe(kundeA.id);
|
||||||
|
expect(danach.standortId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löst die Zuordnung samt Standort, wenn kein Kunde gewählt ist", async () => {
|
||||||
|
const { kundeA, standortA, host } = await aufbau();
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: "", standortId: "" }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBeNull();
|
||||||
|
expect(danach.standortId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schließt die Warnung „Host ohne Kunde“ beim Zuweisen", async () => {
|
||||||
|
const { kundeA, host } = await aufbau();
|
||||||
|
await testPrisma.alert.create({
|
||||||
|
data: {
|
||||||
|
regel: "host-ohne-kunde",
|
||||||
|
fingerprint: `host-ohne-kunde:${host.id}`,
|
||||||
|
schwere: "INFO",
|
||||||
|
titel: "Host ohne Kunde",
|
||||||
|
beschreibung: "…",
|
||||||
|
hostId: host.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id }));
|
||||||
|
|
||||||
|
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: host.id } });
|
||||||
|
expect(alert.erledigtAm).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft das Öffnen und Schließen des Seitenmenüs. Statt eines echten Browsers genügen hier
|
||||||
|
* Attrappen für `document.body` und `window` – geprüft wird, welche Klassen gesetzt werden.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const klassen = new Set<string>();
|
||||||
|
let breite = 390;
|
||||||
|
|
||||||
|
function aufraeumen() {
|
||||||
|
klassen.clear();
|
||||||
|
breite = 390;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
aufraeumen();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
classList: {
|
||||||
|
toggle(name: string, an?: boolean) {
|
||||||
|
const soll = an ?? !klassen.has(name);
|
||||||
|
if (soll) klassen.add(name);
|
||||||
|
else klassen.delete(name);
|
||||||
|
},
|
||||||
|
remove(name: string) {
|
||||||
|
klassen.delete(name);
|
||||||
|
},
|
||||||
|
contains: (name: string) => klassen.has(name),
|
||||||
|
},
|
||||||
|
style: { overflow: "" },
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.stubGlobal("document", { body });
|
||||||
|
vi.stubGlobal("window", {
|
||||||
|
get innerWidth() {
|
||||||
|
return breite;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Seitenmenü auf schmalen Bildschirmen", () => {
|
||||||
|
it("öffnet und schließt wieder", async () => {
|
||||||
|
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(true);
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sperrt den Inhalt hinter dem offenen Menü gegen Scrollen", async () => {
|
||||||
|
const { menuUmschalten, menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("hidden");
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect((document.body as unknown as { style: { overflow: string } }).style.overflow).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schließt auch beim zweiten Antippen des Knopfes", async () => {
|
||||||
|
const { menuUmschalten } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("macht ein zweites Schließen zum Nichts", async () => {
|
||||||
|
const { menuSchliessen } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuSchliessen();
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("klappt auf breiten Bildschirmen nur ein, statt zu überlagern", async () => {
|
||||||
|
breite = 1400;
|
||||||
|
const { menuUmschalten } = await import("@/components/layout/menuZustand");
|
||||||
|
|
||||||
|
menuUmschalten();
|
||||||
|
expect(klassen.has("sidebar-collapse")).toBe(true);
|
||||||
|
expect(klassen.has("sidebar-open")).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 elf Regeln, wenn nichts abgeschaltet ist", () => {
|
it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
|
||||||
expect(REGELN).toHaveLength(11);
|
expect(REGELN).toHaveLength(13);
|
||||||
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
|
expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Subscription läuft ab", () => {
|
describe("Subscription läuft ab", () => {
|
||||||
@@ -376,4 +378,95 @@ describe("Warnregeln", () => {
|
|||||||
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
|
||||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Kunde hat bezahlt, Einkauf noch offen", () => {
|
||||||
|
it("meldet, sobald der Kunde bezahlt hat und die Proxmox-Rechnung offen ist", () => {
|
||||||
|
// Der übliche Ablauf: unbezahlte Rechnung von Proxmox, Kundenrechnung, Kunde zahlt,
|
||||||
|
// dann zahlt man selbst. Dieser Zustand ist der Moment zum Zahlen.
|
||||||
|
const k = kontext({
|
||||||
|
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const treffer = alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen");
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0].schwere).toBe("INFO");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt, solange der Kunde noch nicht bezahlt hat", () => {
|
||||||
|
const k = kontext({
|
||||||
|
laufzeiten: [laufzeit({ einkaufBezahlt: false, verkaufErstellt: true, verkaufBezahlt: false })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt, wenn die Proxmox-Rechnung längst bezahlt ist", () => {
|
||||||
|
const k = kontext({
|
||||||
|
laufzeiten: [laufzeit({ einkaufBezahlt: true, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alleRegelnPruefen(k).filter((a) => a.regel === "kunde-bezahlt-einkauf-offen")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schweigt, wenn es noch gar keine Einkaufsrechnung gibt", () => {
|
||||||
|
const k = kontext({
|
||||||
|
laufzeiten: [laufzeit({ einkaufBezahlt: null, verkaufErstellt: true, verkaufBezahlt: true })],
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||