Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f86a328b3c | ||
|
|
b2664de62f | ||
|
|
dfb2118bac | ||
|
|
335fc47496 |
@@ -17,6 +17,23 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
|
|
||||||
### Sicherheit
|
### Sicherheit
|
||||||
|
|
||||||
|
## [0.23.0] – 2026-10-03
|
||||||
|
|
||||||
|
### Hinzugefügt
|
||||||
|
- Schlagworte stehen jetzt in der Host-Liste und oben im Host-Detail; ein Klick darauf filtert die Liste (#040)
|
||||||
|
- Filter „Alle Schlagworte“ in der Host-Liste (#040)
|
||||||
|
- Eine vorhandene Notiz zeigt ein Zettel-Symbol in der Liste; der Text erscheint beim Überfahren und steht oben im Host-Detail (#040)
|
||||||
|
- Suche und globale Suche finden Hosts auch über Schlagwort und Notiztext (#040)
|
||||||
|
|
||||||
|
## [0.22.2] – 2026-10-03
|
||||||
|
|
||||||
|
### Behoben
|
||||||
|
- **Auf dem Telefon führte kein Weg zu den Rechnungsformularen**: Im Reiter *Rechnungen* der Host-Detailansicht war die Laufzeit nicht verlinkt, und in der Rechnungsliste ist diese Spalte erst ab 992 px sichtbar (#039)
|
||||||
|
- Der Reiter *Rechnungen* rechnete den Einkauf noch ohne Rabatt (#039)
|
||||||
|
|
||||||
|
### Geändert
|
||||||
|
- Der Reiter *Rechnungen* nutzt auf dem Telefon das Kartenlayout (#039)
|
||||||
|
|
||||||
## [0.22.1] – 2026-10-03
|
## [0.22.1] – 2026-10-03
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Zeitübersicht
|
## Zeitübersicht
|
||||||
|
|
||||||
**Gesamt:** 20 h 33 min
|
**Gesamt:** 21 h 43 min
|
||||||
|
|
||||||
| Issue | Zeit |
|
| Issue | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -44,6 +44,8 @@
|
|||||||
| #036 Standort am Host | 0 h 30 min |
|
| #036 Standort am Host | 0 h 30 min |
|
||||||
| #037 Rabatt je Position | 0 h 55 min |
|
| #037 Rabatt je Position | 0 h 55 min |
|
||||||
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||||
|
| #039 Zugang zur Laufzeit | 0 h 30 min |
|
||||||
|
| #040 Schlagworte sichtbar | 0 h 40 min |
|
||||||
|
|
||||||
| Meilenstein | Zeit |
|
| Meilenstein | Zeit |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -66,7 +68,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.22.1
|
**Version:** 0.23.0
|
||||||
|
|
||||||
### Erledigt
|
### Erledigt
|
||||||
- Auftrag gelesen, Rückfragen gestellt
|
- Auftrag gelesen, Rückfragen gestellt
|
||||||
@@ -199,6 +201,13 @@
|
|||||||
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
|
||||||
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
|
||||||
|
|
||||||
|
- #039: Auf dem Telefon führte kein Weg zu den Rechnungsformularen – die Laufzeit war nicht verlinkt und die Spalte erst ab 992 px sichtbar
|
||||||
|
- Dritte Stelle, die den Einkauf ohne Rabatt rechnete. Beim Ändern einer Rechengröße gehört jede Stelle gesucht, die sie selbst bildet – nicht nur die Abfragen
|
||||||
|
- Bootstraps `d-*`-Klassen setzen `display` mit `!important`; für Spalten in der Kartenansicht braucht es eigene Klassen
|
||||||
|
|
||||||
|
- Schlagworte und Notizen sichtbar gemacht (#040). Beides gab es längst – nur eben versteckt in einem Reiter, und damit nutzlos zum Unterscheiden gleichnamiger Hosts
|
||||||
|
- Kein neues Feld gebaut: Ein zweites „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten
|
||||||
|
|
||||||
### 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)
|
||||||
|
|||||||
@@ -192,6 +192,19 @@ Dashboard vergleicht sie mit den Hosts, die sich tatsächlich melden:
|
|||||||
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
Hier laufen auf `hv-rz5-pve-02` und `hv-rz5-pve-03` noch keine Agents – das Dashboard sagt es zweimal
|
||||||
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
deutlich. Sobald dort installiert wird, schließen sich die Warnungen von selbst.
|
||||||
|
|
||||||
|
### Gleichnamige Hosts auseinanderhalten
|
||||||
|
|
||||||
|
In verschiedenen Kundennetzen heißen Hosts oft gleich – `hv-pve-01` gibt es womöglich dreimal. Dafür
|
||||||
|
gibt es Schlagworte und Notizen, zu finden im Host-Detail unter *Notizen und Tags*:
|
||||||
|
|
||||||
|
- **Schlagworte** erscheinen als Abzeichen unter dem Hostnamen in der Liste. Ein Klick darauf zeigt
|
||||||
|
alle Hosts mit diesem Schlagwort; dasselbe geht über die Auswahl *Alle Schlagworte*.
|
||||||
|
- **Notizen** zeigen in der Liste ein Zettel-Symbol, dessen Text beim Überfahren erscheint. Im
|
||||||
|
Host-Detail steht die Notiz oben bei den Stammdaten.
|
||||||
|
- Beides wird mitdurchsucht – auch von der globalen Suche (Strg+K).
|
||||||
|
|
||||||
|
Bewährt haben sich kurze, wiederverwendbare Worte: `cluster`, `backup`, `produktiv`, `aussenstelle`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Kunden und Standorte pflegen
|
## 5. Kunden und Standorte pflegen
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 139 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 170 KiB After Width: | Height: | Size: 170 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 177 KiB |
@@ -0,0 +1,32 @@
|
|||||||
|
# #039 Auf dem Telefon führte kein Weg zur Laufzeit
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.22.2
|
||||||
|
**Labels:** bug, ui
|
||||||
|
**Geschätzt:** 1 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Kann ich am Handy nicht rein.“ Gemeint war der Reiter *Rechnungen* in der Host-Detailansicht: Die
|
||||||
|
Zeile zeigt Laufzeit, Rechnung und Beträge – aber nichts davon war anklickbar.
|
||||||
|
|
||||||
|
## Ursache
|
||||||
|
Zwei Stellen:
|
||||||
|
|
||||||
|
1. In der Host-Detailansicht war die Laufzeit nie verlinkt, auch am Rechner nicht. Man kam nur über
|
||||||
|
die Rechnungsliste dorthin.
|
||||||
|
2. In der Rechnungsliste ist die Laufzeit-Spalte erst ab 992 px eingeblendet. Auf dem Telefon fehlte
|
||||||
|
damit der einzige Verweis auf die Formulare – die Karte verwies nur auf den Host.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- Die Laufzeit in der Host-Detailansicht ist ein Link und zugleich die Überschrift der Karte
|
||||||
|
- In der Rechnungsliste bleibt die Laufzeit auf dem Telefon sichtbar; ausgeblendet wird sie nur
|
||||||
|
zwischen 768 und 992 px, wo die Tabelle am engsten ist
|
||||||
|
- Der Reiter *Rechnungen* nutzt ebenfalls das Kartenlayout
|
||||||
|
|
||||||
|
## Nebenbefund
|
||||||
|
Auch dieser Reiter rechnete den Einkauf noch ohne Rabatt – 1.100 € statt 990 €. Dritte Stelle nach
|
||||||
|
#037 und #038; alle gefunden, weil Florian die Zahl auf dem Bildschirm mit seiner Rechnung verglichen hat.
|
||||||
|
|
||||||
|
## Gelernt
|
||||||
|
Bootstraps `d-*`-Hilfsklassen setzen `display` mit `!important` und überschreiben damit das
|
||||||
|
Kartenlayout. Für Spalten, die in der Kartenansicht sichtbar bleiben sollen, braucht es eine eigene
|
||||||
|
Klasse (`pl-spalte-laufzeit`).
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# #040 Schlagworte und Notizen sichtbar machen
|
||||||
|
**Status:** erledigt
|
||||||
|
**Meilenstein:** v0.23.0
|
||||||
|
**Labels:** ui
|
||||||
|
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
|
||||||
|
|
||||||
|
## Auslöser
|
||||||
|
„Manche Hosts haben gleiche Namen.“ Zwei Hosts namens `hv-pve-01` bei verschiedenen Kunden sind in
|
||||||
|
der Liste nur am Kunden auseinanderzuhalten – und wenn beide zum selben Kunden gehören, gar nicht.
|
||||||
|
|
||||||
|
## Ausgangslage
|
||||||
|
Schlagworte und Notizen gab es schon: im Host-Detail unter *Notizen und Tags*. Sie waren nur nirgends
|
||||||
|
zu sehen, außer in diesem einen Reiter. Damit taugten sie nicht zum Unterscheiden.
|
||||||
|
|
||||||
|
## Behebung
|
||||||
|
- **Host-Liste:** Schlagworte als Abzeichen unter dem Namen, jedes anklickbar – ein Klick filtert die
|
||||||
|
Liste darauf. Eine vorhandene Notiz zeigt ein kleines Zettel-Symbol, dessen Text beim Überfahren erscheint.
|
||||||
|
- **Filterleiste:** Auswahl „Alle Schlagworte“, die nur tatsächlich vergebene Worte anbietet.
|
||||||
|
- **Host-Detail:** Schlagworte und Notiz stehen oben in der Host-Karte, nicht erst im Reiter.
|
||||||
|
- **Suche:** Die Hostsuche und die globale Suche (Strg+K) finden jetzt auch über Schlagwort und
|
||||||
|
Notiztext; die Trefferzeile nennt die Schlagworte.
|
||||||
|
- Sechs Tests, darunter zwei gleichnamige Hosts bei verschiedenen Kunden.
|
||||||
|
|
||||||
|
## Entscheidung
|
||||||
|
Keine neue Datenstruktur. Schlagworte und Notizen waren bereits vorhanden und wurden nur nicht
|
||||||
|
gezeigt – ein zweites Feld „Kommentar“ hätte dieselbe Angabe an zwei Orten gehalten.
|
||||||
@@ -42,4 +42,6 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
|
| [#036](036-standort-am-host.md) | Standort am Host setzen | erledigt | v0.21.0 | 1 h | 0 h 30 min |
|
||||||
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
| [#037](037-rabatt-und-ablauf.md) | Rabatt je Position und der tatsächliche Ablauf | erledigt | v0.22.0 | 3 h | 0 h 55 min |
|
||||||
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
| [#038](038-ueberbreite-handy.md) | Seite wird auf dem Handy herausgezoomt | erledigt | v0.22.1 | 2 h | 0 h 40 min |
|
||||||
| | **Summe** | | | **162 h** | **20 h 33 min** |
|
| [#039](039-zugang-zur-laufzeit.md) | Auf dem Telefon führte kein Weg zur Laufzeit | erledigt | v0.22.2 | 1 h | 0 h 30 min |
|
||||||
|
| [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min |
|
||||||
|
| | **Summe** | | | **165 h** | **21 h 43 min** |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "proxledger",
|
"name": "proxledger",
|
||||||
"version": "0.22.1",
|
"version": "0.23.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
|
|||||||
@@ -108,6 +108,27 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
<h2 className="h5 mt-2 mb-1">{host.hostname}</h2>
|
||||||
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
<div className="text-body-secondary small">{host.fqdn ?? "Kein FQDN gemeldet"}</div>
|
||||||
{meldetNicht ? <KeineMeldungBadge /> : null}
|
{meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
|
||||||
|
{/* Schlagworte und Notiz stehen hier, nicht nur im Reiter – bei gleichnamigen
|
||||||
|
Hosts ist das die Stelle, an der man sie braucht (#040). */}
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-2 d-flex flex-wrap gap-1 justify-content-center">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
<p className="small text-body-secondary mt-2 mb-0 text-start border-start ps-2">
|
||||||
|
{host.notizen}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Zeile bezeichnung="Kunde">
|
<Zeile bezeichnung="Kunde">
|
||||||
@@ -346,7 +367,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
icon: "bi-receipt",
|
icon: "bi-receipt",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle mb-0">
|
<table className="table table-striped align-middle mb-0 pl-karten-tabelle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col">Laufzeit</th>
|
<th scope="col">Laufzeit</th>
|
||||||
@@ -359,21 +380,28 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{host.laufzeiten.map((laufzeit) => {
|
{host.laufzeiten.map((laufzeit) => {
|
||||||
|
// Nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
|
||||||
const einkauf = laufzeit.proxmoxPosition
|
const einkauf = laufzeit.proxmoxPosition
|
||||||
? Number(laufzeit.proxmoxPosition.nettoBetrag)
|
? Number(laufzeit.proxmoxPosition.nettoBetrag) -
|
||||||
|
Number(laufzeit.proxmoxPosition.rabatt ?? 0)
|
||||||
: null;
|
: null;
|
||||||
const verkauf = laufzeit.kundenPosition
|
const verkauf = laufzeit.kundenPosition
|
||||||
? Number(laufzeit.kundenPosition.nettoBetrag)
|
? Number(laufzeit.kundenPosition.nettoBetrag)
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<tr key={laufzeit.id}>
|
<tr key={laufzeit.id}>
|
||||||
<td>
|
<td className="pl-karten-kopf">
|
||||||
|
<Link
|
||||||
|
href={`/rechnungen/${laufzeit.id}`}
|
||||||
|
className="fw-semibold text-decoration-none"
|
||||||
|
>
|
||||||
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
{formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)}
|
||||||
|
</Link>
|
||||||
{laufzeit.status === "VORSCHLAG" ? (
|
{laufzeit.status === "VORSCHLAG" ? (
|
||||||
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
<span className="badge pl-bg-info ms-2">Vorschlag</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td data-label="Proxmox-Rechnung">
|
||||||
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.proxmoxPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<EinkaufBadge
|
<EinkaufBadge
|
||||||
@@ -385,8 +413,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(einkauf)}</td>
|
<td data-label="Einkauf">{formatEuro(einkauf)}</td>
|
||||||
<td>
|
<td data-label="Kundenrechnung">
|
||||||
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
<CopyableCode wert={laufzeit.kundenPosition?.rechnung.nummer ?? null} />
|
||||||
<div>
|
<div>
|
||||||
<VerkaufBadge
|
<VerkaufBadge
|
||||||
@@ -395,8 +423,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{formatEuro(verkauf)}</td>
|
<td data-label="Verkauf">{formatEuro(verkauf)}</td>
|
||||||
<td>
|
<td data-label="Marge">
|
||||||
{einkauf !== null && verkauf !== null
|
{einkauf !== null && verkauf !== null
|
||||||
? formatEuro(verkauf - einkauf)
|
? formatEuro(verkauf - einkauf)
|
||||||
: "–"}
|
: "–"}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const filter: HostFilter = {
|
const filter: HostFilter = {
|
||||||
q: einzeln(roh.q),
|
q: einzeln(roh.q),
|
||||||
kunde: einzeln(roh.kunde),
|
kunde: einzeln(roh.kunde),
|
||||||
|
tag: einzeln(roh.tag),
|
||||||
status: einzeln(roh.status),
|
status: einzeln(roh.status),
|
||||||
produkt: einzeln(roh.produkt),
|
produkt: einzeln(roh.produkt),
|
||||||
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
ablauf: einzeln(roh.ablauf) as HostFilter["ablauf"],
|
||||||
@@ -85,6 +86,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
const basis: Record<string, string | undefined> = {
|
const basis: Record<string, string | undefined> = {
|
||||||
q: filter.q,
|
q: filter.q,
|
||||||
kunde: filter.kunde,
|
kunde: filter.kunde,
|
||||||
|
tag: filter.tag,
|
||||||
status: filter.status,
|
status: filter.status,
|
||||||
produkt: filter.produkt,
|
produkt: filter.produkt,
|
||||||
ablauf: filter.ablauf,
|
ablauf: filter.ablauf,
|
||||||
@@ -117,7 +119,7 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<HostFilterLeiste kunden={optionen.kunden} werte={basis} />
|
<HostFilterLeiste kunden={optionen.kunden} tags={optionen.tags} werte={basis} />
|
||||||
|
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped align-middle">
|
<table className="table table-striped align-middle">
|
||||||
@@ -152,7 +154,28 @@ export default async function HostsSeite({ searchParams }: { searchParams: SuchP
|
|||||||
{host.hostname}
|
{host.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
{host.meldetNicht ? <KeineMeldungBadge /> : null}
|
||||||
|
{host.notizen ? (
|
||||||
|
// Gerade bei gleichnamigen Hosts hilft die Notiz beim Auseinanderhalten (#040).
|
||||||
|
<i
|
||||||
|
className="bi bi-sticky ms-2 text-body-secondary"
|
||||||
|
title={host.notizen}
|
||||||
|
aria-label={`Notiz: ${host.notizen}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
<div className="small text-body-secondary">{host.ips.join(", ") || "–"}</div>
|
||||||
|
{host.tags.length > 0 ? (
|
||||||
|
<div className="mt-1 d-flex flex-wrap gap-1">
|
||||||
|
{host.tags.map((tag) => (
|
||||||
|
<Link
|
||||||
|
key={tag.id}
|
||||||
|
href={`/hosts?tag=${encodeURIComponent(tag.name)}`}
|
||||||
|
className="badge pl-bg-neutral text-decoration-none"
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{host.kunde ? (
|
{host.kunde ? (
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
<th scope="col" className="d-none d-md-table-cell">
|
<th scope="col" className="d-none d-md-table-cell">
|
||||||
Host
|
Host
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" className="d-none d-lg-table-cell">
|
<th scope="col" className="pl-spalte-laufzeit">
|
||||||
Laufzeit
|
Laufzeit
|
||||||
</th>
|
</th>
|
||||||
<th scope="col">Proxmox-Rechnung</th>
|
<th scope="col">Proxmox-Rechnung</th>
|
||||||
@@ -254,7 +254,7 @@ export default async function RechnungenSeite({ searchParams }: { searchParams:
|
|||||||
{zeile.hostname}
|
{zeile.hostname}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className="d-none d-lg-table-cell" data-label="Laufzeit">
|
<td className="pl-spalte-laufzeit" data-label="Laufzeit">
|
||||||
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
<Link href={`/rechnungen/${zeile.id}`} className="text-decoration-none">
|
||||||
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
{formatDatum(zeile.start)} – {formatDatum(zeile.ende)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -401,6 +401,18 @@ body {
|
|||||||
max-width: calc(100vw - 1rem);
|
max-width: calc(100vw - 1rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Laufzeit-Spalte ist auf dem Telefon der einzige Weg zu den Rechnungsformularen, deshalb
|
||||||
|
* bleibt sie dort sichtbar. Zwischen 768 und 992 px ist die Tabelle am engsten – dort entfällt sie.
|
||||||
|
* Bewusst eine eigene Klasse statt der Bootstrap-Hilfsklassen: deren `display: … !important`
|
||||||
|
* überschreibt das Kartenlayout (#039).
|
||||||
|
*/
|
||||||
|
@media (min-width: 768px) and (max-width: 991.98px) {
|
||||||
|
.pl-spalte-laufzeit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobil ---------- */
|
/* ---------- Mobil ---------- */
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|||||||
*/
|
*/
|
||||||
export function HostFilterLeiste({
|
export function HostFilterLeiste({
|
||||||
kunden,
|
kunden,
|
||||||
|
tags,
|
||||||
werte,
|
werte,
|
||||||
}: {
|
}: {
|
||||||
kunden: { id: string; name: string }[];
|
kunden: { id: string; name: string }[];
|
||||||
|
tags: string[];
|
||||||
werte: Record<string, string | undefined>;
|
werte: Record<string, string | undefined>;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -26,7 +28,7 @@ export function HostFilterLeiste({
|
|||||||
router.push(`${pathname}?${neu.toString()}`);
|
router.push(`${pathname}?${neu.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const aktiveFilter = ["kunde", "status", "produkt", "ablauf", "rechnung", "q"].filter(
|
const aktiveFilter = ["kunde", "tag", "status", "produkt", "ablauf", "rechnung", "q"].filter(
|
||||||
(schluessel) => werte[schluessel],
|
(schluessel) => werte[schluessel],
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -76,6 +78,27 @@ export function HostFilterLeiste({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{tags.length > 0 ? (
|
||||||
|
<div className="col-6 col-md-2">
|
||||||
|
<label className="visually-hidden" htmlFor="tag">
|
||||||
|
Schlagwort
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="form-select"
|
||||||
|
id="tag"
|
||||||
|
value={werte.tag ?? ""}
|
||||||
|
onChange={(e) => setzen("tag", e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">Alle Schlagworte</option>
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<option key={tag} value={tag}>
|
||||||
|
{tag}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="col-6 col-md-2">
|
<div className="col-6 col-md-2">
|
||||||
<select
|
<select
|
||||||
className="form-select"
|
className="form-select"
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { sichtbarerKunde } from "@/server/auth/sitzungspflicht";
|
|||||||
export interface HostFilter {
|
export interface HostFilter {
|
||||||
q?: string;
|
q?: string;
|
||||||
kunde?: string;
|
kunde?: string;
|
||||||
|
/** Schlagwort, nach dem gefiltert wird (#040). */
|
||||||
|
tag?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
produkt?: string;
|
produkt?: string;
|
||||||
ablauf?: "30" | "14" | "abgelaufen";
|
ablauf?: "30" | "14" | "abgelaufen";
|
||||||
@@ -20,6 +22,10 @@ export const SEITENGROESSE = 25;
|
|||||||
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
||||||
const und: Prisma.HostWhereInput[] = [];
|
const und: Prisma.HostWhereInput[] = [];
|
||||||
|
|
||||||
|
if (filter.tag) {
|
||||||
|
und.push({ tags: { some: { name: filter.tag } } });
|
||||||
|
}
|
||||||
|
|
||||||
if (filter.q && filter.q.trim().length > 0) {
|
if (filter.q && filter.q.trim().length > 0) {
|
||||||
const suche = filter.q.trim();
|
const suche = filter.q.trim();
|
||||||
und.push({
|
und.push({
|
||||||
@@ -30,6 +36,8 @@ function bedingung(filter: HostFilter, jetzt: Date): Prisma.HostWhereInput {
|
|||||||
{ subKey: { contains: suche, mode: "insensitive" } },
|
{ subKey: { contains: suche, mode: "insensitive" } },
|
||||||
{ ips: { has: suche } },
|
{ ips: { has: suche } },
|
||||||
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
|
{ kunde: { name: { contains: suche, mode: "insensitive" } } },
|
||||||
|
{ notizen: { contains: suche, mode: "insensitive" } },
|
||||||
|
{ tags: { some: { name: { contains: suche, mode: "insensitive" } } } },
|
||||||
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
|
// Suche nach einer Proxmox-Rechnungsnummer führt ebenfalls zum Host.
|
||||||
{
|
{
|
||||||
laufzeiten: {
|
laufzeiten: {
|
||||||
@@ -113,6 +121,7 @@ export async function hostListe(filter: HostFilter, jetzt: Date = new Date()) {
|
|||||||
include: {
|
include: {
|
||||||
kunde: { select: { id: true, name: true } },
|
kunde: { select: { id: true, name: true } },
|
||||||
standort: { select: { name: true } },
|
standort: { select: { name: true } },
|
||||||
|
tags: { orderBy: { name: "asc" }, select: { id: true, name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -181,5 +190,12 @@ export async function filterOptionen() {
|
|||||||
orderBy: { name: "asc" },
|
orderBy: { name: "asc" },
|
||||||
select: { id: true, name: true },
|
select: { id: true, name: true },
|
||||||
});
|
});
|
||||||
return { kunden };
|
|
||||||
|
// Nur Schlagworte, die auch an einem sichtbaren Host hängen – sonst stünden leere zur Auswahl.
|
||||||
|
const tags = await prisma.tag.findMany({
|
||||||
|
where: { hosts: { some: kundeId ? { kundeId } : {} } },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return { kunden, tags: tags.map((tag) => tag.name) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ export async function suchIndex() {
|
|||||||
subKey: true,
|
subKey: true,
|
||||||
ips: true,
|
ips: true,
|
||||||
produkt: true,
|
produkt: true,
|
||||||
|
notizen: true,
|
||||||
kunde: { select: { name: true } },
|
kunde: { select: { name: true } },
|
||||||
|
tags: { select: { name: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
prisma.kunde.findMany({
|
prisma.kunde.findMany({
|
||||||
@@ -128,9 +130,17 @@ export async function suchIndex() {
|
|||||||
titel: host.hostname,
|
titel: host.hostname,
|
||||||
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
untertitel: `${host.produkt} · ${host.kunde?.name ?? "Nicht zugeordnet"}${
|
||||||
host.subKey ? ` · ${host.subKey}` : ""
|
host.subKey ? ` · ${host.subKey}` : ""
|
||||||
}`,
|
}${host.tags.length > 0 ? ` · ${host.tags.map((tag) => tag.name).join(", ")}` : ""}`,
|
||||||
href: `/hosts/${host.id}`,
|
href: `/hosts/${host.id}`,
|
||||||
begriffe: [host.hostname, host.fqdn, host.serverId, host.subKey, ...host.ips]
|
begriffe: [
|
||||||
|
host.hostname,
|
||||||
|
host.fqdn,
|
||||||
|
host.serverId,
|
||||||
|
host.subKey,
|
||||||
|
host.notizen,
|
||||||
|
...host.tags.map((tag) => tag.name),
|
||||||
|
...host.ips,
|
||||||
|
]
|
||||||
.filter((w): w is string => Boolean(w))
|
.filter((w): w is string => Boolean(w))
|
||||||
.map((w) => w.toLowerCase()),
|
.map((w) => w.toLowerCase()),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||||
|
|
||||||
|
/** Schlagworte und Notizen in Liste, Filter und Suche (#040). */
|
||||||
|
vi.mock("@/server/auth/sitzungspflicht", () => ({
|
||||||
|
sichtbarerKunde: async () => null,
|
||||||
|
darfEinkaufSehen: async () => true,
|
||||||
|
angemeldet: async () => null,
|
||||||
|
sitzungErforderlich: async () => {
|
||||||
|
throw new Error("in diesem Test nicht vorgesehen");
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { hostListe, filterOptionen } = await import("@/server/queries/hostliste");
|
||||||
|
const { suchIndex } = await import("@/server/queries/hosts");
|
||||||
|
|
||||||
|
async function hosts() {
|
||||||
|
// Zwei Hosts mit demselben Namen bei verschiedenen Kunden – genau der Fall aus der Praxis.
|
||||||
|
const [kundeA, kundeB] = await Promise.all([
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde A", kundennummer: "K-A" } }),
|
||||||
|
testPrisma.kunde.create({ data: { name: "Kunde B", kundennummer: "K-B" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await testPrisma.host.create({
|
||||||
|
data: {
|
||||||
|
agentId: "a-1",
|
||||||
|
hostname: "hv-pve-01",
|
||||||
|
produkt: "PVE",
|
||||||
|
kundeId: kundeA.id,
|
||||||
|
notizen: "Steht im Serverraum links",
|
||||||
|
tags: { connectOrCreate: [{ where: { name: "rechenzentrum" }, create: { name: "rechenzentrum" } }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await testPrisma.host.create({
|
||||||
|
data: {
|
||||||
|
agentId: "a-2",
|
||||||
|
hostname: "hv-pve-01",
|
||||||
|
produkt: "PVE",
|
||||||
|
kundeId: kundeB.id,
|
||||||
|
tags: { connectOrCreate: [{ where: { name: "aussenstelle" }, create: { name: "aussenstelle" } }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe.runIf(datenbankVerfuegbar)("Schlagworte und Notizen", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
await datenbankLeeren();
|
||||||
|
await hosts();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await testPrisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("liefert die Schlagworte zu jedem Host mit", async () => {
|
||||||
|
const liste = await hostListe({});
|
||||||
|
const namen = liste.hosts.flatMap((host) => host.tags.map((tag) => tag.name)).sort();
|
||||||
|
expect(namen).toEqual(["aussenstelle", "rechenzentrum"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filtert nach Schlagwort", async () => {
|
||||||
|
const liste = await hostListe({ tag: "aussenstelle" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
expect(liste.hosts[0].kunde?.name).toBe("Kunde B");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findet einen Host über sein Schlagwort", async () => {
|
||||||
|
const liste = await hostListe({ q: "rechenzentrum" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
expect(liste.hosts[0].kunde?.name).toBe("Kunde A");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("findet einen Host über seine Notiz", async () => {
|
||||||
|
const liste = await hostListe({ q: "serverraum" });
|
||||||
|
expect(liste.gesamt).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bietet nur vergebene Schlagworte zur Auswahl an", async () => {
|
||||||
|
const { tags } = await filterOptionen();
|
||||||
|
expect(tags).toEqual(["aussenstelle", "rechenzentrum"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nimmt Schlagworte und Notizen in die globale Suche auf", async () => {
|
||||||
|
const eintraege = await suchIndex();
|
||||||
|
const treffer = eintraege.filter((eintrag) => eintrag.begriffe.includes("rechenzentrum"));
|
||||||
|
expect(treffer).toHaveLength(1);
|
||||||
|
expect(treffer[0].untertitel).toContain("rechenzentrum");
|
||||||
|
});
|
||||||
|
});
|
||||||