Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97f6450f1f | ||
|
|
ca2af0c2fc | ||
|
|
9c873a4069 | ||
|
|
09bb98235b |
@@ -17,6 +17,27 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.21.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Standort eines Hosts lässt sich in der Detailansicht setzen; die Auswahl richtet sich nach dem gewählten Kunden (#036)
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten: jede Angabe mit ihrer Beschriftung, statt rechts abgeschnitten zu sein (#035)
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Jede Kundenzuweisung löschte den Standort des Hosts**, weil das Formular ihn nicht mitsendete, die Aktion ihn aber las (#036)
|
||||||
|
- Ein Standort, der zu einem anderen Kunden gehört, wird nicht mehr gespeichert (#036)
|
||||||
|
- Kartenköpfe mit Knöpfen liefen auf dem Telefon über den Rand (#035)
|
||||||
|
- Das Kontrollkästchen für den Rechnungsstatus wurde auf dem Telefon zum 44 Pixel hohen leeren Kasten (#035)
|
||||||
|
|
||||||
|
## [0.20.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Das Seitenmenü ließ sich auf dem Handy nicht schließen.** Es gab weder eine Fläche zum Antippen noch ein Schließen beim Navigieren – das Markup stammt von AdminLTE, dessen JavaScript wir bewusst nicht einbinden (#034)
|
||||||
|
- Die Dashboard-Tabelle war auf dem Telefon rechts abgeschnitten; sie zeigt dort jetzt Host und Ablauf, den Kunden klein darunter (#034)
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schließen-Knopf im Menükopf, Schließen per Escape, und der Inhalt hinter dem offenen Menü scrollt nicht mehr mit (#034)
|
||||||
|
|
||||||
## [0.20.1] – 2026-10-03
|
## [0.20.1] – 2026-10-03
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 16 h 58 min
|
**Gesamt:** 18 h 58 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -39,6 +39,9 @@
|
|||||||
| #031 Update für beide Varianten | 0 h 45 min |
|
| #031 Update für beide Varianten | 0 h 45 min |
|
||||||
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
| #032 LXC-Installation korrigiert | 0 h 20 min |
|
||||||
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
| #033 Weiterleitung auf Lausch-Adresse | 0 h 30 min |
|
||||||
|
| #034 Bedienung auf dem Handy | 0 h 40 min |
|
||||||
|
| #035 Tabellen als Karten | 0 h 50 min |
|
||||||
|
| #036 Standort am Host | 0 h 30 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -61,7 +64,7 @@
|
|||||||
|
|
||||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||||
**Version:** 0.20.1
|
**Version:** 0.21.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -179,6 +182,13 @@
|
|||||||
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
- #033 war hausgemacht: Mein `HOSTNAME=0.0.0.0` aus #031 hat den Healthcheck repariert und dafür alle Weiterleitungen aus Server Actions auf `0.0.0.0` gelenkt. Vorher stand dort der Containername – derselbe Fehler, nur unauffälliger
|
||||||
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
- Lehre: Weiterleitungen gehören aus der konfigurierten Adresse gebaut, nie aus dem, was der Server über sich selbst weiß
|
||||||
|
|
||||||
|
- Mobile Bedienung (#034): Das Menü ließ sich auf dem Telefon nicht schließen – AdminLTE-Markup ohne AdminLTE-JavaScript. Eigener Zustand, abdunkelnde Fläche, Schließen beim Navigieren und mit Escape
|
||||||
|
- Lehre: Die Mobilansicht war mit Screenshots geprüft, nicht mit den Fingern. Ein Bild zeigt nicht, dass etwas nicht wieder weggeht
|
||||||
|
|
||||||
|
- Breite Tabellen werden auf dem Telefon zu Karten (#035); dabei fiel auf, dass `pl-touch-target` am Kontrollkästchen selbst saß und es zum leeren Kasten aufblies
|
||||||
|
- Standort am Host (#036): Die Meldung lautete „kann man nicht einstellen“ – tatsächlich löschte jede Kundenzuweisung den vorhandenen Standort, weil das Formular ihn nicht mitsendete und die Aktion ihn trotzdem las
|
||||||
|
- Beide Fehler kamen aus dem echten Einsatz, nicht aus den Tests. Das bleibt die ergiebigste Quelle
|
||||||
|
|
||||||
### Nächster Schritt
|
### Nächster Schritt
|
||||||
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
|
||||||
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
|
||||||
|
|||||||
@@ -175,6 +175,9 @@ weitergeben, und der Zurück-Knopf funktioniert wie erwartet.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
Im Detail eines Hosts lassen sich Kunde **und Standort** gemeinsam ändern. Die Standortliste zeigt nur
|
||||||
|
die Standorte des gewählten Kunden; angelegt werden sie auf der Kundenseite.
|
||||||
|
|
||||||
## 4a. Cluster
|
## 4a. Cluster
|
||||||
|
|
||||||
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
**In Proxmox hat jeder Knoten eine eigene Subscription** mit eigener Server-ID – ein Cluster mit drei
|
||||||
@@ -473,6 +476,11 @@ Server-ID stehen in der Detailansicht unter *Subscription-Historie*.
|
|||||||
|
|
||||||
## Anhang: dunkles Design und Mobilansicht
|
## Anhang: dunkles Design und Mobilansicht
|
||||||
|
|
||||||
|
Auf dem Telefon öffnet das Symbol links oben das Menü; es schließt sich durch Antippen der
|
||||||
|
abgedunkelten Fläche, über das Kreuz im Menükopf, mit Escape – und von selbst, sobald ein Eintrag
|
||||||
|
gewählt wurde. Die Tabellen zeigen auf schmalen Bildschirmen nur die wichtigsten Spalten; die
|
||||||
|
übrigen Angaben stehen auf der jeweiligen Detailseite.
|
||||||
|
|
||||||
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
Das Dashboard folgt der Einstellung des Betriebssystems und lässt sich über das Mondsymbol in der
|
||||||
Kopfleiste umschalten.
|
Kopfleiste umschalten.
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 248 KiB |
|
Before Width: | Height: | Size: 164 KiB After Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 297 KiB After Width: | Height: | Size: 308 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 274 KiB After Width: | Height: | Size: 278 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 196 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 117 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 210 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 252 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 252 KiB |
@@ -0,0 +1,28 @@
|
|||||||
|
# #034 Bedienung auf dem Handy
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.20.2
|
||||||
|
**Labels:** ui, bug
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
Florian im produktiven Einsatz auf dem Telefon: „Das Menü geht nicht weg.“ Dazu eine Tabelle, von der
|
||||||
|
rechts die Hälfte abgeschnitten war.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Das Markup stammt von AdminLTE, dessen JavaScript binden wir bewusst nicht ein. Die Klasse
|
||||||
|
`sidebar-open` ließ sich damit setzen – aber nichts nahm sie wieder weg: keine Fläche zum Antippen,
|
||||||
|
kein Schließen beim Navigieren, kein Escape. Auf dem Telefon blieb das Menü über dem Inhalt stehen.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Eigener, winziger Zustand (`menuZustand.ts`), auf den Kopfzeile und Menü gemeinsam hören
|
||||||
|
- Abdunkelnde Fläche hinter dem Menü – antippen schließt (ein `button`, damit auch per Tastatur erreichbar)
|
||||||
|
- Schließen-Knopf im Menükopf, nur auf schmalen Bildschirmen
|
||||||
|
- Schließt beim Navigieren und mit Escape
|
||||||
|
- Der Inhalt dahinter scrollt nicht mehr mit, solange das Menü offen ist
|
||||||
|
- Dashboard-Tabelle: auf dem Telefon nur Host und Ablauf, der Kunde steht klein unter dem Hostnamen.
|
||||||
|
Key und Rechnungsstände erscheinen ab Tablet-Breite – die Host-Tabelle machte das schon vorher so.
|
||||||
|
- Fünf Tests für die Zustandslogik
|
||||||
|
|
||||||
|
## Nicht geändert
|
||||||
|
Die abdunkelnde Fläche gilt nur unter 992 px. Auf breiten Bildschirmen bleibt es beim bisherigen
|
||||||
|
Verhalten: Das Menü klappt zu Symbolen zusammen, statt sich über den Inhalt zu legen.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# #035 Tabellen auf dem Handy als Karten
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 3 h **Tatsächlich:** 0 h 50 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Etwas schöner darstellen auf dem Handy muss doch drin sein.“ Auf Rechnungen, Kunden und Jahresbahn
|
||||||
|
war rechts jeweils die Hälfte abgeschnitten, Knöpfe im Kartenkopf liefen über den Rand, und Namen
|
||||||
|
brachen Buchstabe für Buchstabe um.
|
||||||
|
|
||||||
|
## Aufgaben
|
||||||
|
- [x] `.pl-karten-tabelle`: unterhalb von 768 px wird jede Tabellenzeile zur Karte, jede Zelle bekommt
|
||||||
|
ihre Beschriftung aus `data-label` davor
|
||||||
|
- [x] Angewandt auf Kunden, Rechnungen und den Ablauf-Kalender der Jahresbahn
|
||||||
|
- [x] Kartenkopf bricht um, statt über den Rand zu laufen
|
||||||
|
- [x] Jahresbahn: schmalere Namensspalte auf dem Telefon, dazu der Hinweis aufs seitliche Wischen
|
||||||
|
- [x] Mobilansichten der breiten Seiten kommen jetzt mit in die Bilderstrecke
|
||||||
|
|
||||||
|
## Warum Karten statt Scrollen
|
||||||
|
Eine Tabelle mit elf Spalten lässt sich auf 390 px nicht sinnvoll anzeigen. Seitliches Scrollen
|
||||||
|
versteckt die Hälfte der Angaben, und Spalten wegzulassen nimmt sie ganz weg. Als Karte steht jede
|
||||||
|
Angabe mit ihrer Beschriftung da – länger, aber vollständig.
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Das Kontrollkästchen für den Rechnungsstatus trug `pl-touch-target`, also `min-height: 44px` – und
|
||||||
|
wurde damit selbst zum 44 Pixel hohen leeren Kasten. Die Fläche zum Treffen bekommt jetzt sein Umfeld,
|
||||||
|
das Kästchen behält seine Größe.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# #036 Standort am Host setzen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.21.0
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Den Standort bei den Hosts kann man nicht einstellen.“
|
||||||
|
|
||||||
|
## Was wirklich los war
|
||||||
|
Schlimmer als gemeldet: Das Formular in der Host-Detailansicht sendete **nur** den Kunden. Die Aktion
|
||||||
|
`hostKundeSetzen` las aber auch `standortId` – und bekam nichts. Jede Kundenzuweisung hat damit den
|
||||||
|
Standort **stillschweigend gelöscht**. Setzen ging nur beim Ausrollen über die Sammelzuweisung unter
|
||||||
|
*Hosts → Nicht zugeordnet*; danach war er beim nächsten Speichern wieder weg.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Kunde und Standort in **einem** Formular, die Standortliste richtet sich nach dem gewählten Kunden
|
||||||
|
- Wechselt der Kunde, wird die Standortauswahl zurückgesetzt statt stehen zu bleiben
|
||||||
|
- Hat ein Kunde keine Standorte, sagt das Formular es, statt eine leere Auswahl anzubieten
|
||||||
|
- Die Aktion prüft jetzt, dass der Standort zum Kunden gehört, und verwirft ihn sonst – eine falsche
|
||||||
|
Zuordnung wäre schlimmer als keine
|
||||||
|
- Vier Tests, darunter der Fall „Standort eines anderen Kunden“
|
||||||
@@ -37,4 +37,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
| [#031](031-update-skript-beide-varianten.md) | Update- und Sicherungsskript für beide Varianten | erledigt | v0.20.0 | 2 h | 0 h 45 min |
|
||||||
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
| [#032](032-lxc-installation-korrekturen.md) | LXC-Installation: Herkunft der Skripte und Speicher | erledigt | v0.20.1 | 1 h | 0 h 20 min |
|
||||||
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
| [#033](033-weiterleitung-lauschadresse.md) | Abmelden führte auf die Lausch-Adresse | erledigt | v0.20.1 | 1 h | 0 h 30 min |
|
||||||
| | **Summe** | | | **151 h** | **16 h 58 min** |
|
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
|
||||||
|
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 min |
|
||||||
|
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
|
||||||
|
| | **Summe** | | | **157 h** | **18 h 58 min** |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.20.1",
|
"version": "0.21.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -195,7 +195,8 @@ async function main() {
|
|||||||
console.log(` ${seite}-dunkel.png`);
|
console.log(` ${seite}-dunkel.png`);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const seite of ["dashboard", "hosts"]) {
|
// Die Seiten mit den breitesten Tabellen – dort entscheidet sich die Mobilansicht (#035).
|
||||||
|
for (const seite of ["dashboard", "hosts", "kunden", "rechnungen", "jahresbahn"]) {
|
||||||
const eintrag = SEITEN.find((s) => s.name === seite);
|
const eintrag = SEITEN.find((s) => s.name === seite);
|
||||||
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
await aufnehmen(hell, eintrag.pfad, path.join(ZIEL, `mobil-${seite}.png`), 390, 1500, adresse);
|
||||||
console.log(` mobil-${seite}.png`);
|
console.log(` mobil-${seite}.png`);
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { Tabs } from "@/components/ui/Tabs";
|
|||||||
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
import { PRODUKT_TEXT } from "@/lib/anzeige";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
import { formatDatum, formatDatumZeit, formatEuro, formatUptime, formatZahl } from "@/lib/format";
|
||||||
import { hostKundeSetzen } from "@/server/actions/host-detail";
|
import { KundeStandortFormular } from "@/components/hosts/KundeStandortFormular";
|
||||||
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
import { hostTokenWiderrufen } from "@/server/actions/tokens";
|
||||||
import { hostMitDetails } from "@/server/queries/hostliste";
|
import { hostMitDetails } from "@/server/queries/hostliste";
|
||||||
import { meldungsGrenze } from "@/server/queries/hosts";
|
import { meldungsGrenze } from "@/server/queries/hosts";
|
||||||
@@ -43,7 +43,11 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
|
|
||||||
const kunden = await prisma.kunde.findMany({
|
const kunden = await prisma.kunde.findMany({
|
||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
standorte: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
// Welche Cluster-Mitglieder melden sich selbst? Fehlende Knoten haben keinen Agent (#022).
|
||||||
@@ -167,30 +171,12 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
<Zeile bezeichnung="Erstmals gesehen">{formatDatum(host.erstmalsGesehenAm)}</Zeile>
|
||||||
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
<Zeile bezeichnung="Letzte Meldung">{formatDatumZeit(host.letzterReportAm)}</Zeile>
|
||||||
|
|
||||||
<form action={hostKundeSetzen} className="mt-3">
|
<KundeStandortFormular
|
||||||
<input type="hidden" name="hostId" value={host.id} />
|
hostId={host.id}
|
||||||
<label className="form-label small" htmlFor="kundeId">
|
kunden={kunden}
|
||||||
Kunde ändern
|
kundeId={host.kundeId}
|
||||||
</label>
|
standortId={host.standortId}
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {
|
|||||||
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<div style={{ minWidth: "820px" }}>
|
<div className="pl-timeline">
|
||||||
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
|
<div className="d-flex text-body-secondary small mb-2 pl-timeline-kopf">
|
||||||
{monate.map((monat) => (
|
{monate.map((monat) => (
|
||||||
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
<div key={monat} style={{ width: `${100 / 12}%` }}>
|
||||||
{monat}
|
{monat}
|
||||||
@@ -72,7 +72,7 @@ export default async function JahresbahnSeite() {
|
|||||||
: "var(--pl-info)";
|
: "var(--pl-info)";
|
||||||
return (
|
return (
|
||||||
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
|
||||||
<div className="small text-truncate" style={{ width: "13rem" }}>
|
<div className="small text-truncate pl-timeline-name">
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -112,7 +112,8 @@ export default async function JahresbahnSeite() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="small text-body-secondary mb-0">
|
<p className="small text-body-secondary mb-0">
|
||||||
Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
<span className="d-md-none">Seitwärts wischen zeigt die späteren Monate. </span>Die rote
|
||||||
|
Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb
|
||||||
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() {
|
|||||||
tabelle
|
tabelle
|
||||||
>
|
>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Ablauf</th>
|
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
<th scope="col">Key</th>
|
<th scope="col">Key</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col">Proxmox</th>
|
||||||
@@ -139,15 +140,15 @@ export default async function JahresbahnSeite() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{naechste90Tage.map((eintrag) => (
|
{naechste90Tage.map((eintrag) => (
|
||||||
<tr key={eintrag.hostId}>
|
<tr key={eintrag.hostId}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
|
|
||||||
{eintrag.hostname}
|
{eintrag.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Ablauf">
|
||||||
|
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde">
|
||||||
{eintrag.kunde ? (
|
{eintrag.kunde ? (
|
||||||
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
|
||||||
{eintrag.kunde.name}
|
{eintrag.kunde.name}
|
||||||
@@ -156,13 +157,13 @@ export default async function JahresbahnSeite() {
|
|||||||
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Key">
|
||||||
<CopyableCode wert={eintrag.key} />
|
<CopyableCode wert={eintrag.key} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox">
|
||||||
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default async function KundenSeite() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
@@ -56,26 +56,28 @@ export default async function KundenSeite() {
|
|||||||
const punkte = rechnungspunkte.get(kunde.id);
|
const punkte = rechnungspunkte.get(kunde.id);
|
||||||
return (
|
return (
|
||||||
<tr key={kunde.id}>
|
<tr key={kunde.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
<Link href={`/kunden/${kunde.id}`} className="fw-semibold text-decoration-none">
|
||||||
{kunde.name}
|
{kunde.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
<div className="small text-body-secondary">{kunde.ansprechpartner ?? "–"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-mono">{kunde.kundennummer}</td>
|
<td className="pl-mono" data-label="Kundennummer">
|
||||||
<td>{kunde.hosts}</td>
|
{kunde.kundennummer}
|
||||||
<td>{kunde.aktiv}</td>
|
</td>
|
||||||
<td>
|
<td data-label="Hosts">{kunde.hosts}</td>
|
||||||
|
<td data-label="Aktiv lizenziert">{kunde.aktiv}</td>
|
||||||
|
<td data-label="Nächster Ablauf">
|
||||||
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={kunde.naechsterAblauf?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Warnungen">
|
||||||
{kunde.offeneAlerts > 0 ? (
|
{kunde.offeneAlerts > 0 ? (
|
||||||
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
<Badge farbe="warning">{kunde.offeneAlerts} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge farbe="success">keine</Badge>
|
<Badge farbe="success">keine</Badge>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Rechnungen">
|
||||||
{punkte && punkte.offen > 0 ? (
|
{punkte && punkte.offen > 0 ? (
|
||||||
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
<Badge farbe="danger">{punkte.offen} offen</Badge>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -121,12 +121,22 @@ export default async function DashboardSeite() {
|
|||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{/* Auf dem Handy bleiben Host und Ablauf; der Rest käme ohnehin nur
|
||||||
|
per Seitwärtsscrollen ins Bild (#034). */}
|
||||||
<th scope="col">Host</th>
|
<th scope="col">Host</th>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
<th scope="col">Key</th>
|
Kunde
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-lg-table-cell">
|
||||||
|
Key
|
||||||
|
</th>
|
||||||
<th scope="col">Ablauf</th>
|
<th scope="col">Ablauf</th>
|
||||||
<th scope="col">Proxmox</th>
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
<th scope="col">Kundenrechnung</th>
|
Proxmox
|
||||||
|
</th>
|
||||||
|
<th scope="col" className="d-none d-sm-table-cell">
|
||||||
|
Kundenrechnung
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -137,8 +147,12 @@ export default async function DashboardSeite() {
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{/* Auf schmalen Bildschirmen steht der Kunde hier mit darunter. */}
|
||||||
|
<div className="small text-body-secondary d-md-none">
|
||||||
|
{host.kunde ? host.kunde.name : "Nicht zugeordnet"}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-md-table-cell">
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${host.kunde.id}`} className="text-decoration-none">
|
||||||
{host.kunde.name}
|
{host.kunde.name}
|
||||||
@@ -152,16 +166,16 @@ export default async function DashboardSeite() {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-lg-table-cell">
|
||||||
<CopyableCode wert={host.subKey} />
|
<CopyableCode wert={host.subKey} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
<AblaufBadge datum={host.nextduedate?.toISOString() ?? null} heute={heute} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
<EinkaufBadge bezahlt={host.einkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td className="d-none d-sm-table-cell">
|
||||||
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
<VerkaufBadge erstellt={host.verkaufErstellt} bezahlt={host.verkaufBezahlt} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Kunde</th>
|
<th scope="col">Kunde</th>
|
||||||
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
|
const faelligIn = tageBis(zeile.einkaufFaelligAm, heute);
|
||||||
return (
|
return (
|
||||||
<tr key={zeile.id}>
|
<tr key={zeile.id}>
|
||||||
<td>
|
{/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */}
|
||||||
|
<td className="d-md-none pl-karten-kopf">
|
||||||
|
<Link href={`/hosts/${zeile.hostId}`} className="fw-semibold text-decoration-none">
|
||||||
|
{zeile.hostname}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde">
|
||||||
{zeile.kunde ? (
|
{zeile.kunde ? (
|
||||||
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
|
<Link href={`/kunden/${zeile.kunde.id}`} className="text-decoration-none">
|
||||||
{zeile.kunde.name}
|
{zeile.kunde.name}
|
||||||
@@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
{zeile.hostname}
|
{zeile.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-lg-table-cell">
|
<td className="d-none d-lg-table-cell" data-label="Laufzeit">
|
||||||
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
||||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||||
</Link>
|
</Link>
|
||||||
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
|
{zeile.vorschlag ? <Badge farbe="info">Vorschlag</Badge> : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={zeile.einkaufNummer} />
|
<CopyableCode wert={zeile.einkaufNummer} />
|
||||||
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
|
{zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
|
||||||
<div className="small pl-text-danger">
|
<div className="small pl-text-danger">
|
||||||
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.einkaufBetrag)}</td>
|
<td className="d-none d-xl-table-cell" data-label="Einkauf">
|
||||||
<td className="text-center">
|
{formatEuro(zeile.einkaufBetrag)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center" data-label="Einkauf bezahlt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -273,11 +281,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
titel="Proxmox-Rechnung bezahlt"
|
titel="Proxmox-Rechnung bezahlt"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={zeile.verkaufNummer} />
|
<CopyableCode wert={zeile.verkaufNummer} />
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-xl-table-cell">{formatEuro(zeile.verkaufBetrag)}</td>
|
<td className="d-none d-xl-table-cell" data-label="Verkauf">
|
||||||
<td className="text-center">
|
{formatEuro(zeile.verkaufBetrag)}
|
||||||
|
</td>
|
||||||
|
<td className="text-center" data-label="Rechnung erstellt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -286,7 +296,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
titel="Kundenrechnung erstellt"
|
titel="Kundenrechnung erstellt"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="text-center">
|
<td className="text-center" data-label="Vom Kunden bezahlt">
|
||||||
<StatusHaken
|
<StatusHaken
|
||||||
laufzeitId={zeile.id}
|
laufzeitId={zeile.id}
|
||||||
hostId={zeile.hostId}
|
hostId={zeile.hostId}
|
||||||
@@ -297,6 +307,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td
|
<td
|
||||||
|
data-label="Marge"
|
||||||
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
className={`d-none d-xl-table-cell ${(zeile.marge ?? 0) >= 0 ? "pl-text-success" : "pl-text-danger"}`}
|
||||||
>
|
>
|
||||||
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
{zeile.marge === null ? "–" : formatEuro(zeile.marge)}
|
||||||
@@ -306,7 +317,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
})}
|
})}
|
||||||
{zeilen.length === 0 ? (
|
{zeilen.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="text-center text-body-secondary py-4">
|
<td colSpan={12} className="text-center text-body-secondary py-4">
|
||||||
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
|
Nichts gefunden. <Link href="/rechnungen?status=alle">Alle anzeigen</Link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -186,6 +186,19 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Fläche hinter dem geöffneten Menü auf schmalen Bildschirmen. Ein Knopf statt eines div,
|
||||||
|
* damit sie auch per Tastatur erreichbar ist.
|
||||||
|
*/
|
||||||
|
.pl-menu-hintergrund {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1037; /* unter dem Menü (1038), über allem anderen */
|
||||||
|
padding: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Inhaltsbereich ---------- */
|
/* ---------- Inhaltsbereich ---------- */
|
||||||
|
|
||||||
.app-content-header {
|
.app-content-header {
|
||||||
@@ -369,10 +382,128 @@ body {
|
|||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zeitleiste der Jahresbahn: Breiten als Klassen, damit sie auf dem Telefon kleiner ausfallen. */
|
||||||
|
.pl-timeline {
|
||||||
|
min-width: 820px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-kopf {
|
||||||
|
margin-left: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-timeline-name {
|
||||||
|
width: 13rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobil ---------- */
|
/* ---------- Mobil ---------- */
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
.pl-touch-target {
|
.pl-touch-target {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein Kontrollkästchen darf nicht selbst 44 px hoch werden – sonst steht in der Zeile ein
|
||||||
|
* großer leerer Kasten. Die Fläche zum Treffen bekommt stattdessen sein Umfeld (#035).
|
||||||
|
*/
|
||||||
|
.pl-haken {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pl-haken .form-check-input {
|
||||||
|
width: 1.4rem;
|
||||||
|
height: 1.4rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Breite Tabellen auf dem Telefon: Jede Zeile wird zur Karte, jede Zelle bekommt ihre
|
||||||
|
* Beschriftung aus `data-label` davor. Das ersetzt das Scrollen nach rechts, bei dem die
|
||||||
|
* Hälfte der Angaben unsichtbar bleibt (#035).
|
||||||
|
*
|
||||||
|
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||||
|
*/
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
|||||||
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
import { SearchDialog, type SuchEintrag } from "./SearchDialog";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
import { ROLLEN_TEXT } from "@/server/auth/rollen";
|
||||||
|
import { menuUmschalten } from "./menuZustand";
|
||||||
|
|
||||||
export interface KopfleistenAlert {
|
export interface KopfleistenAlert {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -58,16 +59,9 @@ export function Header({
|
|||||||
return () => window.removeEventListener("keydown", tasten);
|
return () => window.removeEventListener("keydown", tasten);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/** Sidebar umschalten: auf schmalen Bildschirmen als Overlay, sonst eingeklappt. */
|
/** Seitenmenü umschalten – der Zustand liegt in menuZustand.ts, damit das Menü selbst ihn kennt. */
|
||||||
const sidebarUmschalten = useCallback(() => {
|
const sidebarUmschalten = useCallback(() => {
|
||||||
const body = document.body;
|
menuUmschalten();
|
||||||
if (window.innerWidth < 992) {
|
|
||||||
body.classList.toggle("sidebar-open");
|
|
||||||
body.classList.remove("sidebar-collapse");
|
|
||||||
} else {
|
|
||||||
body.classList.toggle("sidebar-collapse");
|
|
||||||
body.classList.remove("sidebar-open");
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function themaUmschalten() {
|
function themaUmschalten() {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LogoMitSchriftzug } from "@/components/Logo";
|
import { LogoMitSchriftzug } from "@/components/Logo";
|
||||||
import type { Rolle } from "@/generated/prisma/enums";
|
import type { Rolle } from "@/generated/prisma/enums";
|
||||||
import { darf } from "@/server/auth/rollen";
|
import { darf } from "@/server/auth/rollen";
|
||||||
|
import { menuSchliessen, useMenuOffen } from "./menuZustand";
|
||||||
|
|
||||||
interface MenuEintrag {
|
interface MenuEintrag {
|
||||||
href: string;
|
href: string;
|
||||||
@@ -30,6 +31,21 @@ export function Sidebar({
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [suche, setSuche] = useState("");
|
const [suche, setSuche] = useState("");
|
||||||
|
const menuOffen = useMenuOffen();
|
||||||
|
|
||||||
|
// Nach dem Navigieren hat das Menü seinen Zweck erfüllt – auf dem Handy verdeckt es sonst alles.
|
||||||
|
useEffect(() => {
|
||||||
|
menuSchliessen();
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Escape schließt, wie bei jedem anderen Overlay auch.
|
||||||
|
useEffect(() => {
|
||||||
|
function taste(ereignis: KeyboardEvent) {
|
||||||
|
if (ereignis.key === "Escape") menuSchliessen();
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", taste);
|
||||||
|
return () => window.removeEventListener("keydown", taste);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const haupt: MenuEintrag[] = [
|
const haupt: MenuEintrag[] = [
|
||||||
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
{ href: "/", titel: "Dashboard", icon: "bi-speedometer2" },
|
||||||
@@ -105,11 +121,30 @@ export function Sidebar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="app-sidebar shadow" data-bs-theme="dark">
|
<>
|
||||||
<div className="sidebar-brand">
|
{/* 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 d-flex align-items-center justify-content-between">
|
||||||
<Link href="/" className="brand-link">
|
<Link href="/" className="brand-link">
|
||||||
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
<LogoMitSchriftzug variante="sidebar" groesse={34} />
|
||||||
</Link>
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link text-white d-lg-none pl-touch-target me-2"
|
||||||
|
aria-label="Menü schließen"
|
||||||
|
onClick={menuSchliessen}
|
||||||
|
>
|
||||||
|
<i className="bi bi-x-lg" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pl-sidebar-user">
|
<div className="pl-sidebar-user">
|
||||||
@@ -147,6 +182,7 @@ export function Sidebar({
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useSyncExternalStore } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zustand des Seitenmenüs auf schmalen Bildschirmen (#034).
|
||||||
|
*
|
||||||
|
* AdminLTE bringt dafür eigenes JavaScript mit, das wir bewusst nicht einbinden – die Klasse
|
||||||
|
* `sidebar-open` am `body` allein lässt sich aber nicht wieder loswerden: Kein Hintergrund zum
|
||||||
|
* Antippen, kein Schließen beim Navigieren. Deshalb ein eigener, winziger Zustand, auf den
|
||||||
|
* Kopfzeile und Menü gemeinsam hören.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SCHMAL = 992;
|
||||||
|
|
||||||
|
let offen = false;
|
||||||
|
const zuhoerer = new Set<() => void>();
|
||||||
|
|
||||||
|
function melden() {
|
||||||
|
for (const ruf of zuhoerer) ruf();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Setzt die Klassen, die das AdminLTE-Stylesheet auswertet. */
|
||||||
|
function klassenSetzen() {
|
||||||
|
const body = document.body;
|
||||||
|
body.classList.toggle("sidebar-open", offen);
|
||||||
|
// Auf schmalen Bildschirmen liegt das Menü über dem Inhalt; dahinter soll nichts mitscrollen.
|
||||||
|
body.style.overflow = offen && window.innerWidth < SCHMAL ? "hidden" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuUmschalten() {
|
||||||
|
if (window.innerWidth >= SCHMAL) {
|
||||||
|
// Breiter Bildschirm: Das Menü bleibt stehen und wird nur schmaler.
|
||||||
|
document.body.classList.toggle("sidebar-collapse");
|
||||||
|
document.body.classList.remove("sidebar-open");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
offen = !offen;
|
||||||
|
document.body.classList.remove("sidebar-collapse");
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function menuSchliessen() {
|
||||||
|
if (!offen) return;
|
||||||
|
offen = false;
|
||||||
|
klassenSetzen();
|
||||||
|
melden();
|
||||||
|
}
|
||||||
|
|
||||||
|
function abonnieren(ruf: () => void) {
|
||||||
|
zuhoerer.add(ruf);
|
||||||
|
return () => {
|
||||||
|
zuhoerer.delete(ruf);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMenuOffen(): boolean {
|
||||||
|
// Auf dem Server ist das Menü nie offen – sonst käme es beim ersten Rendern zu einem Unterschied.
|
||||||
|
return useSyncExternalStore(
|
||||||
|
abonnieren,
|
||||||
|
() => offen,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ export function StatusHaken({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="d-inline-flex align-items-center justify-content-center">
|
<span className="pl-haken d-inline-flex align-items-center justify-content-center">
|
||||||
{laeuft ? (
|
{laeuft ? (
|
||||||
<span
|
<span
|
||||||
className="spinner-border spinner-border-sm text-body-secondary"
|
className="spinner-border spinner-border-sm text-body-secondary"
|
||||||
@@ -47,7 +47,7 @@ export function StatusHaken({
|
|||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="form-check-input pl-touch-target"
|
className="form-check-input"
|
||||||
checked={wert}
|
checked={wert}
|
||||||
disabled={deaktiviert}
|
disabled={deaktiviert}
|
||||||
onChange={(e) => umschalten(e.target.checked)}
|
onChange={(e) => umschalten(e.target.checked)}
|
||||||
|
|||||||
@@ -57,8 +57,24 @@ export async function hostKundeSetzen(formular: FormData) {
|
|||||||
const standortId = formular.get("standortId")?.toString() || null;
|
const standortId = formular.get("standortId")?.toString() || null;
|
||||||
if (!hostId) return;
|
if (!hostId) return;
|
||||||
|
|
||||||
await prisma.host.update({ where: { id: hostId }, data: { kundeId, standortId } });
|
// Ein Standort gehört immer zu einem Kunden. Passt beides nicht zusammen – etwa weil der Kunde
|
||||||
await protokolliere("host.zugewiesen", "Host", hostId, { kundeId, standortId });
|
// gewechselt hat –, wird der Standort verworfen statt eine falsche Zuordnung zu speichern.
|
||||||
|
const gueltigerStandort =
|
||||||
|
standortId && kundeId
|
||||||
|
? ((await prisma.standort.findFirst({
|
||||||
|
where: { id: standortId, kundeId },
|
||||||
|
select: { id: true },
|
||||||
|
})) ?? null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
await prisma.host.update({
|
||||||
|
where: { id: hostId },
|
||||||
|
data: { kundeId, standortId: gueltigerStandort?.id ?? null },
|
||||||
|
});
|
||||||
|
await protokolliere("host.zugewiesen", "Host", hostId, {
|
||||||
|
kundeId,
|
||||||
|
standortId: gueltigerStandort?.id ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
if (kundeId) {
|
if (kundeId) {
|
||||||
await prisma.alert.updateMany({
|
await prisma.alert.updateMany({
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Zuweisung von Kunde und Standort an einen Host (#036). */
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sitzungErforderlich: async () => ({ benutzerId: "test", rolle: "ADMIN", kundeId: null }),
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
}));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: () => {} }));
|
||||||
|
|
||||||
|
const { hostKundeSetzen } = await import("@/server/actions/host-detail");
|
||||||
|
|
||||||
|
async function aufbau() {
|
||||||
|
const [kundeA, kundeB] = await Promise.all([
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
|
||||||
|
]);
|
||||||
|
const [standortA, standortB] = await Promise.all([
|
||||||
|
testPrisma.standort.create({ data: { kundeId: kundeA.id, name: "Rechenzentrum A" } }),
|
||||||
|
testPrisma.standort.create({ data: { kundeId: kundeB.id, name: "Rechenzentrum B" } }),
|
||||||
|
]);
|
||||||
|
const host = await testPrisma.host.create({
|
||||||
|
data: { agentId: "a-1", hostname: "pve-01", produkt: "PVE" },
|
||||||
|
});
|
||||||
|
return { kundeA, kundeB, standortA, standortB, host };
|
||||||
|
}
|
||||||
|
|
||||||
|
function formular(werte: Record<string, string>) {
|
||||||
|
const daten = new FormData();
|
||||||
|
for (const [name, wert] of Object.entries(werte)) daten.set(name, wert);
|
||||||
|
return daten;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Kunde und Standort setzen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setzt Kunde und Standort zusammen", async () => {
|
||||||
|
const { kundeA, standortA, host } = await aufbau();
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBe(kundeA.id);
|
||||||
|
expect(danach.standortId).toBe(standortA.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("verwirft einen Standort, der zu einem anderen Kunden gehört", async () => {
|
||||||
|
const { kundeA, standortB, host } = await aufbau();
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortB.id }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBe(kundeA.id);
|
||||||
|
expect(danach.standortId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("löst die Zuordnung samt Standort, wenn kein Kunde gewählt ist", async () => {
|
||||||
|
const { kundeA, standortA, host } = await aufbau();
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id, standortId: standortA.id }));
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: "", standortId: "" }));
|
||||||
|
|
||||||
|
const danach = await testPrisma.host.findUniqueOrThrow({ where: { id: host.id } });
|
||||||
|
expect(danach.kundeId).toBeNull();
|
||||||
|
expect(danach.standortId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schließt die Warnung „Host ohne Kunde“ beim Zuweisen", async () => {
|
||||||
|
const { kundeA, host } = await aufbau();
|
||||||
|
await testPrisma.alert.create({
|
||||||
|
data: {
|
||||||
|
regel: "host-ohne-kunde",
|
||||||
|
fingerprint: `host-ohne-kunde:${host.id}`,
|
||||||
|
schwere: "INFO",
|
||||||
|
titel: "Host ohne Kunde",
|
||||||
|
beschreibung: "…",
|
||||||
|
hostId: host.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await hostKundeSetzen(formular({ hostId: host.id, kundeId: kundeA.id }));
|
||||||
|
|
||||||
|
const alert = await testPrisma.alert.findFirstOrThrow({ where: { hostId: host.id } });
|
||||||
|
expect(alert.erledigtAm).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||