diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c375fd..96738a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DEVLOG.md b/DEVLOG.md index abd0a3c..0ab4baa 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -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) diff --git a/docs/anleitung/README.md b/docs/anleitung/README.md index f2885dd..17e9378 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -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 diff --git a/docs/anleitung/bilder/benutzer.png b/docs/anleitung/bilder/benutzer.png index d92e958..312a7a4 100644 Binary files a/docs/anleitung/bilder/benutzer.png and b/docs/anleitung/bilder/benutzer.png differ diff --git a/docs/anleitung/bilder/host-detail.png b/docs/anleitung/bilder/host-detail.png index b3cf4f9..c8dc817 100644 Binary files a/docs/anleitung/bilder/host-detail.png and b/docs/anleitung/bilder/host-detail.png differ diff --git a/docs/anleitung/bilder/jahresbahn.png b/docs/anleitung/bilder/jahresbahn.png index 9ce7f6d..dc8e66b 100644 Binary files a/docs/anleitung/bilder/jahresbahn.png and b/docs/anleitung/bilder/jahresbahn.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index 5acaa5d..2fc37f7 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/mobil-hosts.png b/docs/anleitung/bilder/mobil-hosts.png index 7823faf..14c9f1d 100644 Binary files a/docs/anleitung/bilder/mobil-hosts.png and b/docs/anleitung/bilder/mobil-hosts.png differ diff --git a/docs/anleitung/bilder/mobil-jahresbahn.png b/docs/anleitung/bilder/mobil-jahresbahn.png new file mode 100644 index 0000000..cca0796 Binary files /dev/null and b/docs/anleitung/bilder/mobil-jahresbahn.png differ diff --git a/docs/anleitung/bilder/mobil-kunden.png b/docs/anleitung/bilder/mobil-kunden.png new file mode 100644 index 0000000..80b78da Binary files /dev/null and b/docs/anleitung/bilder/mobil-kunden.png differ diff --git a/docs/anleitung/bilder/mobil-rechnungen.png b/docs/anleitung/bilder/mobil-rechnungen.png new file mode 100644 index 0000000..1b4286f Binary files /dev/null and b/docs/anleitung/bilder/mobil-rechnungen.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index 7350f3c..866504e 100644 Binary files a/docs/anleitung/bilder/nicht-zugeordnet.png and b/docs/anleitung/bilder/nicht-zugeordnet.png differ diff --git a/docs/anleitung/bilder/passkeys.png b/docs/anleitung/bilder/passkeys.png index 4be1541..d425d0e 100644 Binary files a/docs/anleitung/bilder/passkeys.png and b/docs/anleitung/bilder/passkeys.png differ diff --git a/docs/anleitung/bilder/rechnungen-dunkel.png b/docs/anleitung/bilder/rechnungen-dunkel.png index cea5491..7f979ae 100644 Binary files a/docs/anleitung/bilder/rechnungen-dunkel.png and b/docs/anleitung/bilder/rechnungen-dunkel.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index cea5491..7f979ae 100644 Binary files a/docs/anleitung/bilder/rechnungen.png and b/docs/anleitung/bilder/rechnungen.png differ diff --git a/docs/anleitung/bilder/tokens.png b/docs/anleitung/bilder/tokens.png index 974555d..41da4a4 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/issues/035-mobile-tabellen.md b/docs/issues/035-mobile-tabellen.md new file mode 100644 index 0000000..c8d5157 --- /dev/null +++ b/docs/issues/035-mobile-tabellen.md @@ -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. diff --git a/docs/issues/036-standort-am-host.md b/docs/issues/036-standort-am-host.md new file mode 100644 index 0000000..e16372e --- /dev/null +++ b/docs/issues/036-standort-am-host.md @@ -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“ diff --git a/docs/issues/README.md b/docs/issues/README.md index 0e7a3d6..472161e 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -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** | diff --git a/package.json b/package.json index beca736..0a8ca7e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.20.2", + "version": "0.21.0", "private": true, "scripts": { "dev": "next dev", diff --git a/scripts/screenshots.mjs b/scripts/screenshots.mjs index 4f7c15f..6f9ccd8 100644 --- a/scripts/screenshots.mjs +++ b/scripts/screenshots.mjs @@ -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`); diff --git a/src/app/(dashboard)/hosts/[id]/page.tsx b/src/app/(dashboard)/hosts/[id]/page.tsx index bd12ff5..c082e3b 100644 --- a/src/app/(dashboard)/hosts/[id]/page.tsx +++ b/src/app/(dashboard)/hosts/[id]/page.tsx @@ -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 {formatDatum(host.erstmalsGesehenAm)} {formatDatumZeit(host.letzterReportAm)} -
- - -
- - -
-
+ diff --git a/src/app/(dashboard)/jahresbahn/page.tsx b/src/app/(dashboard)/jahresbahn/page.tsx index c7a0c53..bfd4793 100644 --- a/src/app/(dashboard)/jahresbahn/page.tsx +++ b/src/app/(dashboard)/jahresbahn/page.tsx @@ -41,8 +41,8 @@ export default async function JahresbahnSeite() {

Noch kein Host mit Ablaufdatum gemeldet.

) : (
-
-
+
+
{monate.map((monat) => (
{monat} @@ -72,7 +72,7 @@ export default async function JahresbahnSeite() { : "var(--pl-info)"; return (
-
+
{eintrag.hostname} @@ -112,7 +112,8 @@ export default async function JahresbahnSeite() { )}

- Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb + Seitwärts wischen zeigt die späteren Monate. 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.

@@ -125,11 +126,11 @@ export default async function JahresbahnSeite() { tabelle >
- +
- + @@ -139,15 +140,15 @@ export default async function JahresbahnSeite() { {naechste90Tage.map((eintrag) => ( - - - + - - - diff --git a/src/app/(dashboard)/kunden/page.tsx b/src/app/(dashboard)/kunden/page.tsx index 8127873..086bd37 100644 --- a/src/app/(dashboard)/kunden/page.tsx +++ b/src/app/(dashboard)/kunden/page.tsx @@ -39,7 +39,7 @@ export default async function KundenSeite() { } >
-
Ablauf HostAblauf Kunde Key Proxmox
- - - + + {eintrag.hostname} + + + {eintrag.kunde ? ( {eintrag.kunde.name} @@ -156,13 +157,13 @@ export default async function JahresbahnSeite() { Nicht zugeordnet )} + + +
+
@@ -56,26 +56,28 @@ export default async function KundenSeite() { const punkte = rechnungspunkte.get(kunde.id); return ( - - - - - + + + - -
Kunde
+ {kunde.name}
{kunde.ansprechpartner ?? "–"}
{kunde.kundennummer}{kunde.hosts}{kunde.aktiv} + + {kunde.kundennummer} + {kunde.hosts}{kunde.aktiv} + {kunde.offeneAlerts > 0 ? ( {kunde.offeneAlerts} offen ) : ( keine )} + {punkte && punkte.offen > 0 ? ( {punkte.offen} offen ) : ( diff --git a/src/app/(dashboard)/rechnungen/page.tsx b/src/app/(dashboard)/rechnungen/page.tsx index 14a3526..95e096c 100644 --- a/src/app/(dashboard)/rechnungen/page.tsx +++ b/src/app/(dashboard)/rechnungen/page.tsx @@ -197,7 +197,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
- +
@@ -234,7 +234,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: const faelligIn = tageBis(zeile.einkaufFaelligAm, heute); return ( - + - - - - + - - - + - - diff --git a/src/app/globals.css b/src/app/globals.css index e587ac5..ee7b97f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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%; + } } diff --git a/src/components/hosts/KundeStandortFormular.tsx b/src/components/hosts/KundeStandortFormular.tsx new file mode 100644 index 0000000..f0b46bf --- /dev/null +++ b/src/components/hosts/KundeStandortFormular.tsx @@ -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 ( + + + + + + + +
+ + +
+ + {gewaehlterKunde && standorte.length === 0 ? ( +
+ Für diesen Kunden gibt es noch keinen Standort. Anlegen lässt er sich auf der Kundenseite. +
+ ) : null} + + ); +} diff --git a/src/components/rechnungen/StatusHaken.tsx b/src/components/rechnungen/StatusHaken.tsx index d0eb717..17793e3 100644 --- a/src/components/rechnungen/StatusHaken.tsx +++ b/src/components/rechnungen/StatusHaken.tsx @@ -37,7 +37,7 @@ export function StatusHaken({ } return ( - + {laeuft ? ( umschalten(e.target.checked)} diff --git a/src/server/actions/host-detail.ts b/src/server/actions/host-detail.ts index f1a1b87..c1b461f 100644 --- a/src/server/actions/host-detail.ts +++ b/src/server/actions/host-detail.ts @@ -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({ diff --git a/tests/host-standort.test.ts b/tests/host-standort.test.ts new file mode 100644 index 0000000..3227c30 --- /dev/null +++ b/tests/host-standort.test.ts @@ -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) { + 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(); + }); +});
Kunde
+ {/* Auf dem Telefon steht der Host als Überschrift der Karte, sonst als Spalte. */} + + + {zeile.hostname} + + {zeile.kunde ? ( {zeile.kunde.name} @@ -248,13 +254,13 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: {zeile.hostname} + {formatDatum(zeile.start)} – {formatDatum(zeile.ende)} {zeile.vorschlag ? Vorschlag : null} + {zeile.einkaufBezahlt === false && faelligIn !== null && faelligIn <= 7 ? (
@@ -262,8 +268,10 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
) : null}
{formatEuro(zeile.einkaufBetrag)} + + {formatEuro(zeile.einkaufBetrag)} + + {formatEuro(zeile.verkaufBetrag)} + + {formatEuro(zeile.verkaufBetrag)} + + = 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 ? (
+ Nichts gefunden. Alle anzeigen