diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c3c18..f643f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Sicherheit +## [Unveröffentlicht v0.6.0] + +### Hinzugefügt +- Jahresbahn mit zwölf Monaten, Heute-Linie und Gruppierung nach Kunde, aus echten Daten (#009) +- Ablauf-Kalender für die nächsten 90 Tage samt Rechnungsstatus und eine Übersicht bereits abgelaufener Subscriptions (#009) + +### Geändert +- Die Oberfläche arbeitet vollständig mit der Datenbank; Beispieldaten dienen nur noch dem Seed (#009) + ## [0.5.0] – 2026-09-17 ### Hinzugefügt diff --git a/DEVLOG.md b/DEVLOG.md index c72571b..fcdebd0 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,7 +2,7 @@ ## Zeitübersicht -**Gesamt:** 3 h 23 min +**Gesamt:** 3 h 38 min | Issue | Zeit | |---|---| @@ -15,6 +15,7 @@ | #007 Host-Tabelle und Detailansichten | 0 h 25 min | | #020 Issues nach Gitea übertragen | 0 h 20 min | | #008 Laufzeiten und Rechnungen | 0 h 35 min | +| #009 Jahresbahn und Ablauf-Kalender | 0 h 15 min | | Meilenstein | Zeit | |---|---| @@ -25,12 +26,13 @@ | v0.4.1 | 0 h 10 min | **abgeschlossen** | v0.4.2 | 0 h 10 min | **abgeschlossen** | v0.5.0 | 0 h 35 min | **abgeschlossen** +| v0.6.0 | 0 h 15 min | in Arbeit --- ## 2026-09-17 · Sitzung 1 · 19:36–laufend **Issue:** #001–#007 (Meilensteine v0.1.0 bis v0.4.0) -**Version:** 0.5.0 +**Version:** 0.6.0 ### Erledigt - Auftrag gelesen, Rückfragen gestellt @@ -56,6 +58,7 @@ - Alles nach Gitea gepusht: main bei 51243c2, Tags v0.1.0 bis v0.4.1 - #008 abgeschlossen: Laufzeit-Vorschläge bestätigen oder verwerfen, Rechnungen mit Positionen erfassen, PDF-Upload, Häkchen direkt in der Tabelle, Filter nach Status, Kunde und Jahr; Aufgabenliste auf dem Dashboard setzt jetzt echte Status - Meilenstein v0.5.0 abgeschlossen; 78 Tests +- #009 abgeschlossen: Jahresbahn und Ablauf-Kalender (90 Tage) aus der Datenbank, dazu eine Karte für bereits abgelaufene Subscriptions; die Oberfläche nutzt keine Beispieldaten mehr ### Entscheidungen - **Rechnungsnummer als Schlüssel für Sammelrechnungen:** Dieselbe Nummer bei einer zweiten Laufzeit hängt eine Position an – kein eigener Dialog für „Sammelrechnung anlegen“ nötig. @@ -84,4 +87,4 @@ - Offen: Rolle „readonly“ zusätzlich zu Admin? (vorläufig vorgesehen) ### Nächster Schritt -- #009 Kundenübersicht, Ablauf-Kalender und Jahresbahn auf Datenbankbasis +- #010 Export: CSV für Kundenübersicht und Rechnungsliste, PDF-Ausgaben diff --git a/docs/issues/009-kundenuebersicht-dashboard-jahresbahn.md b/docs/issues/009-kundenuebersicht-dashboard-jahresbahn.md index 0554304..80cd4e0 100644 --- a/docs/issues/009-kundenuebersicht-dashboard-jahresbahn.md +++ b/docs/issues/009-kundenuebersicht-dashboard-jahresbahn.md @@ -1,25 +1,31 @@ # #009 Kundenübersicht, Dashboard und Jahresbahn -**Status:** offen +**Status:** erledigt **Meilenstein:** v0.6.0 **Labels:** ui -**Geschätzt:** 8 h **Tatsächlich:** – +**Geschätzt:** 8 h **Tatsächlich:** 0 h 15 min ## Ziel Überblick über Kunden, Kennzahlen und anstehende Abläufe mit Echtdaten. ## Aufgaben -- [ ] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel -- [ ] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr) -- [ ] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen -- [ ] Card „Rechnungsstatus je Kunde“ -- [ ] Ablauf-Kalender: nächste 90 Tage -- [ ] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde +- [x] Kundenübersicht: Anzahl Hosts, aktive Subscriptions, nächster Ablauf, offene Warnungen, Rechnungs-Ampel +- [x] Kunden-Detail mit Echtdaten (Info-Boxen, Hosts, Laufzeiten, Summen pro Jahr) +- [x] Dashboard mit Echtdaten: Small Boxes (inkl. „Mehr Infos“-Links auf gefilterte Listen), Kacheln ohne Subscription, ohne Meldung, ohne Kunde, offene Proxmox-Rechnungen, fehlende Kundenrechnungen +- [x] Card „Rechnungsstatus je Kunde“ +- [x] Ablauf-Kalender: nächste 90 Tage +- [x] Jahresbahn: Timeline der nächsten 12 Monate, „Heute“-Linie, gruppiert nach Kunde ## Akzeptanzkriterien -- [ ] Kennzahlen stimmen mit den gefilterten Listen überein -- [ ] Jahresbahn mobil horizontal scrollbar +- [x] Kennzahlen stimmen mit den gefilterten Listen überein +- [x] Jahresbahn mobil horizontal scrollbar ## Abhängigkeiten #008 ## Notizen / Entscheidungen +- Kundenübersicht, Kunden-Detail und Dashboard liefen bereits seit #006 auf der Datenbank; hier kamen + Jahresbahn und Ablauf-Kalender dazu. +- Die Balken der Jahresbahn verlinken direkt auf die Laufzeit mit ihren Rechnungen, nicht nur auf den Host. +- Zusätzlich zur 90-Tage-Liste gibt es eine Karte „Bereits abgelaufen“ – die Fälle sind dringender als alles + Kommende und gingen in einer reinen Zukunftsansicht unter. +- Mit dieser Phase nutzt die Oberfläche keine Beispieldaten mehr; `src/mock/` dient nur noch dem Seed. diff --git a/docs/issues/README.md b/docs/issues/README.md index 22f321e..8f2d2fb 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -12,8 +12,8 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#006](006-kunden-hostzuweisung.md) | Kunden, Standorte und Hostzuweisung | erledigt | v0.3.0 | 6 h | 0 h 35 min | | [#007](007-hosts-detailansichten.md) | Host-Tabelle, Suche und Detailansichten | erledigt | v0.4.0 | 8 h | 0 h 25 min | | [#008](008-laufzeiten-rechnungen.md) | Laufzeiten und Rechnungen | erledigt | v0.5.0 | 10 h | 0 h 35 min | -| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | in Arbeit | v0.6.0 | 8 h | – | -| [#010](010-export.md) | Export CSV und PDF | offen | v0.6.0 | 5 h | – | +| [#009](009-kundenuebersicht-dashboard-jahresbahn.md) | Kundenübersicht, Dashboard und Jahresbahn | erledigt | v0.6.0 | 8 h | 0 h 15 min | +| [#010](010-export.md) | Export CSV und PDF | in Arbeit | v0.6.0 | 5 h | – | | [#011](011-warnregeln.md) | Warnregeln | offen | v0.7.0 | 8 h | – | | [#012](012-benachrichtigungen.md) | Benachrichtigungen | offen | v0.8.0 | 6 h | – | | [#013](013-authentifizierung.md) | Authentifizierung und Audit-Log | offen | v0.9.0 | 6 h | – | @@ -24,4 +24,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a | [#018](018-agent-feldtest.md) | Agent auf echten Proxmox-Hosts prüfen | offen | v0.2.1 | 2 h | – | | [#019](019-ci-pipeline.md) | CI-Pipeline | offen | v0.10.0 | 3 h | – | | [#020](020-gitea-issue-abgleich.md) | Issues nach Gitea übertragen | erledigt | v0.4.1 | 2 h | 0 h 10 min | -| | **Summe** | | | **114 h** | **3 h 23 min** | +| | **Summe** | | | **114 h** | **3 h 38 min** | diff --git a/package.json b/package.json index dae45b8..b3eac28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "proxledger", - "version": "0.5.0", + "version": "0.6.0", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/(dashboard)/jahresbahn/page.tsx b/src/app/(dashboard)/jahresbahn/page.tsx index c12029a..c7a0c53 100644 --- a/src/app/(dashboard)/jahresbahn/page.tsx +++ b/src/app/(dashboard)/jahresbahn/page.tsx @@ -1,26 +1,31 @@ import Link from "next/link"; import { ContentHeader } from "@/components/layout/ContentHeader"; +import { AblaufBadge, EinkaufBadge, VerkaufBadge } from "@/components/ui/Badges"; import { Card } from "@/components/ui/Card"; -import { formatDatum, tageBis } from "@/lib/format"; -import { HEUTE, hosts, kunden } from "@/mock/data"; +import { CopyableCode } from "@/components/ui/CopyableCode"; +import { formatDatum, formatTageBis } from "@/lib/format"; +import { ablaufDaten } from "@/server/queries/jahresbahn"; export const metadata = { title: "Jahresbahn" }; const MONATE = ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun", "Jul", "Aug", "Sep", "Okt", "Nov", "Dez"]; +const GESAMT_TAGE = 365; -export default function JahresbahnSeite() { - const start = new Date(Date.UTC(HEUTE.getUTCFullYear(), HEUTE.getUTCMonth(), 1)); - const gesamtTage = 365; +export default async function JahresbahnSeite() { + const jetzt = new Date(); + const { gruppen, naechste90Tage, abgelaufen } = await ablaufDaten(jetzt); - function position(datum: string): number | null { - const tage = Math.round((new Date(datum).getTime() - start.getTime()) / 86_400_000); - if (tage < 0 || tage > gesamtTage) return null; - return (tage / gesamtTage) * 100; + // Die Bahn beginnt am Ersten des laufenden Monats und zeigt zwölf Monate. + const start = new Date(Date.UTC(jetzt.getUTCFullYear(), jetzt.getUTCMonth(), 1)); + + function position(datum: Date): number | null { + const tage = Math.round((datum.getTime() - start.getTime()) / 86_400_000); + if (tage < 0 || tage > GESAMT_TAGE) return null; + return (tage / GESAMT_TAGE) * 100; } - const heutePosition = position(HEUTE.toISOString().slice(0, 10)) ?? 0; - - const monatsBeschriftung = Array.from({ length: 12 }, (_, i) => { + const heutePosition = position(jetzt) ?? 0; + const monate = Array.from({ length: 12 }, (_, i) => { const datum = new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth() + i, 1)); return `${MONATE[datum.getUTCMonth()]} ${String(datum.getUTCFullYear()).slice(2)}`; }); @@ -31,79 +36,175 @@ export default function JahresbahnSeite() {
- -
-
-
- {monatsBeschriftung.map((monat) => ( -
- {monat} -
- ))} -
- - {kunden.map((kunde) => { - const eigene = hosts.filter( - (h) => h.kundeId === kunde.id && h.subscription.nextduedate !== null, - ); - if (eigene.length === 0) return null; - return ( -
-
- - {kunde.name} - + + {gruppen.length === 0 ? ( +

Noch kein Host mit Ablaufdatum gemeldet.

+ ) : ( +
+
+
+ {monate.map((monat) => ( +
+ {monat}
- {eigene.map((host) => { - const datum = host.subscription.nextduedate!; - const links = position(datum.slice(0, 10)); - const tage = tageBis(datum, HEUTE); + ))} +
+ + {gruppen.map((gruppe) => ( +
+
+ {gruppe.kundeId ? ( + + {gruppe.name} + + ) : ( + {gruppe.name} + )} +
+ + {gruppe.eintraege.map((eintrag) => { + const links = position(eintrag.ablauf); const farbe = - tage === null || tage < 14 + eintrag.tage < 14 ? "var(--pl-danger)" - : tage < 30 + : eintrag.tage < 30 ? "var(--pl-warning)" : "var(--pl-info)"; return ( -
-
- - {host.hostname} +
+
+ + {eintrag.hostname}
{links === null ? ( - außerhalb des Zeitraums ({formatDatum(datum)}) + außerhalb des Zeitraums ({formatDatum(eintrag.ablauf)}) ) : ( -
- {formatDatum(datum)} -
+ {formatDatum(eintrag.ablauf)} + )}
); })}
- ); - })} + ))} +
-
+ )} +

- Die rote Linie markiert heute ({formatDatum(HEUTE)}). Farbe der Balken: rot unter 14 Tagen, gelb - unter 30 Tagen, sonst blau. + Die rote Linie markiert heute ({formatDatum(jetzt)}). Balkenfarbe: rot unter 14 Tagen, gelb + unter 30 Tagen, sonst blau. Ein Klick führt zur Laufzeit mit ihren Rechnungen.

+ +
+
+ +
+ + + + + + + + + + + + + {naechste90Tage.map((eintrag) => ( + + + + + + + + + ))} + {naechste90Tage.length === 0 ? ( + + + + ) : null} + +
AblaufHostKundeKeyProxmoxKundenrechnung
+ + + + {eintrag.hostname} + + + {eintrag.kunde ? ( + + {eintrag.kunde.name} + + ) : ( + Nicht zugeordnet + )} + + + + + + +
+ In den nächsten 90 Tagen läuft nichts ab. +
+
+
+
+ +
+ 0 ? "danger" : "info"} + > + {abgelaufen.length === 0 ? ( +

Keine abgelaufenen Subscriptions.

+ ) : ( +
    + {abgelaufen.map((eintrag) => ( +
  • + + {eintrag.hostname} + +
    + {eintrag.kunde?.name ?? "Nicht zugeordnet"} · {formatDatum(eintrag.ablauf)} ( + {formatTageBis(eintrag.tage)}) +
    +
  • + ))} +
+ )} +
+
+
diff --git a/src/server/queries/jahresbahn.ts b/src/server/queries/jahresbahn.ts new file mode 100644 index 0000000..040d157 --- /dev/null +++ b/src/server/queries/jahresbahn.ts @@ -0,0 +1,73 @@ +import { prisma } from "@/lib/db"; + +/** Hosts mit Ablaufdatum, gruppiert nach Kunde – Grundlage für Jahresbahn und Ablauf-Kalender. */ +export async function ablaufDaten(jetzt: Date = new Date()) { + const hosts = await prisma.host.findMany({ + where: { subNextduedate: { not: null } }, + orderBy: [{ subNextduedate: "asc" }], + select: { + id: true, + hostname: true, + subKey: true, + subNextduedate: true, + subStatus: true, + kunde: { select: { id: true, name: true } }, + laufzeiten: { + where: { status: "BESTAETIGT" }, + orderBy: { start: "desc" }, + take: 2, + select: { + id: true, + start: true, + proxmoxPosition: { select: { rechnung: { select: { bezahlt: true } } } }, + kundenPosition: { select: { rechnung: { select: { erstellt: true } } } }, + }, + }, + }, + }); + + const eintraege = hosts.map((host) => { + const ablauf = host.subNextduedate!; + const verlaengerung = host.laufzeiten.find((l) => l.start.getTime() === ablauf.getTime()); + + return { + hostId: host.id, + hostname: host.hostname, + key: host.subKey, + ablauf, + status: host.subStatus, + kunde: host.kunde, + laufzeitId: verlaengerung?.id ?? null, + einkaufBezahlt: verlaengerung?.proxmoxPosition?.rechnung.bezahlt ?? null, + verkaufErstellt: verlaengerung?.kundenPosition?.rechnung.erstellt ?? null, + tage: Math.round((ablauf.getTime() - jetzt.getTime()) / 86_400_000), + }; + }); + + // Nach Kunde gruppieren; Hosts ohne Kunde kommen ans Ende. + const gruppen = new Map(); + for (const eintrag of eintraege) { + const schluessel = eintrag.kunde?.id ?? "ohne"; + const gruppe = gruppen.get(schluessel) ?? { + name: eintrag.kunde?.name ?? "Nicht zugeordnet", + kundeId: eintrag.kunde?.id ?? null, + eintraege: [], + }; + gruppe.eintraege.push(eintrag); + gruppen.set(schluessel, gruppe); + } + + const sortiert = [...gruppen.values()].sort((a, b) => { + if (a.kundeId === null) return 1; + if (b.kundeId === null) return -1; + return a.name.localeCompare(b.name, "de"); + }); + + return { + gruppen: sortiert, + naechste90Tage: eintraege.filter((e) => e.tage >= 0 && e.tage <= 90), + abgelaufen: eintraege.filter((e) => e.tage < 0), + }; +} + +export type AblaufEintrag = Awaited>["naechste90Tage"][number];