fix(ui): Ueberbreite auf dem Handy beseitigen (#038)

Beim Oeffnen der Warnungen und auf der Host-Detailansicht zoomte der Browser
die ganze Seite heraus. Ursache war jeweils ein Element breiter als das
Fenster: die 22 rem breite Klappliste, drei Tabellen ohne table-responsive
und eine 32-stellige Server-ID ohne Umbruch.

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-03 17:01:43 +02:00
co-authored by Claude Opus 5
parent c1177067e6
commit c5293f5915
19 changed files with 211 additions and 105 deletions
+9
View File
@@ -17,6 +17,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Sicherheit ### Sicherheit
## [0.22.1] – 2026-10-03
### Behoben
- **Die ganze Seite wurde auf dem Telefon herausgezoomt**, sobald die Warnungen geöffnet wurden oder eine Host-Detailansicht eine lange Server-ID enthielt. Ursache war jeweils ein Element breiter als das Fenster (#038)
- Die Laufzeit-Detailseite rechnete noch mit dem Listenpreis statt mit dem Betrag nach Rabatt – Marge und der Vorschlag für die Kundenrechnung waren um den Rabatt zu hoch (#037)
### Hinzugefügt
- `npm run screenshots -- --mobil name=/pfad` für gezielte Aufnahmen in Telefonbreite (#038)
## [0.22.0] – 2026-10-03 ## [0.22.0] – 2026-10-03
### Geändert ### Geändert
+7 -2
View File
@@ -2,7 +2,7 @@
## Zeitübersicht ## Zeitübersicht
**Gesamt:** 19 h 53 min **Gesamt:** 20 h 33 min
| Issue | Zeit | | Issue | Zeit |
|---|---| |---|---|
@@ -43,6 +43,7 @@
| #035 Tabellen als Karten | 0 h 50 min | | #035 Tabellen als Karten | 0 h 50 min |
| #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 |
| Meilenstein | Zeit | | Meilenstein | Zeit |
|---|---| |---|---|
@@ -65,7 +66,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.0 **Version:** 0.22.1
### Erledigt ### Erledigt
- Auftrag gelesen, Rückfragen gestellt - Auftrag gelesen, Rückfragen gestellt
@@ -194,6 +195,10 @@
- Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel - Florians Schilderung des Ablaufs (unbezahlte Rechnung → Kundenrechnung → Kunde zahlt → selbst zahlen → PAID-Fassung) ergab die zwölfte Warnregel
- Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen - Lehre: Ein Beleg aus der Praxis sagt mehr über das Datenmodell als jede Annahme darüber, wie Rechnungen aussehen
- Überbreite auf dem Handy (#038): Drei Verursacher, ein Muster – ein Element breiter als das Fenster zoomt die ganze Seite heraus
- Dabei einen eigenen Fehler aus #037 gefunden: Die Detailseite rechnete weiter mit dem Listenpreis. Beim Ändern einer Rechengröße reicht es nicht, die Abfragen anzupassen – auch die Seiten rechnen selbst
- Das Aufnahme-Skript kann jetzt gezielt in Telefonbreite aufnehmen; damit ließ sich die Behebung belegen statt nur behaupten
### 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)
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: 121 KiB

After

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

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 194 KiB

+31
View File
@@ -0,0 +1,31 @@
# #038 Seite wird auf dem Handy herausgezoomt
**Status:** erledigt
**Meilenstein:** v0.22.1
**Labels:** bug, ui
**Geschätzt:** 2 h **Tatsächlich:** 0 h 40 min
## Auslöser
Zwei Meldungen aus dem Einsatz: Beim Öffnen der Warnungen verkleinerte sich die ganze Seite, und die
Host-Detailansicht saß verschoben im Bild.
## Ursache
Beides derselbe Mechanismus: Ein Element ist breiter als das Fenster, also zoomt der Browser die
**ganze Seite** heraus, damit sie hineinpasst. Drei Verursacher:
1. Die Klappliste der Warnungen war mit 22 rem fest breiter als ein 390-px-Fenster.
2. Drei Tabellen in der Host-Detailansicht (Keys, Server-IDs, Tokens) standen ohne
`table-responsive` – anders als die übrigen.
3. Eine 32-stellige Server-ID bricht nicht von selbst um.
## Behebung
- Klapplisten richten sich auf dem Telefon am Fenster aus (`position: fixed`, links und rechts 0,5 rem,
höchstens 70 % Fensterhöhe mit eigenem Scroll) statt am Knopf
- Die drei Tabellen bekommen ihre Hülle
- Lange Schlüssel und IDs dürfen umbrechen (`overflow-wrap: anywhere`)
- `npm run screenshots -- --mobil name=/pfad` nimmt gezielt eine Seite in Telefonbreite auf; damit
wurde die Host-Detailansicht gegengeprüft
## Nachtrag zu #037
Beim Prüfen fiel auf, dass die Laufzeit-Detailseite noch mit dem **Listenpreis** rechnete statt mit
dem Betrag nach Rabatt – Marge und Vorschlag für die Kundenrechnung waren dort um den Rabatt zu hoch.
Die Seite zeigt jetzt „990,00 € · 1.100,00 € − 110,00 € Rabatt“ und rechnet mit 990.
+2 -1
View File
@@ -41,4 +41,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
| [#035](035-mobile-tabellen.md) | Tabellen auf dem Handy als Karten | erledigt | v0.21.0 | 3 h | 0 h 50 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 | | [#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 |
| | **Summe** | | | **160 h** | **19 h 53 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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "proxledger", "name": "proxledger",
"version": "0.22.0", "version": "0.22.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
+13
View File
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder"); const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
const ALLE = process.argv.includes("--alle"); const ALLE = process.argv.includes("--alle");
/** Zusätzliche Handy-Aufnahmen von der Befehlszeile: --mobil name=/pfad */
const MOBIL_ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--mobil")
.map((eintrag) => {
const [name, ...rest] = eintrag.split("=");
return { name, pfad: rest.join("="), titel: "Handy" };
});
/** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */ /** Zusätzliche Seiten von der Befehlszeile: --seite name=/pfad */
const ZUSATZ = process.argv const ZUSATZ = process.argv
.filter((wert, i) => process.argv[i - 1] === "--seite") .filter((wert, i) => process.argv[i - 1] === "--seite")
@@ -187,6 +195,11 @@ async function main() {
console.log(` ${seite.name}.png – ${seite.titel}`); console.log(` ${seite.name}.png – ${seite.titel}`);
} }
for (const seite of MOBIL_ZUSATZ) {
await aufnehmen(hell, seite.pfad, path.join(ZIEL, `${seite.name}.png`), 390, 1800, adresse);
console.log(` ${seite.name}.png – ${seite.titel}`);
}
if (ALLE) { if (ALLE) {
const dunkel = profilVorbereiten("dunkel", true); const dunkel = profilVorbereiten("dunkel", true);
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) { for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
+7 -1
View File
@@ -225,7 +225,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
inhalt: ( inhalt: (
<> <>
<h3 className="h6">Keys</h3> <h3 className="h6">Keys</h3>
<table className="table table-striped align-middle mb-4"> <div className="table-responsive mb-4">
<table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
<th scope="col">Key</th> <th scope="col">Key</th>
@@ -252,8 +253,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
<h3 className="h6">Server-IDs</h3> <h3 className="h6">Server-IDs</h3>
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -281,6 +284,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
{host.serverIdHistorie.length > 1 ? ( {host.serverIdHistorie.length > 1 ? (
<p className="small pl-text-warning mt-2 mb-0"> <p className="small pl-text-warning mt-2 mb-0">
Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss Mehrere Server-IDs bedeuten Hardwaretausch oder Neuinstallation – der Key muss
@@ -429,6 +433,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
titel: "Tokens", titel: "Tokens",
icon: "bi-key-fill", icon: "bi-key-fill",
inhalt: ( inhalt: (
<div className="table-responsive">
<table className="table table-striped align-middle mb-0"> <table className="table table-striped align-middle mb-0">
<thead> <thead>
<tr> <tr>
@@ -476,6 +481,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
) : null} ) : null}
</tbody> </tbody>
</table> </table>
</div>
), ),
}, },
]} ]}
+13 -3
View File
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null; const einkauf = laufzeit.proxmoxPosition?.rechnung ?? null;
const verkauf = laufzeit.kundenPosition?.rechnung ?? null; const verkauf = laufzeit.kundenPosition?.rechnung ?? null;
const einkaufBetrag = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null; // Gerechnet wird mit dem Betrag nach Abzug des Rabatts – das ist, was tatsächlich gezahlt wird (#037).
const einkaufListenpreis = laufzeit.proxmoxPosition ? Number(laufzeit.proxmoxPosition.nettoBetrag) : null;
const einkaufRabatt = Number(laufzeit.proxmoxPosition?.rabatt ?? 0);
const einkaufBetrag = einkaufListenpreis === null ? null : einkaufListenpreis - einkaufRabatt;
const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null; const verkaufBetrag = laufzeit.kundenPosition ? Number(laufzeit.kundenPosition.nettoBetrag) : null;
const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
<hr /> <hr />
<dl className="row mb-0"> <dl className="row mb-0">
<dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Einkauf netto</dt>
<dd className="col-7">{formatEuro(einkaufBetrag)}</dd> <dd className="col-7">
{formatEuro(einkaufBetrag)}
{einkaufRabatt > 0 ? (
<span className="small text-body-secondary d-block">
{formatEuro(einkaufListenpreis)} − {formatEuro(einkaufRabatt)} Rabatt
</span>
) : null}
</dd>
<dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt> <dt className="col-5 fw-normal text-body-secondary">Verkauf netto</dt>
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd> <dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
<dt className="col-5 fw-normal text-body-secondary">Marge</dt> <dt className="col-5 fw-normal text-body-secondary">Marge</dt>
@@ -164,7 +174,7 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
nummer: einkauf?.nummer ?? null, nummer: einkauf?.nummer ?? null,
datum: einkauf?.datum.toISOString() ?? null, datum: einkauf?.datum.toISOString() ?? null,
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag, faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
nettoBetrag: einkaufBetrag, nettoBetrag: einkaufListenpreis,
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz), ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
rabatt: rabatt:
laufzeit.proxmoxPosition?.rabatt === null || laufzeit.proxmoxPosition?.rabatt === null ||
+30
View File
@@ -395,6 +395,12 @@ body {
width: 13rem; width: 13rem;
} }
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
.pl-klappliste {
width: 22rem;
max-width: calc(100vw - 1rem);
}
/* ---------- Mobil ---------- */ /* ---------- Mobil ---------- */
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
@@ -425,6 +431,30 @@ body {
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen. * Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
*/ */
@media (max-width: 767.98px) { @media (max-width: 767.98px) {
/*
* Auf dem Telefon ist die Klappliste breiter als das Fenster – der Browser zoomt dann die
* **ganze Seite** heraus, damit sie hineinpasst. Deshalb hier fest am Fenster ausrichten
* statt am Knopf (#038).
*/
.pl-klappliste {
position: fixed;
top: 3.5rem;
right: 0.5rem;
left: 0.5rem;
width: auto;
max-width: none;
max-height: 70vh;
overflow-y: auto;
}
/*
* Lange Schlüssel und Server-IDs dürfen umbrechen. Sonst machen sie die Seite breiter als das
* Fenster – und der Browser zoomt die ganze Seite heraus, damit sie hineinpasst (#038).
*/
.pl-mono {
overflow-wrap: anywhere;
}
.pl-karten-tabelle, .pl-karten-tabelle,
.pl-karten-tabelle tbody, .pl-karten-tabelle tbody,
.pl-karten-tabelle tr, .pl-karten-tabelle tr,
+4 -3
View File
@@ -140,8 +140,7 @@ export function Header({
) : null} ) : null}
</button> </button>
<div <div
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`} className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
style={{ width: "22rem" }}
> >
<span className="dropdown-header">{alerts.length} offene Warnungen</span> <span className="dropdown-header">{alerts.length} offene Warnungen</span>
<div className="dropdown-divider" /> <div className="dropdown-divider" />
@@ -206,7 +205,9 @@ export function Header({
<i className="bi bi-person-circle" aria-hidden="true" /> <i className="bi bi-person-circle" aria-hidden="true" />
<span className="d-none d-sm-inline">{benutzer}</span> <span className="d-none d-sm-inline">{benutzer}</span>
</button> </button>
<div className={`dropdown-menu dropdown-menu-end shadow ${benutzerOffen ? "show" : ""}`}> <div
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${benutzerOffen ? "show" : ""}`}
>
<span className="dropdown-header"> <span className="dropdown-header">
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]} Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
</span> </span>