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>
@@ -17,6 +17,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
### 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
|
||||
|
||||
### Geändert
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Zeitübersicht
|
||||
|
||||
**Gesamt:** 19 h 53 min
|
||||
**Gesamt:** 20 h 33 min
|
||||
|
||||
| Issue | Zeit |
|
||||
|---|---|
|
||||
@@ -43,6 +43,7 @@
|
||||
| #035 Tabellen als Karten | 0 h 50 min |
|
||||
| #036 Standort am Host | 0 h 30 min |
|
||||
| #037 Rabatt je Position | 0 h 55 min |
|
||||
| #038 Überbreite auf dem Handy | 0 h 40 min |
|
||||
|
||||
| Meilenstein | Zeit |
|
||||
|---|---|
|
||||
@@ -65,7 +66,7 @@
|
||||
|
||||
## 2026-09-17 · Sitzung 1 · 19:36–laufend
|
||||
**Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0)
|
||||
**Version:** 0.22.0
|
||||
**Version:** 0.22.1
|
||||
|
||||
### Erledigt
|
||||
- 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
|
||||
- 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
|
||||
- #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)
|
||||
|
||||
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 114 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 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: 210 KiB After Width: | Height: | Size: 198 KiB |
|
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 194 KiB |
@@ -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.
|
||||
@@ -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 |
|
||||
| [#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 |
|
||||
| | **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,6 +1,6 @@
|
||||
{
|
||||
"name": "proxledger",
|
||||
"version": "0.22.0",
|
||||
"version": "0.22.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -25,6 +25,14 @@ const BASIS = process.env.SCREENSHOT_URL ?? "http://localhost:3000";
|
||||
const ZIEL = path.join(process.cwd(), "docs", "anleitung", "bilder");
|
||||
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 */
|
||||
const ZUSATZ = process.argv
|
||||
.filter((wert, i) => process.argv[i - 1] === "--seite")
|
||||
@@ -187,6 +195,11 @@ async function main() {
|
||||
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) {
|
||||
const dunkel = profilVorbereiten("dunkel", true);
|
||||
for (const seite of ["dashboard", "hosts", "rechnungen", "warnungen"]) {
|
||||
|
||||
@@ -225,7 +225,8 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
inhalt: (
|
||||
<>
|
||||
<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>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
@@ -252,8 +253,10 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3 className="h6">Server-IDs</h3>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -281,6 +284,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{host.serverIdHistorie.length > 1 ? (
|
||||
<p className="small pl-text-warning mt-2 mb-0">
|
||||
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",
|
||||
icon: "bi-key-fill",
|
||||
inhalt: (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-striped align-middle mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -476,6 +481,7 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -25,7 +25,10 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
|
||||
const einkauf = laufzeit.proxmoxPosition?.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 marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null;
|
||||
|
||||
@@ -109,7 +112,14 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id:
|
||||
<hr />
|
||||
<dl className="row mb-0">
|
||||
<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>
|
||||
<dd className="col-7">{formatEuro(verkaufBetrag)}</dd>
|
||||
<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,
|
||||
datum: einkauf?.datum.toISOString() ?? null,
|
||||
faelligAm: einkauf?.faelligAm?.toISOString() ?? faelligVorschlag,
|
||||
nettoBetrag: einkaufBetrag,
|
||||
nettoBetrag: einkaufListenpreis,
|
||||
ustSatz: Number(einkauf?.ustSatz ?? vorgaben.steuersatz),
|
||||
rabatt:
|
||||
laufzeit.proxmoxPosition?.rabatt === null ||
|
||||
|
||||
@@ -395,6 +395,12 @@ body {
|
||||
width: 13rem;
|
||||
}
|
||||
|
||||
/* Klapplisten in der Kopfzeile (Warnungen, Benutzermenü). */
|
||||
.pl-klappliste {
|
||||
width: 22rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
|
||||
/* ---------- Mobil ---------- */
|
||||
|
||||
@media (max-width: 575.98px) {
|
||||
@@ -425,6 +431,30 @@ body {
|
||||
* Zellen ohne `data-label` stehen für sich – etwa eine Zeile mit Knöpfen.
|
||||
*/
|
||||
@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 tbody,
|
||||
.pl-karten-tabelle tr,
|
||||
|
||||
@@ -140,8 +140,7 @@ export function Header({
|
||||
) : null}
|
||||
</button>
|
||||
<div
|
||||
className={`dropdown-menu dropdown-menu-end shadow ${glockeOffen ? "show" : ""}`}
|
||||
style={{ width: "22rem" }}
|
||||
className={`dropdown-menu dropdown-menu-end shadow pl-klappliste ${glockeOffen ? "show" : ""}`}
|
||||
>
|
||||
<span className="dropdown-header">{alerts.length} offene Warnungen</span>
|
||||
<div className="dropdown-divider" />
|
||||
@@ -206,7 +205,9 @@ export function Header({
|
||||
<i className="bi bi-person-circle" aria-hidden="true" />
|
||||
<span className="d-none d-sm-inline">{benutzer}</span>
|
||||
</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">
|
||||
Angemeldet als {benutzer} · {ROLLEN_TEXT[rolle]}
|
||||
</span>
|
||||
|
||||