diff --git a/CHANGELOG.md b/CHANGELOG.md index 2710f2a..ae86731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [0.25.0] – 2026-10-03 + +### Hinzugefügt +- **Laufzeit von Hand anlegen** – für Hosts, die (noch) kein Ablaufdatum melden, und für Nachträge (#043) +- **Laufzeit bearbeiten**: Key, Beginn, Ende und Notiz lassen sich nachträglich ändern (#043) +- **Laufzeiten zusammenführen**, wenn neben einer erfassten Laufzeit ein Vorschlag mit abweichendem Datum steht; die Rechnungen wandern mit (#043) +- Kundenansicht: Laufzeit und Host verlinkt, dazu Marge und Rabatt je Zeile (#043) + ## [0.24.1] – 2026-10-03 ### Behoben diff --git a/DEVLOG.md b/DEVLOG.md index b229db1..d2b2afd 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 22 h 53 min +**Gesamt:** 23 h 58 min | Issue | Zeit | |---|---| @@ -48,6 +48,7 @@ | #040 Schlagworte sichtbar | 0 h 40 min | | #041 Doppelte Hosts | 0 h 50 min | | #042 QDevice im Cluster | 0 h 20 min | +| #043 Laufzeiten bearbeiten | 1 h 05 min | | Meilenstein | Zeit | |---|---| @@ -70,7 +71,7 @@ ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.24.1 +**Version:** 0.25.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -216,6 +217,9 @@ - QDevice im Cluster (#042): Der Agent nahm blind die dritte Spalte von `pvecm nodes` als Namen. Mit QDevice steht dort der Zustand des QDevice - Dritter Fall dieser Art: Was beim Feldtest auf einem Cluster stimmte, stimmt nicht überall. Spalten gehören über ihre Überschrift angesprochen +- Laufzeiten (#043): Anlegen gab es nur als Server-Aktion ohne Oberfläche, Ändern und Zusammenführen gar nicht. Dabei tritt der Doppelfall zwangsläufig auf, sobald das gemeldete Ablaufdatum von der Bestellung abweicht +- Beim Zusammenführen muss die Quelle ihre Rechnungsposition zuerst loslassen – die Datenbank lässt eine Position nur an einer Laufzeit zu. Der Test hat die falsche Reihenfolge sofort gezeigt + ### 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 8815b0c..dba61ce 100644 --- a/docs/anleitung/README.md +++ b/docs/anleitung/README.md @@ -279,6 +279,22 @@ Positionen und wird automatisch nachgeführt. Die Jahresbahn zeigt zwölf Monate mit einer Linie für „heute“, gruppiert nach Kunde. Darunter stehen die Abläufe der nächsten 90 Tage und die bereits abgelaufenen Subscriptions. +### Woher Laufzeiten kommen + +Eine Laufzeit ist ein Lizenzjahr eines Hosts. Es gibt zwei Wege: + +1. **Vom Agent vorgeschlagen.** Meldet ein Host ein Ablaufdatum, legt ProxLedger eine Laufzeit von + diesem Datum bis ein Jahr später an – zunächst als *Vorschlag*. Unter *Rechnungen → Vorschläge* + bestätigst oder verwirfst du ihn. +2. **Von Hand angelegt** über *Rechnungen → Laufzeit von Hand anlegen*. Nötig, wenn ein Host noch kein + Ablaufdatum meldet – etwa weil der Key erst bestellt ist – oder wenn ein zurückliegendes Lizenzjahr + nachgetragen wird. + +Jede Laufzeit lässt sich später ändern (*Laufzeit bearbeiten* in der Detailansicht): Key, Beginn, Ende +und Notiz. Stehen für denselben Host zwei Laufzeiten, die dasselbe Lizenzjahr meinen – etwa weil das +gemeldete Datum um einen Tag abweicht –, lassen sie sich **zusammenführen**. Die aufgelöste Laufzeit +gibt ihre Rechnungen an die verbleibende ab. + ### Der übliche Ablauf 1. Proxmox schickt die Rechnung zunächst als **UNPAID**. Sie wird hier erfasst, Haken „bezahlt“ bleibt leer. diff --git a/docs/anleitung/bilder/benutzer.png b/docs/anleitung/bilder/benutzer.png index 92b9990..66b624b 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 5d83aa9..3fad24d 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/hosts.png b/docs/anleitung/bilder/hosts.png index 6044187..2ad52cd 100644 Binary files a/docs/anleitung/bilder/hosts.png and b/docs/anleitung/bilder/hosts.png differ diff --git a/docs/anleitung/bilder/kunden.png b/docs/anleitung/bilder/kunden.png index b575ca1..70dcf46 100644 Binary files a/docs/anleitung/bilder/kunden.png and b/docs/anleitung/bilder/kunden.png differ diff --git a/docs/anleitung/bilder/nicht-zugeordnet.png b/docs/anleitung/bilder/nicht-zugeordnet.png index a66c2ac..e10dbcd 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 2f9535d..0c3811b 100644 Binary files a/docs/anleitung/bilder/passkeys.png and b/docs/anleitung/bilder/passkeys.png differ diff --git a/docs/anleitung/bilder/rechnungen.png b/docs/anleitung/bilder/rechnungen.png index c4e096d..1f44b8b 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 a88c254..3f376e4 100644 Binary files a/docs/anleitung/bilder/tokens.png and b/docs/anleitung/bilder/tokens.png differ diff --git a/docs/issues/043-laufzeiten-bearbeiten.md b/docs/issues/043-laufzeiten-bearbeiten.md new file mode 100644 index 0000000..50d6743 --- /dev/null +++ b/docs/issues/043-laufzeiten-bearbeiten.md @@ -0,0 +1,42 @@ +# #043 Laufzeiten anlegen, bearbeiten und zusammenführen +**Status:** erledigt +**Meilenstein:** v0.25.0 +**Labels:** backend, ui +**Geschätzt:** 4 h **Tatsächlich:** 1 h 05 min + +## Auslöser +Drei Rückmeldungen aus dem Einsatz: + +1. „In der Kundenübersicht wäre es auch cool, die Laufzeit / Rechnung zu überarbeiten.“ +2. „Wie werden so Laufzeiten erfasst?“ +3. „Auch die Laufzeit im Nachhinein zu bearbeiten geht nicht. Oder mit einer vorhandenen zu + verknüpfen oder zu vereinen.“ + +## Ausgangslage +Laufzeiten entstanden ausschließlich als **Vorschlag** aus dem Agenten-Report: Meldet ein Host ein +Ablaufdatum, legt ProxLedger eine Laufzeit von diesem Datum bis ein Jahr später an. Bestätigen oder +verwerfen war möglich – mehr nicht: + +- **Anlegen von Hand** gab es als Server-Aktion, aber **nirgends in der Oberfläche**. +- **Ändern** war unmöglich. Weicht das gemeldete Datum von der Bestellung ab, blieb es falsch. +- **Zusammenführen** gab es nicht, obwohl der Fall zwangsläufig auftritt: Neben einer erfassten + Laufzeit legt der Agent eine zweite an, wenn das Ablaufdatum um einen Tag abweicht. +- Aus der **Kundenansicht** führte kein Weg in die Laufzeit. + +## Behebung +- Formular *Laufzeit von Hand anlegen* auf der Rechnungsseite; der gemeldete Key wird vorgeschlagen, + das Ende aus dem Beginn vorbelegt (ein Jahr minus ein Tag – dieselbe Rechnung wie beim Vorschlag). +- *Laufzeit bearbeiten* im Laufzeit-Detail: Key, Beginn, Ende, Notiz. Eine Änderung bestätigt den + Vorschlag zugleich. +- *Zusammenführen* mit einer anderen Laufzeit desselben Hosts. Die aufgelöste Laufzeit gibt ihre + Rechnungen ab, sofern das Ziel dort nichts hat; haben beide eine Rechnung derselben Art, bricht der + Vorgang mit einer Erklärung ab. +- Kundenansicht: Laufzeit und Host sind verlinkt, Marge und Rabatt stehen dabei. + +## Entscheidungen +- **Kein stilles Überschreiben beim Zusammenführen.** Zwei Einkaufsrechnungen auf einem Lizenzjahr + wären nicht mehr zuzuordnen – lieber ein Abbruch mit Hinweis. +- **Beim Ändern bleibt die Regel „ein Startdatum je Host“.** Kollidiert das neue Datum, verweist die + Meldung aufs Zusammenführen. +- Beim Zusammenführen muss die Quelle ihre Positionen zuerst loslassen: Eine Rechnungsposition gehört + zu genau einer Laufzeit, und die Datenbank wacht darüber. Erst dann darf das Ziel sie übernehmen. diff --git a/docs/issues/README.md b/docs/issues/README.md index 37acc06..b136294 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -46,4 +46,5 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#040](040-schlagworte-sichtbar.md) | Schlagworte und Notizen sichtbar machen | erledigt | v0.23.0 | 2 h | 0 h 40 min | | [#041](041-doppelte-hosts.md) | Gleichnamige Hosts erkennen und stilllegen | erledigt | v0.24.0 | 3 h | 0 h 50 min | | [#042](042-qdevice-cluster.md) | Cluster-Knoten falsch gelesen bei QDevice | erledigt | v0.24.1 | 1 h | 0 h 20 min | -| | **Summe** | | | **169 h** | **22 h 53 min** | +| [#043](043-laufzeiten-bearbeiten.md) | Laufzeiten anlegen, bearbeiten, zusammenführen | erledigt | v0.25.0 | 4 h | 1 h 05 min | +| | **Summe** | | | **173 h** | **23 h 58 min** | diff --git a/package.json b/package.json index c2f2db5..212ddb1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.24.1", + "version": "0.25.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/(dashboard)/kunden/[id]/page.tsx b/src/app/(dashboard)/kunden/[id]/page.tsx index 826526d..82a82d2 100644 --- a/src/app/(dashboard)/kunden/[id]/page.tsx +++ b/src/app/(dashboard)/kunden/[id]/page.tsx @@ -204,7 +204,7 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{
- +
@@ -213,26 +213,46 @@ export default async function KundenDetailSeite({ params }: { params: Promise<{ + {laufzeiten.map((laufzeit) => ( - - - + - - + - + + ))} {laufzeiten.length === 0 ? ( - diff --git a/src/app/(dashboard)/rechnungen/[id]/page.tsx b/src/app/(dashboard)/rechnungen/[id]/page.tsx index b378d9f..404bf3f 100644 --- a/src/app/(dashboard)/rechnungen/[id]/page.tsx +++ b/src/app/(dashboard)/rechnungen/[id]/page.tsx @@ -11,6 +11,8 @@ import { formatDatum, formatEuro } from "@/lib/format"; import { laufzeitBestaetigenAktion, laufzeitVerwerfenAktion } from "@/server/actions/rechnungen"; import { einstellungen, faelligkeitsVorschlag } from "@/server/einstellungen"; import { laufzeitDetail } from "@/server/queries/rechnungen"; +import { LaufzeitBearbeiten } from "@/components/rechnungen/LaufzeitBearbeiten"; +import { prisma } from "@/lib/db"; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; @@ -33,7 +35,22 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id: const marge = einkaufBetrag !== null && verkaufBetrag !== null ? verkaufBetrag - einkaufBetrag : null; // Vorgaben aus den Einstellungen füllen leere Felder vor (#025). - const [vorgaben, faelligVorschlag] = await Promise.all([einstellungen(), faelligkeitsVorschlag()]); + const [vorgaben, faelligVorschlag, nachbarn] = await Promise.all([ + einstellungen(), + faelligkeitsVorschlag(), + prisma.laufzeit.findMany({ + where: { hostId: laufzeit.host.id, id: { not: laufzeit.id } }, + orderBy: { start: "desc" }, + select: { + id: true, + key: true, + start: true, + ende: true, + proxmoxPositionId: true, + kundenPositionId: true, + }, + }), + ]); const verkaufVorschlag = verkaufBetrag ?? (einkaufBetrag === null ? null : Math.round(einkaufBetrag * (1 + vorgaben.margeProzent / 100) * 100) / 100); @@ -127,6 +144,23 @@ export default async function LaufzeitSeite({ params }: { params: Promise<{ id: {marge === null ? "–" : formatEuro(marge)} + +
+ ({ + id: nachbar.id, + zeitraum: `${formatDatum(nachbar.start)} – ${formatDatum(nachbar.ende)}`, + key: nachbar.key, + hatEinkauf: nachbar.proxmoxPositionId !== null, + hatVerkauf: nachbar.kundenPositionId !== null, + }))} + /> {einkauf && einkauf.positionen.length > 1 ? ( diff --git a/src/app/(dashboard)/rechnungen/page.tsx b/src/app/(dashboard)/rechnungen/page.tsx index 2ab560c..5c83594 100644 --- a/src/app/(dashboard)/rechnungen/page.tsx +++ b/src/app/(dashboard)/rechnungen/page.tsx @@ -8,6 +8,7 @@ import { CopyableCode } from "@/components/ui/CopyableCode"; import { InfoBox } from "@/components/ui/SmallBox"; import { prisma } from "@/lib/db"; import { formatDatum, formatEuro, tageBis } from "@/lib/format"; +import { LaufzeitAnlegen } from "@/components/rechnungen/LaufzeitAnlegen"; import { anzahlVorschlaege, laufzeitJahre, @@ -33,11 +34,16 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: q: einzeln(roh.q), }; - const [{ zeilen, summen }, jahre, kunden, vorschlaege] = await Promise.all([ + const [{ zeilen, summen }, jahre, kunden, vorschlaege, hostsFuerLaufzeit] = await Promise.all([ rechnungsListe(filter), laufzeitJahre(), prisma.kunde.findMany({ orderBy: { name: "asc" }, select: { id: true, name: true } }), anzahlVorschlaege(), + prisma.host.findMany({ + where: { archiviertAm: null }, + orderBy: { hostname: "asc" }, + select: { id: true, hostname: true, subKey: true, kunde: { select: { name: true } } }, + }), ]); const heute = new Date(); @@ -106,6 +112,19 @@ export default async function RechnungenSeite({ searchParams }: { searchParams: + {/* Laufzeiten entstehen sonst aus den Agenten-Reports; von Hand braucht man es für + Hosts ohne gemeldetes Ablaufdatum oder für Nachträge (#043). */} + + ({ + id: host.id, + hostname: host.hostname, + kundeName: host.kunde?.name ?? null, + subKey: host.subKey, + }))} + /> + + host.id === hostId); + + return ( +
+ {ergebnis.fehler ?
{ergebnis.fehler}
: null} + {ergebnis.erfolg ?
{ergebnis.erfolg}
: null} + +
+ + +
+ +
+ + setKey(e.target.value)} + placeholder="pve2s-…" + required + /> + {gewaehlt?.subKey ? ( +
Gemeldet: {gewaehlt.subKey}
+ ) : ( +
Dieser Host meldet keinen Key – Nummer aus der Bestellung eintragen.
+ )} +
+ +
+ + { + setStart(e.target.value); + setEnde(endeAus(e.target.value)); + }} + required + /> +
+ +
+ + setEnde(e.target.value)} + required + /> +
Ein Jahr, vorbelegt.
+
+ +
+ +
+ + ); +} diff --git a/src/components/rechnungen/LaufzeitBearbeiten.tsx b/src/components/rechnungen/LaufzeitBearbeiten.tsx new file mode 100644 index 0000000..f34abca --- /dev/null +++ b/src/components/rechnungen/LaufzeitBearbeiten.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useActionState, useState } from "react"; +import type { AktionsErgebnis } from "@/server/actions/kunden"; +import { + laufzeitBearbeitenAktion, + laufzeitenZusammenfuehrenAktion, +} from "@/server/actions/rechnungen"; + +const LEER: AktionsErgebnis = {}; + +export interface NachbarLaufzeit { + id: string; + zeitraum: string; + key: string; + hatEinkauf: boolean; + hatVerkauf: boolean; +} + +/** + * Laufzeit nachträglich ändern und mit einer anderen desselben Hosts zusammenführen (#043). + * + * Nötig, weil Laufzeiten meist aus einem Agenten-Report entstehen: Weicht das gemeldete + * Ablaufdatum von der Bestellung ab, steht die Laufzeit falsch da – oder gleich doppelt. + */ +export function LaufzeitBearbeiten({ + laufzeitId, + hostId, + // Nicht `key` nennen: Das ist in React reserviert und käme hier nie an. + schluessel, + start, + ende, + notizen, + nachbarn, +}: { + laufzeitId: string; + hostId: string; + schluessel: string; + start: string; + ende: string; + notizen: string | null; + nachbarn: NachbarLaufzeit[]; +}) { + const [ergebnis, aktion, laeuft] = useActionState(laufzeitBearbeitenAktion, LEER); + const [zusammen, zusammenAktion, fuehrtZusammen] = useActionState(laufzeitenZusammenfuehrenAktion, LEER); + const [offen, setOffen] = useState(false); + + return ( + <> +
LaufzeitEinkauf Kundenrechnung VerkaufMarge
- {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} + {/* Von hier aus werden die Rechnungen erfasst – ohne Link führte die + Kundenansicht in eine Sackgasse (#043). */} + + + {formatDatum(laufzeit.start)} – {formatDatum(laufzeit.ende)} + {laufzeit.vorschlag ? ( Vorschlag ) : null} {laufzeit.hostname} + + + {laufzeit.hostname} + +
{formatEuro(laufzeit.einkauf?.nettoBetrag)} + + {formatEuro(laufzeit.einkauf?.nettoBetrag)} + {laufzeit.einkauf && laufzeit.einkauf.rabatt > 0 ? ( +
+ {formatEuro(laufzeit.einkauf.listenpreis)} − {formatEuro(laufzeit.einkauf.rabatt)}{" "} + Rabatt +
+ ) : null} +
{formatEuro(laufzeit.verkauf?.nettoBetrag)}{formatEuro(laufzeit.verkauf?.nettoBetrag)}= 0 + ? "pl-text-success" + : "pl-text-danger" + : undefined + } + > + {laufzeit.einkauf && laufzeit.verkauf + ? formatEuro((laufzeit.verkauf.nettoBetrag ?? 0) - laufzeit.einkauf.nettoBetrag) + : "–"} +
+ Noch keine Laufzeiten erfasst.