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>
This commit is contained in:
2026-10-03 16:20:20 +02:00
co-authored by Claude Opus 5
parent 9c873a4069
commit ca2af0c2fc
30 changed files with 457 additions and 69 deletions
+12
View File
@@ -17,6 +17,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### 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
+8 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht
**Gesamt:** 17 h 38 min
**Gesamt:** 18 h 58 min
| Issue | Zeit |
|---|---|
@@ -40,6 +40,8 @@
| #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 |
| Meilenstein | Zeit |
|---|---|
@@ -62,7 +64,7 @@
## 2026-09-17 · Sitzung 1 · 19:36–laufend
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
**Version:** 0.20.2
**Version:** 0.21.0
### Erledigt
- Auftrag gelesen, Rückfragen gestellt
@@ -183,6 +185,10 @@
- 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
- #016 Benutzerhandbuch mit Screenshots, danach #017 Favicons und #019 CI-Pipeline
- Für 1.0.0 fehlt noch: PMG (kein Host verfügbar) und der Fall „aktive Subscription auf PBS“ (#023)
+3
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
Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 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: 121 KiB

After

Width:  |  Height:  |  Size: 120 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: 97 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: 169 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: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

+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“
+3 -1
View File
@@ -38,4 +38,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#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 |
| [#034](034-mobile-bedienung.md) | Bedienung auf dem Handy | erledigt | v0.20.2 | 2 h | 0 h 40 min |
| | **Summe** | | | **153 h** | **17 h 38 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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.20.2",
"version": "0.21.0",
"private": true,
"scripts": {
"dev": "next dev",
+2 -1
View File
@@ -195,7 +195,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`);
+12 -26
View File
@@ -16,7 +16,7 @@ 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 { hostTokenWiderrufen } from "@/server/actions/tokens";
import { hostMitDetails } from "@/server/queries/hostliste";
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({
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).
@@ -167,30 +171,12 @@ 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">
<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>
</div>
</form>
<KundeStandortFormular
hostId={host.id}
kunden={kunden}
kundeId={host.kundeId}
standortId={host.standortId}
/>
</Card>
</div>
+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 -11
View File
@@ -197,7 +197,7 @@ 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>
@@ -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="d-none d-lg-table-cell" 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>
+118
View File
@@ -382,10 +382,128 @@ 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;
}
/* ---------- 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) {
.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>
);
}
+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)}
+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({
+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();
});
});