@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
|
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 |
|
| [#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,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",
|
||||||
|
|||||||
@@ -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"]) {
|
||||||
|
|||||||
@@ -225,62 +225,66 @@ 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">
|
||||||
<thead>
|
<table className="table table-striped align-middle mb-0">
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">Key</th>
|
|
||||||
<th scope="col">Erstmals gemeldet</th>
|
|
||||||
<th scope="col">Zuletzt gemeldet</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{host.keyHistorie.map((eintrag) => (
|
|
||||||
<tr key={eintrag.id}>
|
|
||||||
<td>
|
|
||||||
<CopyableCode wert={eintrag.key} />
|
|
||||||
</td>
|
|
||||||
<td>{formatDatum(eintrag.ersteMeldung)}</td>
|
|
||||||
<td>{formatDatum(eintrag.letzteMeldung)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{host.keyHistorie.length === 0 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="text-body-secondary">
|
<th scope="col">Key</th>
|
||||||
Noch kein Key gemeldet.
|
<th scope="col">Erstmals gemeldet</th>
|
||||||
</td>
|
<th scope="col">Zuletzt gemeldet</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{host.keyHistorie.map((eintrag) => (
|
||||||
|
<tr key={eintrag.id}>
|
||||||
|
<td>
|
||||||
|
<CopyableCode wert={eintrag.key} />
|
||||||
|
</td>
|
||||||
|
<td>{formatDatum(eintrag.ersteMeldung)}</td>
|
||||||
|
<td>{formatDatum(eintrag.letzteMeldung)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{host.keyHistorie.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-body-secondary">
|
||||||
|
Noch kein Key gemeldet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 className="h6">Server-IDs</h3>
|
<h3 className="h6">Server-IDs</h3>
|
||||||
<table className="table table-striped align-middle mb-0">
|
<div className="table-responsive">
|
||||||
<thead>
|
<table className="table table-striped align-middle mb-0">
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">Server-ID</th>
|
|
||||||
<th scope="col">Erstmals gemeldet</th>
|
|
||||||
<th scope="col">Zuletzt gemeldet</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{host.serverIdHistorie.map((eintrag) => (
|
|
||||||
<tr key={eintrag.id}>
|
|
||||||
<td>
|
|
||||||
<CopyableCode wert={eintrag.serverId} />
|
|
||||||
</td>
|
|
||||||
<td>{formatDatum(eintrag.ersteMeldung)}</td>
|
|
||||||
<td>{formatDatum(eintrag.letzteMeldung)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{host.serverIdHistorie.length === 0 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={3} className="text-body-secondary">
|
<th scope="col">Server-ID</th>
|
||||||
Noch keine Server-ID gemeldet.
|
<th scope="col">Erstmals gemeldet</th>
|
||||||
</td>
|
<th scope="col">Zuletzt gemeldet</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{host.serverIdHistorie.map((eintrag) => (
|
||||||
|
<tr key={eintrag.id}>
|
||||||
|
<td>
|
||||||
|
<CopyableCode wert={eintrag.serverId} />
|
||||||
|
</td>
|
||||||
|
<td>{formatDatum(eintrag.ersteMeldung)}</td>
|
||||||
|
<td>{formatDatum(eintrag.letzteMeldung)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{host.serverIdHistorie.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-body-secondary">
|
||||||
|
Noch keine Server-ID gemeldet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</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,53 +433,55 @@ export default async function HostDetailSeite({ params }: { params: Promise<{ id
|
|||||||
titel: "Tokens",
|
titel: "Tokens",
|
||||||
icon: "bi-key-fill",
|
icon: "bi-key-fill",
|
||||||
inhalt: (
|
inhalt: (
|
||||||
<table className="table table-striped align-middle mb-0">
|
<div className="table-responsive">
|
||||||
<thead>
|
<table className="table table-striped align-middle mb-0">
|
||||||
<tr>
|
<thead>
|
||||||
<th scope="col">Anfang</th>
|
|
||||||
<th scope="col">Erstellt</th>
|
|
||||||
<th scope="col">Zuletzt benutzt</th>
|
|
||||||
<th scope="col">Zustand</th>
|
|
||||||
<th scope="col" className="text-end">
|
|
||||||
Aktion
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{host.tokens.map((token) => (
|
|
||||||
<tr key={token.id}>
|
|
||||||
<td className="pl-mono">{token.praefix}…</td>
|
|
||||||
<td>{formatDatum(token.erstelltAm)}</td>
|
|
||||||
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
|
|
||||||
<td>
|
|
||||||
{token.widerrufenAm ? (
|
|
||||||
<Badge farbe="neutral">Widerrufen</Badge>
|
|
||||||
) : (
|
|
||||||
<Badge farbe="success">Aktiv</Badge>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="text-end">
|
|
||||||
{token.widerrufenAm ? null : (
|
|
||||||
<form action={hostTokenWiderrufen}>
|
|
||||||
<input type="hidden" name="id" value={token.id} />
|
|
||||||
<input type="hidden" name="hostId" value={host.id} />
|
|
||||||
<button type="submit" className="btn btn-sm btn-outline-danger">
|
|
||||||
Widerrufen
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{host.tokens.length === 0 ? (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={5} className="text-body-secondary">
|
<th scope="col">Anfang</th>
|
||||||
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
|
<th scope="col">Erstellt</th>
|
||||||
</td>
|
<th scope="col">Zuletzt benutzt</th>
|
||||||
|
<th scope="col">Zustand</th>
|
||||||
|
<th scope="col" className="text-end">
|
||||||
|
Aktion
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{host.tokens.map((token) => (
|
||||||
|
<tr key={token.id}>
|
||||||
|
<td className="pl-mono">{token.praefix}…</td>
|
||||||
|
<td>{formatDatum(token.erstelltAm)}</td>
|
||||||
|
<td>{formatDatumZeit(token.letzteNutzungAm)}</td>
|
||||||
|
<td>
|
||||||
|
{token.widerrufenAm ? (
|
||||||
|
<Badge farbe="neutral">Widerrufen</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge farbe="success">Aktiv</Badge>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-end">
|
||||||
|
{token.widerrufenAm ? null : (
|
||||||
|
<form action={hostTokenWiderrufen}>
|
||||||
|
<input type="hidden" name="id" value={token.id} />
|
||||||
|
<input type="hidden" name="hostId" value={host.id} />
|
||||||
|
<button type="submit" className="btn btn-sm btn-outline-danger">
|
||||||
|
Widerrufen
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{host.tokens.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-body-secondary">
|
||||||
|
Kein Token vorhanden – dieser Host stammt aus den Demo-Daten.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||