Merge branch 'phase/009-jahresbahn'

This commit is contained in:
2026-09-17 21:57:09 +02:00
7 changed files with 265 additions and 73 deletions
+9
View File
@@ -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
+6 -3
View File
@@ -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
@@ -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.
+3 -3
View File
@@ -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** |
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "proxledger",
"version": "0.5.0",
"version": "0.6.0",
"private": true,
"scripts": {
"dev": "next dev",
+157 -56
View File
@@ -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() {
<div className="app-content">
<div className="container-fluid">
<Card titel="Abläufe der nächsten 12 Monate" rahmen="primary">
<div className="table-responsive">
<div style={{ minWidth: "760px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "12rem" }}>
{monatsBeschriftung.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
</div>
))}
</div>
{kunden.map((kunde) => {
const eigene = hosts.filter(
(h) => h.kundeId === kunde.id && h.subscription.nextduedate !== null,
);
if (eigene.length === 0) return null;
return (
<div key={kunde.id} className="mb-3">
<div className="fw-semibold small mb-1">
<Link href={`/kunden/${kunde.id}`} className="text-decoration-none">
{kunde.name}
</Link>
<Card titel="Abläufe der nächsten zwölf Monate" rahmen="primary">
{gruppen.length === 0 ? (
<p className="mb-0 text-body-secondary">Noch kein Host mit Ablaufdatum gemeldet.</p>
) : (
<div className="table-responsive">
<div style={{ minWidth: "820px" }}>
<div className="d-flex text-body-secondary small mb-2" style={{ marginLeft: "13rem" }}>
{monate.map((monat) => (
<div key={monat} style={{ width: `${100 / 12}%` }}>
{monat}
</div>
{eigene.map((host) => {
const datum = host.subscription.nextduedate!;
const links = position(datum.slice(0, 10));
const tage = tageBis(datum, HEUTE);
))}
</div>
{gruppen.map((gruppe) => (
<div key={gruppe.kundeId ?? "ohne"} className="mb-3">
<div className="fw-semibold small mb-1">
{gruppe.kundeId ? (
<Link href={`/kunden/${gruppe.kundeId}`} className="text-decoration-none">
{gruppe.name}
</Link>
) : (
<span className="badge pl-bg-neutral">{gruppe.name}</span>
)}
</div>
{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 (
<div key={host.id} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "12rem" }}>
<Link href={`/hosts/${host.id}`} className="text-decoration-none">
{host.hostname}
<div key={eintrag.hostId} className="d-flex align-items-center mb-1">
<div className="small text-truncate" style={{ width: "13rem" }}>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
</div>
<div className="pl-timeline-row flex-grow-1 position-relative">
<div className="pl-timeline-heute" style={{ left: `${heutePosition}%` }} />
{links === null ? (
<span className="small text-body-secondary ms-2">
außerhalb des Zeitraums ({formatDatum(datum)})
außerhalb des Zeitraums ({formatDatum(eintrag.ablauf)})
</span>
) : (
<div
className="pl-timeline-bar"
<Link
href={
eintrag.laufzeitId
? `/rechnungen/${eintrag.laufzeitId}`
: `/hosts/${eintrag.hostId}`
}
className="pl-timeline-bar text-decoration-none"
style={{
left: `${Math.max(0, links - 6)}%`,
left: `${Math.min(92, Math.max(0, links - 5))}%`,
backgroundColor: farbe,
color: farbe === "var(--pl-warning)" ? "var(--pl-warning-text)" : "#fff",
}}
title={`${host.hostname} läuft am ${formatDatum(datum)} ab`}
title={`${eintrag.hostname} läuft am ${formatDatum(eintrag.ablauf)} ab`}
>
{formatDatum(datum)}
</div>
{formatDatum(eintrag.ablauf)}
</Link>
)}
</div>
</div>
);
})}
</div>
);
})}
))}
</div>
</div>
</div>
)}
<p className="small text-body-secondary mb-0">
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.
</p>
</Card>
<div className="row">
<div className="col-lg-8">
<Card
titel={`Ablauf-Kalender: nächste 90 Tage (${naechste90Tage.length})`}
rahmen="warning"
tabelle
>
<div className="table-responsive">
<table className="table table-striped align-middle">
<thead>
<tr>
<th scope="col">Ablauf</th>
<th scope="col">Host</th>
<th scope="col">Kunde</th>
<th scope="col">Key</th>
<th scope="col">Proxmox</th>
<th scope="col">Kundenrechnung</th>
</tr>
</thead>
<tbody>
{naechste90Tage.map((eintrag) => (
<tr key={eintrag.hostId}>
<td>
<AblaufBadge datum={eintrag.ablauf.toISOString()} heute={jetzt} />
</td>
<td>
<Link href={`/hosts/${eintrag.hostId}`} className="text-decoration-none">
{eintrag.hostname}
</Link>
</td>
<td>
{eintrag.kunde ? (
<Link href={`/kunden/${eintrag.kunde.id}`} className="text-decoration-none">
{eintrag.kunde.name}
</Link>
) : (
<span className="badge pl-bg-neutral">Nicht zugeordnet</span>
)}
</td>
<td>
<CopyableCode wert={eintrag.key} />
</td>
<td>
<EinkaufBadge bezahlt={eintrag.einkaufBezahlt} />
</td>
<td>
<VerkaufBadge erstellt={eintrag.verkaufErstellt ?? false} bezahlt={false} />
</td>
</tr>
))}
{naechste90Tage.length === 0 ? (
<tr>
<td colSpan={6} className="text-center text-body-secondary py-4">
In den nächsten 90 Tagen läuft nichts ab.
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</Card>
</div>
<div className="col-lg-4">
<Card
titel={`Bereits abgelaufen (${abgelaufen.length})`}
rahmen={abgelaufen.length > 0 ? "danger" : "info"}
>
{abgelaufen.length === 0 ? (
<p className="mb-0 text-body-secondary">Keine abgelaufenen Subscriptions.</p>
) : (
<ul className="list-unstyled mb-0">
{abgelaufen.map((eintrag) => (
<li key={eintrag.hostId} className="mb-2">
<Link href={`/hosts/${eintrag.hostId}`} className="fw-semibold text-decoration-none">
{eintrag.hostname}
</Link>
<div className="small text-body-secondary">
{eintrag.kunde?.name ?? "Nicht zugeordnet"} · {formatDatum(eintrag.ablauf)} (
{formatTageBis(eintrag.tage)})
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</div>
</div>
</div>
</>
+73
View File
@@ -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<string, { name: string; kundeId: string | null; eintraege: typeof eintraege }>();
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<ReturnType<typeof ablaufDaten>>["naechste90Tage"][number];