Compare commits

..
14 Commits
Author SHA1 Message Date
florian.wilde 02845ed717 Merge branch 'phase/041-doppelte-hosts'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:57:18 +02:00
florian.wildeandClaude Opus 5 3657b60d16 feat(hosts): Doppelte Hosts erkennen und stilllegen (#041)
Zwei Eintraege desselben Namens beim selben Kunden entstehen, wenn sich die
Kennung aus /etc/machine-id aendert - Neuinstallation, geklonter Container,
zurueckgespielte Sicherung. Dieselbe Maschine meldet sich dann als neuer Host,
der alte Eintrag bleibt stehen. Zwei getrennte Maschinen mit gleichem Namen
sind aber genauso moeglich.

Neue dreizehnte Warnregel meldet den Fall und nennt die IP-Adressen zum
Unterscheiden; gleiche Namen bei verschiedenen Kunden bleiben still.

Dazu die bis dahin fehlende Moeglichkeit, einen Host loszuwerden: Stilllegen
blendet ihn aus Listen, Zahlen, Suche und Warnungen aus und schliesst seine
offenen Warnungen, laesst Laufzeiten und Rechnungen aber unberuehrt.
Endgueltig loeschen geht nur ohne Laufzeiten - Buchhaltung wirft man nicht weg.

Kein automatisches Zusammenfuehren: Es muesste raten, welcher Eintrag welche
Rechnung erbt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:57:18 +02:00
florian.wilde f86a328b3c Merge branch 'phase/040-tags-sichtbar'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:43:55 +02:00
florian.wildeandClaude Opus 5 b2664de62f feat(hosts): Schlagworte und Notizen sichtbar machen (#040)
Gleichnamige Hosts in verschiedenen Kundennetzen liessen sich in der Liste
nicht auseinanderhalten. Schlagworte und Notizen gab es zwar schon, aber nur
im Reiter "Notizen und Tags" - und damit nutzlos zum Unterscheiden.

Jetzt stehen die Schlagworte als anklickbare Abzeichen in der Liste und oben
im Host-Detail, es gibt einen Filter dafuer, eine Notiz zeigt ein
Zettel-Symbol mit dem Text beim Ueberfahren, und beides wird mitdurchsucht -
auch von der globalen Suche.

Bewusst kein zweites Feld "Kommentar": Das haette dieselbe Angabe an zwei
Orten gehalten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:43:55 +02:00
florian.wilde dfb2118bac Merge branch 'phase/039-laufzeit-zugang'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:08:54 +02:00
florian.wildeandClaude Opus 5 335fc47496 fix(ui): Zugang zur Laufzeit auf dem Telefon (#039)
Im Reiter Rechnungen der Host-Detailansicht war die Laufzeit nie verlinkt,
und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar. Auf dem
Telefon fuehrte damit kein Weg zu den Rechnungsformularen.

Die Laufzeit ist jetzt ein Link und zugleich Ueberschrift der Karte; in der
Liste bleibt sie auf dem Telefon sichtbar und entfaellt nur zwischen 768 und
992 px. Dafuer braucht es eine eigene Klasse: Bootstraps d-*-Hilfsklassen
setzen display mit !important und ueberschreiben das Kartenlayout.

Nebenbefund: Auch dieser Reiter rechnete den Einkauf ohne Rabatt - dritte
Stelle nach #037 und #038.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:08:54 +02:00
florian.wilde e570d30019 Merge branch 'phase/038-klapplisten'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 17:01:43 +02:00
florian.wildeandClaude Opus 5 c5293f5915 fix(ui): Ueberbreite auf dem Handy beseitigen (#038)
Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

Klapplisten richten sich auf dem Telefon jetzt am Fenster aus statt am Knopf,
die Tabellen bekommen ihre Huelle, lange IDs duerfen umbrechen. Geprueft mit
einer neuen Aufnahme in Telefonbreite (--mobil).

Dabei ein eigener Fehler aus #037 aufgefallen: Die Laufzeit-Detailseite
rechnete weiter mit dem Listenpreis statt mit dem Betrag nach Rabatt - Marge
und Vorschlag fuer die Kundenrechnung waren um den Rabatt zu hoch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 17:01:43 +02:00
florian.wilde c1177067e6 Merge branch 'phase/037-rabatt-position'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:42:12 +02:00
florian.wildeandClaude Opus 5 e50fb30ab3 feat(rechnungen): Rabatt je Position, Reverse Charge, Ablauf-Regel (#037)
Eine echte Proxmox-Rechnung zeigt je Subscription-Key eine Zeile mit
Listenpreis und eine mit "Client Discount". Unser Modell hing den Rabatt an
die Rechnung - bei einer Sammelrechnung ueber zwei Hosts liesse er sich damit
keinem Host zuordnen, und die Marge je Host waere falsch. Schlimmer: Er wurde
nirgends abgezogen.

Jetzt haengt der Rabatt an der Position, der Rechnungsbetrag ist die Summe
nach Abzug, und Marge wie Summen rechnen mit dem tatsaechlich gezahlten
Betrag. Die Migration verteilt vorhandene Rabatte anteilig auf die Positionen
und rechnet die Summen neu - nichts geht verloren.

Dazu ein eigener Steuersatz fuer den Einkauf mit Vorgabe 0: Proxmox rechnet
gegenueber Unternehmen mit USt-IdNr. ueber Reverse Charge ab.

Neue zwoelfte Warnregel aus Florians Ablauf: Der Kunde hat bezahlt, die
Proxmox-Rechnung ist noch offen - der Moment, an dem man selbst zahlt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:42:12 +02:00
florian.wilde 97f6450f1f Merge branch 'phase/035-mobil-tabellen'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 16:20:20 +02:00
florian.wildeandClaude Opus 5 ca2af0c2fc feat(ui): Tabellen als Karten auf dem Handy, Standort am Host (#035, #036)
Breite Tabellen (Kunden, Rechnungen, Ablauf-Kalender) werden unterhalb von
768 px zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts
abgeschnitten zu sein. Kartenkoepfe brechen um, die Jahresbahn bekommt eine
schmalere Namensspalte und einen Hinweis aufs Wischen.

Standort am Host (#036): Das Formular sendete nur den Kunden, die Aktion las
aber auch standortId - jede Kundenzuweisung hat den Standort still geloescht.
Jetzt stehen Kunde und Standort in einem Formular, die Liste richtet sich nach
dem Kunden, und die Aktion prueft, dass der Standort zum Kunden gehoert.

Nebenbefund: Das Kontrollkaestchen fuer den Rechnungsstatus trug
pl-touch-target und wurde dadurch selbst zum 44 Pixel hohen leeren Kasten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 16:20:20 +02:00
florian.wilde 9c873a4069 Merge branch 'phase/034-mobil'
Prüfung / pruefung (push) Canceled after 0s
2026-10-03 15:55:59 +02:00
florian.wildeandClaude Opus 5 09bb98235b fix(ui): Seitenmenue auf dem Handy schliessbar machen (#034)
Das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht
einbinden. Die Klasse sidebar-open liess sich damit setzen, aber nichts nahm
sie wieder weg: keine Flaeche zum Antippen, kein Schliessen beim Navigieren,
kein Escape. Auf dem Telefon blieb das Menue ueber dem Inhalt stehen.

Jetzt gibt es einen eigenen Zustand, auf den Kopfzeile und Menue gemeinsam
hoeren, eine abdunkelnde Flaeche dahinter, einen Schliessen-Knopf im Menuekopf
und das Schliessen beim Navigieren. Der Inhalt dahinter scrollt nicht mehr mit.

Ausserdem zeigt die Dashboard-Tabelle auf dem Telefon nur Host und Ablauf, den
Kunden klein darunter - vorher war rechts die Haelfte abgeschnitten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 15:55:59 +02:00
80 changed files with 2064 additions and 241 deletions
+69
View File
@@ -17,6 +17,75 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit
## [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
### Behoben
+35 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 16 h 58 min
**Gesamt:** 22 h 33 min
| Issue | Zeit |
|---|---|
@@ -39,6 +39,14 @@
| #031 Update für beide Varianten | 0 h 45 min |
| #032 LXC-Installation korrigiert | 0 h 20 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 |
| Meilenstein | Zeit |
|---|---|
@@ -61,7 +69,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.20.1
**Version:** 0.24.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -179,6 +187,31 @@
- #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ß
- 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
### Nächster Schritt
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+53
View File
@@ -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
**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
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
@@ -248,6 +279,23 @@ Positionen und wird automatisch nachgeführt.
Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die
Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions.
### 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
@@ -473,6 +521,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
## 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
Kopfleiste umschalten.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 297 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 279 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 169 KiB

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 252 KiB

+28
View File
@@ -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.
+28
View File
@@ -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.
+22
View File
@@ -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“
+46
View File
@@ -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.
+31
View File
@@ -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.
+32
View File
@@ -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`).
+26
View File
@@ -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.
+35
View File
@@ -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.
+9 -1
View File
@@ -37,4 +37,12 @@ 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 |
| [#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 |
| | **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 |
| | **Summe** | | | **168 h** | **22 h 33 min** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.20.1",
"version": "0.24.0",
"private": true,
"scripts": {
"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");
+10 -2
View File
@@ -134,6 +134,10 @@ model Host {
subNextduedate DateTime? @db.Date
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())
letzterReportAm DateTime?
erstelltAm DateTime @default(now())
@@ -261,9 +265,10 @@ model ProxmoxRechnung {
nummer String @unique
datum 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)
ustSatz Decimal @default(19.00) @db.Decimal(5, 2)
rabatt Decimal? @db.Decimal(12, 2)
/// Bei Proxmox in aller Regel 0: Reverse Charge nach Art. 196 (#037).
ustSatz Decimal @default(0.00) @db.Decimal(5, 2)
zahlungsart String?
bezahlt Boolean @default(false)
bezahltAm DateTime? @db.Date
@@ -284,7 +289,10 @@ model ProxmoxRechnungPosition {
rechnungId String
bezeichnung String
menge Int @default(1)
/// Listenpreis dieser Position, wie er auf der Rechnung steht.
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)
laufzeit Laufzeit?
+2 -1
View File
@@ -166,7 +166,6 @@ async function seedDemo() {
faelligAm: datum(vorlage.faelligAm),
nettoBetrag: zugehoerig.reduce((summe, l) => summe + (l.einkauf?.nettoBetrag ?? 0), 0),
ustSatz: vorlage.ustSatz,
rabatt: vorlage.rabatt ?? null,
zahlungsart: vorlage.zahlungsart ?? null,
bezahlt: vorlage.bezahlt,
bezahltAm: datum(vorlage.bezahltAm),
@@ -175,6 +174,8 @@ async function seedDemo() {
id: `prp-${l.id}`,
bezeichnung: `${l.key} · ${l.start} bis ${l.ende}`,
nettoBetrag: l.einkauf?.nettoBetrag ?? 0,
// Der Rabatt hängt seit #037 an der Position – wie auf der echten Proxmox-Rechnung.
rabatt: vorlage.rabatt ?? null,
})),
},
},
+15 -1
View File
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
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 */
const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite")
@@ -187,6 +195,11 @@ async function main() {
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) {
const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
@@ -195,7 +208,8 @@ async function main() {
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);
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
console.log(` mobil-${seite}.png`);
+84 -32
View File
@@ -16,7 +16,8 @@ import { Tabs } from "@/components/ui/Tabs";
import { PRODUKT_TEXT } from "@/lib/anzeige";
import { prisma } from "@/lib/db";
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 { hostMitDetails } from "@/server/queries/hostliste";
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({
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).
@@ -104,6 +109,32 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
{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>
<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="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} />
<label className="form-label small" htmlFor="kundeId">
Kunde ändern
</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 type="submit" className="btn btn-sm btn-outline-secondary">
{host.archiviertAm ? "Wieder aufnehmen" : "Stilllegen"}
</button>
</div>
</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>
</div>
@@ -239,7 +278,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: (
<>
<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>
<tr>
<th scope="col">Key</th>
@@ -266,8 +306,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null}
</tbody>
</table>
</div>
<h3 className="h6">Server-IDs</h3>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
@@ -295,6 +337,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null}
</tbody>
</table>
</div>
{host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0">
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",
inhalt: (
<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>
<tr>
<th scope="col">Laufzeit</th>
@@ -369,21 +412,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
</thead>
<tbody>
{host.laufzeiten.map((laufzeit) => {
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkauf = laufzeit.proxmoxPosition
? Number(laufzeit.proxmoxPosition.nettoBetrag)
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
: null;
const verkauf = laufzeit.kundenPosition
? Number(laufzeit.kundenPosition.nettoBetrag)
: null;
return (
<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)}
</Link>
{laufzeit.status === "VORSCHLAG" ? (
<span className="badge pl-bg-info ms-2">Vorschlag</span>
) : null}
</td>
<td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
<div>
<EinkaufBadge
@@ -395,8 +445,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/>
</div>
</td>
<td>{formatEuro(einkauf)}</td>
<td>
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
<td data-label="Kundenrechnung">
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
<div>
<VerkaufBadge
@@ -405,8 +455,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
/>
</div>
</td>
<td>{formatEuro(verkauf)}</td>
<td>
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
<td data-label="Marge">
{einkauf !== null && verkauf !== null
? formatEuro(verkauf - einkauf)
: "–"}
@@ -443,6 +493,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Tokens",
icon: "bi-key-fill",
inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0">
<thead>
<tr>
@@ -490,6 +541,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null}
</tbody>
</table>
</div>
),
},
]}
+29 -1
View File
@@ -68,6 +68,8 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
const filter: HostFilter = {
q: einzeln(roh.q),
kunde: einzeln(roh.kunde),
tag: einzeln(roh.tag),
archiviert: einzeln(roh.archiviert) as HostFilter["archiviert"],
status: einzeln(roh.status),
produkt: einzeln(roh.produkt),
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> = {
q: filter.q,
kunde: filter.kunde,
tag: filter.tag,
archiviert: filter.archiviert,
status: filter.status,
produkt: filter.produkt,
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">
<table className="table table-striped align-middle">
@@ -152,7 +156,31 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
{host.hostname}
</Link>
{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>
{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>
{host.kunde ? (
+16 -15
View File
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : (
<div className="table-responsive">
<div style={{ minWidth: "820px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
<div className="pl-timeline">
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
{monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
: "var(--pl-info)";
return (
<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">
{eintrag.hostname}
</Link>
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
)}
<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.
</p>
</Card>
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
tabelle
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th>
<th scope="col">Ablauf</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Proxmox</th>
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
<tbody>
{naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}>
<td>
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
<td className="pl-karten-kopf">
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
{eintrag.hostname}
</Link>
</td>
<td>
<td data-label="Ablauf">
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td data-label="Kunde">
{eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<td data-label="Key">
<CopyableCode wert={eintrag.key} />
</td>
<td>
<td data-label="Proxmox">
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td>
<td>
<td data-label="Kundenrechnung">
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td>
</tr>
+10 -8
View File
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
}
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Kunde</th>
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
const punkte = rechnungspunkte.get(kunde.id);
return (
<tr key={kunde.id}>
<td>
<td className="pl-karten-kopf">
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
{kunde.name}
</Link>
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
</td>
<td className="pl-mono">{kunde.kundennummer}</td>
<td>{kunde.hosts}</td>
<td>{kunde.aktiv}</td>
<td>
<td className="pl-mono" data-label="Kundennummer">
{kunde.kundennummer}
</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} />
</td>
<td>
<td data-label="Warnungen">
{kunde.offeneAlerts > 0 ? (
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
) : (
<Badge farbe="success">keine</Badge>
)}
</td>
<td>
<td data-label="Rechnungen">
{punkte && punkte.offen > 0 ? (
<Badge farbe="danger">{punkte.offen} offen</Badge>
) : (
+22 -8
View File
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
<table className="table table-striped align-middle">
<thead>
<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">Kunde</th>
<th scope="col">Key</th>
<th scope="col" className="d-none d-md-table-cell">
Kunde
</th>
<th scope="col" className="d-none d-lg-table-cell">
Key
</th>
<th scope="col">Ablauf</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
<th scope="col" className="d-none d-sm-table-cell">
Proxmox
</th>
<th scope="col" className="d-none d-sm-table-cell">
Kundenrechnung
</th>
</tr>
</thead>
<tbody>
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
{host.hostname}
</Link>
{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 className="d-none d-md-table-cell">
{host.kunde ? (
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
{host.kunde.name}
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
</Link>
)}
</td>
<td>
<td className="d-none d-lg-table-cell">
<CopyableCode wert={host.subKey} />
</td>
<td>
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
</td>
<td>
<td className="d-none d-sm-table-cell">
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
</td>
<td>
<td className="d-none d-sm-table-cell">
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
</td>
</tr>
+25 -6
View File
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const einkauf = laufzeit.proxmoxPosition?.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 marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<hr />
<dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd>
<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>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt>
@@ -135,7 +145,15 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
) : (
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>
))}
</ul>
@@ -156,12 +174,13 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag,
nettoBetrag: einkaufListenpreis,
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt:
einkauf?.rabatt === null || einkauf?.rabatt === undefined
laufzeit.proxmoxPosition?.rabatt === null ||
laufzeit.proxmoxPosition?.rabatt === undefined
? null
: Number(einkauf.rabatt),
: Number(laufzeit.proxmoxPosition.rabatt),
zahlungsart: einkauf?.zahlungsart ?? null,
bezahlt: einkauf?.bezahlt ?? false,
bezahltAm: einkauf?.bezahltAm?.toISOString() ?? null,
+23 -12
View File
@@ -197,14 +197,14 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</form>
<div className="table-responsive">
<table className="table table-striped align-middle">
<table className="table table-striped align-middle pl-karten-tabelle">
<thead>
<tr>
<th scope="col">Kunde</th>
<th scope="col" className="d-none d-md-table-cell">
Host
</th>
<th scope="col" className="d-none d-lg-table-cell">
<th scope="col" className="pl-spalte-laufzeit">
Laufzeit
</th>
<th scope="col">Proxmox-Rechnung</th>
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
return (
<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 ? (
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
{zeile.kunde.name}
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
{zeile.hostname}
</Link>
</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">
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
</Link>
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
</td>
<td>
<td data-label="Proxmox-Rechnung">
<CopyableCode wert={zeile.einkaufNummer} />
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
<div className="small pl-text-danger">
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
</div>
) : null}
</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
<td className="text-center">
<td className="d-none d-xl-table-cell" data-label="Einkauf">
{formatEuro(zeile.einkaufBetrag)}
</td>
<td className="text-center" data-label="Einkauf bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Proxmox-Rechnung bezahlt"
/>
</td>
<td>
<td data-label="Kundenrechnung">
<CopyableCode wert={zeile.verkaufNummer} />
</td>
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
<td className="text-center">
<td className="d-none d-xl-table-cell" data-label="Verkauf">
{formatEuro(zeile.verkaufBetrag)}
</td>
<td className="text-center" data-label="Rechnung erstellt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
titel="Kundenrechnung erstellt"
/>
</td>
<td className="text-center">
<td className="text-center" data-label="Vom Kunden bezahlt">
<StatusHaken
laufzeitId={zeile.id}
hostId={zeile.hostId}
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
/>
</td>
<td
data-label="Marge"
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
>
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
})}
{zeilen.length === 0 ? (
<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>
</td>
</tr>
+173
View File
@@ -186,6 +186,19 @@ body {
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 ---------- */
.app-content-header {
@@ -369,10 +382,170 @@ body {
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 ---------- */
@media (max-width: 575.98px) {
.pl-touch-target {
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
name="steuersatz"
beschriftung="Steuersatz in Prozent"
hinweis="Vorgabe für neue Rechnungspositionen."
beschriftung="Steuersatz Verkauf in Prozent"
hinweis="Vorgabe für neue Kundenrechnungen."
fehler={fehler.steuersatz}
>
<input
@@ -135,6 +135,24 @@ export function SystemFormular({ werte, appUrl }: { werte: Einstellungen; appUrl
/>
</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
name="zahlungszielTage"
beschriftung="Zahlungsziel in Tagen"
+38 -1
View File
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
*/
export function HostFilterLeiste({
kunden,
tags,
werte,
}: {
kunden: { id: string; name: string }[];
tags: string[];
werte: Record<string, string | undefined>;
}) {
const router = useRouter();
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
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],
).length;
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
</select>
</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">
<select
className="form-select"
@@ -135,6 +158,20 @@ export function HostFilterLeiste({
nur mit offenen Rechnungspunkten
</label>
</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 ? (
<Link href="/hosts" className="small">
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>
);
}
+7 -12
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
import type { Rolle } from "@/generated/prisma/enums";
import { ROLLEN_TEXT } from "@/server/auth/rollen";
import { menuUmschalten } from "./menuZustand";
export interface KopfleistenAlert {
id: string;
@@ -58,16 +59,9 @@ export function Header({
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 body = document.body;
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");
}
menuUmschalten();
}, []);
function themaUmschalten() {
@@ -146,8 +140,7 @@ export function Header({
) : null}
</button>
<div
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
>
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
<div className="dropdown-divider" />
@@ -212,7 +205,9 @@ export function Header({
<i className="bi bi-person-circle" aria-hidden="true" />
<span className="d-none d-sm-inline">{benutzer}</span>
</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">
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
</span>
+38 -2
View File
@@ -2,10 +2,11 @@
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { useState } from "react";
import { useEffect, useState } from "react";
import { LogoMitSchriftzug } from "@/components/Logo";
import type { Rolle } from "@/generated/prisma/enums";
import { darf } from "@/server/auth/rollen";
import { menuSchliessen, useMenuOffen } from "./menuZustand";
interface MenuEintrag {
href: string;
@@ -30,6 +31,21 @@ export function Sidebar({
const pathname = usePathname();
const router = useRouter();
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[] = [
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
@@ -105,11 +121,30 @@ export function Sidebar({
}
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">
<div className="sidebar-brand">
<div className="sidebar-brand d-flex align-items-center justify-content-between">
<Link href="/" className="brand-link">
<LogoMitSchriftzug variante="sidebar" groesse={34} />
</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 className="pl-sidebar-user">
@@ -148,5 +183,6 @@ export function Sidebar({
</nav>
</div>
</aside>
</>
);
}
+66
View File
@@ -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,
);
}
@@ -1,8 +1,9 @@
"use client";
import { useActionState } from "react";
import { useActionState, useState } from "react";
import { kundenrechnungSpeichern, proxmoxRechnungSpeichern } from "@/server/actions/rechnungen";
import type { AktionsErgebnis } from "@/server/actions/kunden";
import { formatEuro } from "@/lib/format";
const LEER: AktionsErgebnis = {};
@@ -42,6 +43,13 @@ export function ProxmoxRechnungFormular({
}) {
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 (
<form action={aktion}>
<input type="hidden" name="laufzeitId" value={laufzeitId} />
@@ -93,16 +101,18 @@ export function ProxmoxRechnungFormular({
<div className="col-md-3">
<label className="form-label" htmlFor="px-netto">
Netto (€) *
Listenpreis netto (€) *
</label>
<input
id="px-netto"
name="nettoBetrag"
className="form-control"
inputMode="decimal"
defaultValue={werte.nettoBetrag ?? ""}
value={netto}
onChange={(e) => setNetto(e.target.value)}
required
/>
<div className="form-text">Betrag dieser einen Laufzeit, wie er auf der Rechnung steht.</div>
</div>
<div className="col-md-2">
<label className="form-label" htmlFor="px-ust">
@@ -125,8 +135,18 @@ export function ProxmoxRechnungFormular({
name="rabatt"
className="form-control"
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 className="col-md-4">
<label className="form-label" htmlFor="px-zahlungsart">
+2 -2
View File
@@ -37,7 +37,7 @@ export function StatusHaken({
}
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 ? (
<span
className="spinner-border spinner-border-sm text-body-secondary"
@@ -47,7 +47,7 @@ export function StatusHaken({
) : (
<input
type="checkbox"
className="form-check-input pl-touch-target"
className="form-check-input"
checked={wert}
disabled={deaktiviert}
onChange={(e) => umschalten(e.target.checked)}
+7 -1
View File
@@ -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.
*/
export async function eigenerPfad(pfad: string, eingetragen?: string): Promise<string> {
let kopfzeilen: Headers;
try {
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.
const basis = oeffentlicheBasis(new Request("http://platzhalter.invalid", { headers: kopfzeilen }), eingetragen);
+1
View File
@@ -35,6 +35,7 @@ export async function einstellungenSpeichernAktion(
waehrung: text(formular, "waehrung") || vorher.waehrung,
steuersatz: zahl(formular, "steuersatz"),
steuersatzEinkauf: zahl(formular, "steuersatzEinkauf"),
zahlungszielTage: zahl(formular, "zahlungszielTage"),
margeProzent: zahl(formular, "margeProzent"),
+18 -2
View File
@@ -57,8 +57,24 @@ export async function hostKundeSetzen(formular: FormData) {
const standortId = formular.get("standortId")?.toString() || null;
if (!hostId) return;
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
// Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
// 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) {
await prisma.alert.updateMany({
+60
View File
@@ -1,6 +1,8 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { eigenerPfad } from "@/lib/adresse";
import { prisma } from "@/lib/db";
import { protokolliere } from "@/server/audit";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
@@ -45,3 +47,61 @@ export async function hostZuordnungAufheben(formular: FormData) {
revalidatePath("/kunden");
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"));
}
+4 -1
View File
@@ -9,13 +9,15 @@ import { alleRegelnPruefen, REGELN, type AlertKandidat, type RegelKontext } from
export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKontext> {
const [hosts, laufzeiten, einstellungen] = await Promise.all([
prisma.host.findMany({
// Stillgelegte Hosts erzeugen keine Warnungen mehr (#041).
where: { archiviertAm: null },
include: {
kunde: { select: { name: true } },
serverIdHistorie: { select: { serverId: true, ersteMeldung: true, letzteMeldung: true } },
},
}),
prisma.laufzeit.findMany({
where: { status: "BESTAETIGT" },
where: { status: "BESTAETIGT", host: { archiviertAm: null } },
include: {
host: { select: { hostname: true, kundeId: true, kunde: { select: { name: true } } } },
proxmoxPosition: { include: { rechnung: true } },
@@ -40,6 +42,7 @@ export async function kontextLaden(jetzt: Date = new Date()): Promise<RegelKonte
subLizenzierteSockets: host.subLizenzierteSockets,
sockets: host.sockets,
letzterReportAm: host.letzterReportAm,
ips: host.ips,
serverIds: host.serverIdHistorie,
})),
laufzeiten: laufzeiten.map((laufzeit) => ({
+80
View File
@@ -32,6 +32,7 @@ export interface HostDaten {
subLizenzierteSockets: number | null;
sockets: number | null;
letzterReportAm: Date | null;
ips: string[];
serverIds: { serverId: string; ersteMeldung: Date; letzteMeldung: Date }[];
}
@@ -405,6 +406,83 @@ 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) => {
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[] = [
subscriptionLaeuftAb,
statusNichtAktiv,
@@ -417,6 +495,8 @@ export const REGELN: Regel[] = [
proxmoxRechnungFaellig,
kundenrechnungFehlt,
kundenrechnungUnbezahlt,
kundeBezahltEinkaufOffen,
hostnameDoppelt,
];
/** Wendet alle aktiven Regeln an. */
+6
View File
@@ -24,7 +24,13 @@ export const einstellungenSchema = z.object({
// --- Kaufmännisches ---
waehrung: z.enum(["EUR", "CHF"]).default("EUR"),
/** Steuersatz der Kundenrechnungen. */
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),
/** Vorschlag für den Aufschlag auf den Einkaufspreis beim Anlegen einer Kundenrechnung. */
margeProzent: z.number().min(0).max(500).default(20),
+1 -1
View File
@@ -12,7 +12,7 @@ export async function hostsNachAblauf(jetzt: Date = new Date()) {
const einkaufSichtbar = await darfEinkaufSehen();
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" },
include: {
kunde: { select: { id: true, name: true } },
+22 -1
View File
@@ -6,6 +6,10 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
export interface HostFilter {
q?: string;
kunde?: string;
/** Schlagwort, nach dem gefiltert wird (#040). */
tag?: string;
/** Stillgelegte Hosts mitzeigen (#041). */
archiviert?: "ja";
status?: string;
produkt?: string;
ablauf?: "30" | "14" | "abgelaufen";
@@ -20,6 +24,13 @@ export const SEITENGROESSE = 25;
function bedingung(filter: HostFilter, jetzt: Date): 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) {
const suche = filter.q.trim();
und.push({
@@ -30,6 +41,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
{ subKey: { contains: suche, mode: "insensitive" } },
{ ips: { has: suche } },
{ 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.
{
laufzeiten: {
@@ -113,6 +126,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
include: {
kunde: { select: { id: true, 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" },
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) };
}
+26 -8
View File
@@ -8,33 +8,36 @@ export function meldungsGrenze(jetzt: Date = new Date()): Date {
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() {
// Ein Kundenkonto hat hier nichts zu suchen: Nicht zugeordnete Hosts gehören per Definition
// noch niemandem, dürfen also auch niemandem gezeigt werden.
if (await sichtbarerKunde()) return [];
return prisma.host.findMany({
where: { kundeId: null },
where: { kundeId: null, ...NICHT_ARCHIVIERT },
orderBy: { erstmalsGesehenAm: "desc" },
});
}
export async function hostAnzahl() {
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. */
export async function kennzahlen(jetzt: Date = new Date()) {
const in30Tagen = new Date(jetzt.getTime() + 30 * 86_400_000);
const kundeId = await sichtbarerKunde();
const hostSicht = kundeId ? { kundeId } : {};
const hostSicht = kundeId ? { kundeId, ...NICHT_ARCHIVIERT } : NICHT_ARCHIVIERT;
const laufzeitSicht = kundeId ? { host: { kundeId } } : {};
const [gesamt, aktiv, ohneKunde, baldAblaufend, ohneMeldung, offeneAlerts] = await Promise.all([
prisma.host.count({ where: hostSicht }),
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: {
@@ -42,7 +45,12 @@ export async function kennzahlen(jetzt: Date = new Date()) {
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([
@@ -94,7 +102,7 @@ export async function suchIndex() {
const [hosts, kunden, proxmoxRechnungen, kundenrechnungen] = await Promise.all([
prisma.host.findMany({
where: kundeId ? { kundeId } : {},
where: { ...NICHT_ARCHIVIERT, ...(kundeId ? { kundeId } : {}) },
select: {
id: true,
hostname: true,
@@ -103,7 +111,9 @@ export async function suchIndex() {
subKey: true,
ips: true,
produkt: true,
notizen: true,
kunde: { select: { name: true } },
tags: { select: { name: true } },
},
}),
prisma.kunde.findMany({
@@ -128,9 +138,17 @@ export async function suchIndex() {
titel: host.hostname,
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
host.subKey ? ` · ${host.subKey}` : ""
}`,
}${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
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))
.map((w) => w.toLowerCase()),
})),
+1 -1
View File
@@ -6,7 +6,7 @@ export async function ablaufDaten(jetzt: Date = new Date()) {
const nurKunde = await sichtbarerKunde();
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" }],
select: {
id: true,
+5 -1
View File
@@ -87,7 +87,11 @@ export async function laufzeitenVonKunde(kundeId: string) {
einkaufSichtbar && laufzeit.proxmoxPosition
? {
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,
faelligAm: laufzeit.proxmoxPosition.rechnung.faelligAm,
}
+4 -1
View File
@@ -76,8 +76,11 @@ export async function rechnungsListe(filter: RechnungsFilter) {
const zeilen = laufzeiten.map((laufzeit) => {
// 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.
// Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird.
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;
return {
+18 -7
View File
@@ -68,7 +68,8 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
if (!laufzeit) throw new RechnungsFehler("Laufzeit nicht gefunden.");
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 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,
faelligAm: eingabe.faelligAm,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -97,7 +97,6 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
faelligAm: eingabe.faelligAm,
nettoBetrag: eingabe.nettoBetrag,
ustSatz: eingabe.ustSatz ?? vorgabeSteuersatz,
rabatt: eingabe.rabatt ?? null,
zahlungsart: eingabe.zahlungsart ?? null,
bezahlt: eingabe.bezahlt,
bezahltAm: eingabe.bezahlt ? (eingabe.bezahltAm ?? new Date()) : null,
@@ -109,10 +108,20 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
const position = laufzeit.proxmoxPosition
? await tx.proxmoxRechnungPosition.update({
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({
data: { rechnungId: rechnung.id, bezeichnung, nettoBetrag: eingabe.nettoBetrag },
data: {
rechnungId: rechnung.id,
bezeichnung,
nettoBetrag: eingabe.nettoBetrag,
rabatt: eingabe.rabatt ?? null,
},
});
await tx.laufzeit.update({
@@ -120,9 +129,11 @@ export async function proxmoxRechnungErfassen(laufzeitId: string, eingabe: Proxm
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 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 protokolliere("proxmox_rechnung.erfasst", "ProxmoxRechnung", rechnung.id, {
+1 -1
View File
@@ -30,7 +30,7 @@ describe.runIf(datenbankVerfuegbar)("Abgleich der Warnungen", () => {
it("legt die Standard-Einstellungen für alle Regeln an", async () => {
const einstellungen = await testPrisma.warnregelEinstellung.findMany();
expect(einstellungen).toHaveLength(11);
expect(einstellungen).toHaveLength(13);
const ablauf = einstellungen.find((e) => e.regel === "subscription-laeuft-ab");
expect(ablauf?.schwellwerte).toEqual({ tage: [30, 14, 7] });
});
+102
View File
@@ -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);
});
});
+94
View File
@@ -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();
});
});
+97
View File
@@ -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);
});
});
+89
View File
@@ -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");
});
});
+91
View File
@@ -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);
});
});
+95 -3
View File
@@ -28,6 +28,7 @@ function host(überschreibungen: Partial<HostDaten> = {}): HostDaten {
subLizenzierteSockets: 1,
sockets: 1,
letzterReportAm: new Date(JETZT.getTime() - 3_600_000),
ips: ["10.0.0.10"],
serverIds: [{ serverId: "AAAA", ersteMeldung: inTagen(-300), letzteMeldung: JETZT }],
...überschreibungen,
};
@@ -72,9 +73,9 @@ function ausRegel(k: RegelKontext, name: string) {
}
describe("Warnregeln", () => {
it("prüft alle elf Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(11);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(11);
it("prüft alle dreizehn Regeln, wenn nichts abgeschaltet ist", () => {
expect(REGELN).toHaveLength(13);
expect(new Set(REGELN.map((r) => r.name)).size).toBe(13);
});
describe("Subscription läuft ab", () => {
@@ -376,4 +377,95 @@ describe("Warnregeln", () => {
const fingerprints = alleRegelnPruefen(k).map((kandidat) => kandidat.fingerprint);
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");
});
});
});