feat(ui): klickbarer Prototyp mit Beispieldaten (#002)

Layout im AdminLTE-Stil (Sidebar, Kopfleiste, Inhaltskopf, Footer), Logo als
SVG-Komponente, Farbschema mit Dark-Mode, Dashboard, Kunden- und Host-Ansichten,
Rechnungsuebersicht, Jahresbahn und globale Suche (Strg+K).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 20:03:15 +02:00
co-authored by Claude Opus 5
parent d98bde9922
commit 8596a18a27
47 changed files with 4128 additions and 40 deletions
+727
View File
@@ -0,0 +1,727 @@
import type { Alert, Host, Kunde, Laufzeit, Report } from "./types";
/**
* Beispieldaten für den Prototyp (#002). Bezugsdatum ist HEUTE, damit Ablauffristen
* und Badges realistisch aussehen. In #003 ersetzt die Datenbank diese Datei.
*/
export const HEUTE = new Date("2026-09-17T08:00:00Z");
export const kunden: Kunde[] = [
{
id: "k1",
name: "Brandl Elektrotechnik GmbH",
kundennummer: "K-1001",
ansprechpartner: "Sabine Brandl",
email: "it@brandl-elektro.de",
telefon: "+49 871 998120",
notizen: "Wartungsvertrag bis 12/2027. Rechnungen bitte gesammelt zum Quartalsende.",
standorte: [
{ id: "s1", kundeId: "k1", name: "Zentrale Landshut", ort: "Landshut" },
{ id: "s2", kundeId: "k1", name: "Werk Ergolding", ort: "Ergolding" },
],
},
{
id: "k2",
name: "Huber Logistik AG",
kundennummer: "K-1002",
ansprechpartner: "Markus Huber",
email: "edv@huber-logistik.de",
telefon: "+49 89 4412300",
notizen: null,
standorte: [{ id: "s3", kundeId: "k2", name: "Hauptlager München", ort: "München" }],
},
{
id: "k3",
name: "Lehner Steuerberatung",
kundennummer: "K-1003",
ansprechpartner: "Dr. Eva Lehner",
email: "kanzlei@lehner-stb.de",
telefon: "+49 941 776540",
notizen: "Nur Fernwartung nach Voranmeldung.",
standorte: [{ id: "s4", kundeId: "k3", name: "Kanzlei Regensburg", ort: "Regensburg" }],
},
{
id: "k4",
name: "Systemhaus Nord GmbH",
kundennummer: "K-1004",
ansprechpartner: "Jens Petersen",
email: "service@systemhaus-nord.de",
telefon: "+49 431 220190",
notizen: null,
standorte: [{ id: "s5", kundeId: "k4", name: "Rechenzentrum Kiel", ort: "Kiel" }],
},
];
export const hosts: Host[] = [
{
id: "h1",
hostname: "pve-brandl-01",
fqdn: "pve-brandl-01.brandl.local",
kundeId: "k1",
standortId: "s1",
produkt: "pve",
produktVersion: "pve-manager/8.3.2/dc2b2f4e",
kernel: "6.8.12-4-pve",
serverId: "A1B2C3D4E5F60718",
clusterName: "brandl-cluster",
nodeName: "node01",
ips: ["192.168.10.11", "10.20.0.11"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2388G",
uptimeSekunden: 4_233_600,
paketUpdates: 3,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T03:41:00Z",
subscription: {
key: "pve1c-a6bd21fcb6",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-09-24T00:00:00Z",
nextduedate: "2026-09-24T00:00:00Z",
checktime: "2026-09-17T03:40:00Z",
},
tags: ["produktiv", "cluster"],
notizen: "Hauptvirtualisierung Zentrale.",
},
{
id: "h2",
hostname: "pve-brandl-02",
fqdn: "pve-brandl-02.brandl.local",
kundeId: "k1",
standortId: "s2",
produkt: "pve",
produktVersion: "pve-manager/8.3.2/dc2b2f4e",
kernel: "6.8.12-4-pve",
serverId: "B2C3D4E5F6071829",
clusterName: "brandl-cluster",
nodeName: "node02",
ips: ["192.168.10.12"],
sockets: 2,
cpuModell: "Intel(R) Xeon(R) Silver 4310",
uptimeSekunden: 1_987_200,
paketUpdates: 0,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T03:52:00Z",
subscription: {
key: "pve2c-3f91ad77e2",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 2 CPUs/year",
lizenzierteSockets: 2,
regdate: "2025-10-08T00:00:00Z",
nextduedate: "2026-10-08T00:00:00Z",
checktime: "2026-09-17T03:51:00Z",
},
tags: ["produktiv", "cluster"],
notizen: null,
},
{
id: "h3",
hostname: "pbs-brandl-bak",
fqdn: "pbs-brandl-bak.brandl.local",
kundeId: "k1",
standortId: "s2",
produkt: "pbs",
produktVersion: "proxmox-backup-server/3.2.7",
kernel: "6.8.12-4-pve",
serverId: "C3D4E5F607182A3B",
clusterName: null,
nodeName: null,
ips: ["192.168.10.20"],
sockets: 1,
cpuModell: "AMD Ryzen 7 5700G",
uptimeSekunden: 8_553_600,
paketUpdates: 7,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T04:10:00Z",
subscription: {
key: "pbsc-9d41be5507",
status: "active",
level: "c",
produktname: "Proxmox Backup Server Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2026-03-02T00:00:00Z",
nextduedate: "2027-03-02T00:00:00Z",
checktime: "2026-09-17T04:09:00Z",
},
tags: ["backup"],
notizen: null,
},
{
id: "h4",
hostname: "pve-huber-01",
fqdn: "pve-huber-01.huber.intern",
kundeId: "k2",
standortId: "s3",
produkt: "pve",
produktVersion: "pve-manager/8.2.4/faa83925",
kernel: "6.8.4-2-pve",
serverId: "D4E5F607182A3B4C",
clusterName: null,
nodeName: null,
ips: ["10.8.0.5", "192.168.30.5"],
sockets: 2,
cpuModell: "Intel(R) Xeon(R) Gold 5318Y",
uptimeSekunden: 15_120_000,
paketUpdates: 24,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T02:15:00Z",
subscription: {
key: "pve1c-77c0ea1b93",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-12-01T00:00:00Z",
nextduedate: "2026-12-01T00:00:00Z",
checktime: "2026-09-17T02:14:00Z",
},
tags: ["produktiv"],
notizen: "Zweiter Sockel im Sommer nachgerüstet.",
},
{
id: "h5",
hostname: "pve-huber-02",
fqdn: "pve-huber-02.huber.intern",
kundeId: "k2",
standortId: "s3",
produkt: "pve",
produktVersion: "pve-manager/8.2.4/faa83925",
kernel: "6.8.4-2-pve",
serverId: "E5F607182A3B4C5D",
clusterName: null,
nodeName: null,
ips: ["192.168.30.6"],
sockets: 1,
cpuModell: "Intel(R) Core(TM) i7-13700",
uptimeSekunden: 604_800,
paketUpdates: 41,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T02:22:00Z",
subscription: {
key: null,
status: "notfound",
level: null,
produktname: null,
lizenzierteSockets: null,
regdate: null,
nextduedate: null,
checktime: "2026-09-17T02:21:00Z",
},
tags: ["test"],
notizen: "Testumgebung, Lizenzierung noch offen.",
},
{
id: "h6",
hostname: "pmg-lehner-mail",
fqdn: "mail.lehner-stb.de",
kundeId: "k3",
standortId: "s4",
produkt: "pmg",
produktVersion: "pmg/8.1.2",
kernel: "6.8.12-2-pve",
serverId: "F607182A3B4C5D6E",
clusterName: null,
nodeName: null,
ips: ["81.21.44.18", "192.168.5.10"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2124",
uptimeSekunden: 20_736_000,
paketUpdates: 2,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T01:30:00Z",
subscription: {
key: "pmgc-2be0f41a88",
status: "active",
level: "c",
produktname: "Proxmox Mail Gateway Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2025-11-15T00:00:00Z",
nextduedate: "2026-11-15T00:00:00Z",
checktime: "2026-09-17T01:29:00Z",
},
tags: ["mail"],
notizen: null,
},
{
id: "h7",
hostname: "pve-lehner-01",
fqdn: "pve-lehner-01.lehner.local",
kundeId: "k3",
standortId: "s4",
produkt: "pve",
produktVersion: "pve-manager/8.1.10/4b06efb5",
kernel: "6.5.13-5-pve",
serverId: "07182A3B4C5D6E7F",
clusterName: null,
nodeName: null,
ips: ["192.168.5.20"],
sockets: 1,
cpuModell: "Intel(R) Xeon(R) E-2226G",
uptimeSekunden: 3_110_400,
paketUpdates: 63,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T01:45:00Z",
subscription: {
key: "pve1c-11ffa3c209",
status: "expired",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2024-08-30T00:00:00Z",
nextduedate: "2026-08-30T00:00:00Z",
checktime: "2026-09-17T01:44:00Z",
},
tags: ["produktiv"],
notizen: "Verlängerung mit Kundin besprochen, Bestellung offen.",
},
{
id: "h8",
hostname: "pve-nord-01",
fqdn: "pve-nord-01.nord.intern",
kundeId: "k4",
standortId: "s5",
produkt: "pve",
produktVersion: "pve-manager/9.0.3/ab12cd34",
kernel: "6.14.5-1-pve",
serverId: "182A3B4C5D6E7F80",
clusterName: "nord-cluster",
nodeName: "kiel01",
ips: ["172.16.4.11"],
sockets: 2,
cpuModell: "AMD EPYC 7313P",
uptimeSekunden: 11_059_200,
paketUpdates: null,
agentVersion: "0.1.0",
letzterReport: "2026-09-14T03:12:00Z",
subscription: {
key: "pve2c-85d37ae041",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 2 CPUs/year",
lizenzierteSockets: 2,
regdate: "2026-01-20T00:00:00Z",
nextduedate: "2027-01-20T00:00:00Z",
checktime: "2026-09-14T03:11:00Z",
},
tags: ["produktiv", "cluster"],
notizen: null,
},
{
id: "h9",
hostname: "pve-demo-neu",
fqdn: null,
kundeId: null,
standortId: null,
produkt: "pve",
produktVersion: "pve-manager/9.0.3/ab12cd34",
kernel: "6.14.5-1-pve",
serverId: "2A3B4C5D6E7F8091",
clusterName: null,
nodeName: null,
ips: ["203.0.113.44"],
sockets: 1,
cpuModell: "Intel(R) Core(TM) i5-14500",
uptimeSekunden: 86_400,
paketUpdates: 12,
agentVersion: "0.2.0",
letzterReport: "2026-09-17T06:05:00Z",
subscription: {
key: "pve1c-5c72db90aa",
status: "active",
level: "c",
produktname: "Proxmox VE Community Subscription 1 CPU/year",
lizenzierteSockets: 1,
regdate: "2026-06-05T00:00:00Z",
nextduedate: "2027-06-05T00:00:00Z",
checktime: "2026-09-17T06:04:00Z",
},
tags: [],
notizen: null,
},
];
export const laufzeiten: Laufzeit[] = [
{
id: "l1",
hostId: "h1",
key: "pve1c-a6bd21fcb6",
start: "2025-09-24",
ende: "2026-09-23",
vorschlag: false,
einkauf: {
nummer: "PX-2025-88213",
datum: "2025-09-22",
faelligAm: "2025-10-06",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-09-30",
pdfDateiname: "proxmox-PX-2025-88213.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0412",
datum: "2025-10-01",
nettoBetrag: 165,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-10-14",
pdfDateiname: "RE-2025-0412.pdf",
},
},
{
id: "l2",
hostId: "h1",
key: "pve1c-a6bd21fcb6",
start: "2026-09-24",
ende: "2027-09-23",
vorschlag: false,
einkauf: {
nummer: "PX-2026-14790",
datum: "2026-09-10",
faelligAm: "2026-09-24",
nettoBetrag: 115,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: false,
bezahltAm: null,
pdfDateiname: "proxmox-PX-2026-14790.pdf",
},
verkauf: null,
},
{
id: "l3",
hostId: "h2",
key: "pve2c-3f91ad77e2",
start: "2025-10-08",
ende: "2026-10-07",
vorschlag: false,
einkauf: {
nummer: "PX-2025-88213",
datum: "2025-09-22",
faelligAm: "2025-10-06",
nettoBetrag: 220,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-09-30",
pdfDateiname: "proxmox-PX-2025-88213.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0412",
datum: "2025-10-01",
nettoBetrag: 320,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-10-14",
pdfDateiname: "RE-2025-0412.pdf",
},
},
{
id: "l4",
hostId: "h2",
key: "pve2c-3f91ad77e2",
start: "2026-10-08",
ende: "2027-10-07",
vorschlag: true,
einkauf: null,
verkauf: null,
},
{
id: "l5",
hostId: "h3",
key: "pbsc-9d41be5507",
start: "2026-03-02",
ende: "2027-03-01",
vorschlag: false,
einkauf: {
nummer: "PX-2026-09114",
datum: "2026-02-26",
faelligAm: "2026-03-12",
nettoBetrag: 95,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Kreditkarte",
bezahlt: true,
bezahltAm: "2026-02-26",
pdfDateiname: "proxmox-PX-2026-09114.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2026-0188",
datum: "2026-03-03",
nettoBetrag: 140,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2026-03-20",
pdfDateiname: "RE-2026-0188.pdf",
},
},
{
id: "l6",
hostId: "h4",
key: "pve1c-77c0ea1b93",
start: "2025-12-01",
ende: "2026-11-30",
vorschlag: false,
einkauf: {
nummer: "PX-2025-91002",
datum: "2025-11-27",
faelligAm: "2025-12-11",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-12-02",
pdfDateiname: null,
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0501",
datum: "2025-12-03",
nettoBetrag: 170,
ustSatz: 19,
bezahlt: false,
bezahltAm: null,
pdfDateiname: "RE-2025-0501.pdf",
},
},
{
id: "l7",
hostId: "h6",
key: "pmgc-2be0f41a88",
start: "2025-11-15",
ende: "2026-11-14",
vorschlag: false,
einkauf: {
nummer: "PX-2025-90455",
datum: "2025-11-11",
faelligAm: "2025-11-25",
nettoBetrag: 90,
ustSatz: 19,
rabatt: 5,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-11-18",
pdfDateiname: "proxmox-PX-2025-90455.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0489",
datum: "2025-11-20",
nettoBetrag: 135,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-12-01",
pdfDateiname: "RE-2025-0489.pdf",
},
},
{
id: "l8",
hostId: "h7",
key: "pve1c-11ffa3c209",
start: "2025-08-30",
ende: "2026-08-29",
vorschlag: false,
einkauf: {
nummer: "PX-2025-85110",
datum: "2025-08-26",
faelligAm: "2025-09-09",
nettoBetrag: 110,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2025-08-28",
pdfDateiname: "proxmox-PX-2025-85110.pdf",
},
verkauf: {
erstellt: true,
nummer: "RE-2025-0377",
datum: "2025-09-01",
nettoBetrag: 165,
ustSatz: 19,
bezahlt: true,
bezahltAm: "2025-09-15",
pdfDateiname: "RE-2025-0377.pdf",
},
},
{
id: "l9",
hostId: "h8",
key: "pve2c-85d37ae041",
start: "2026-01-20",
ende: "2027-01-19",
vorschlag: false,
einkauf: {
nummer: "PX-2026-03998",
datum: "2026-01-16",
faelligAm: "2026-01-30",
nettoBetrag: 220,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2026-01-19",
pdfDateiname: "proxmox-PX-2026-03998.pdf",
},
verkauf: {
erstellt: false,
nummer: null,
datum: null,
nettoBetrag: null,
ustSatz: 19,
bezahlt: false,
bezahltAm: null,
pdfDateiname: null,
},
},
{
id: "l10",
hostId: "h9",
key: "pve1c-5c72db90aa",
start: "2026-06-05",
ende: "2027-06-04",
vorschlag: false,
einkauf: {
nummer: "PX-2026-11233",
datum: "2026-06-02",
faelligAm: "2026-06-16",
nettoBetrag: 115,
ustSatz: 19,
rabatt: 0,
zahlungsart: "Überweisung",
bezahlt: true,
bezahltAm: "2026-06-04",
pdfDateiname: null,
},
verkauf: null,
},
];
export const alerts: Alert[] = [
{
id: "a1",
typ: "ablauf-7",
titel: "Subscription läuft in 7 Tagen ab",
beschreibung: "pve-brandl-01 · Brandl Elektrotechnik GmbH · 24.09.2026",
schwere: "gefahr",
hostId: "h1",
kundeId: "k1",
laufzeitId: "l2",
seit: "2026-09-17T06:00:00Z",
erledigt: false,
},
{
id: "a2",
typ: "status",
titel: "Subscription abgelaufen",
beschreibung: "pve-lehner-01 · Lehner Steuerberatung · Status expired seit 30.08.2026",
schwere: "gefahr",
hostId: "h7",
kundeId: "k3",
laufzeitId: null,
seit: "2026-08-30T04:00:00Z",
erledigt: false,
},
{
id: "a3",
typ: "sockets",
titel: "Lizenzierte Sockets weichen ab",
beschreibung: "pve-huber-01 · 1 lizenziert, 2 physisch vorhanden",
schwere: "warnung",
hostId: "h4",
kundeId: "k2",
laufzeitId: null,
seit: "2026-09-02T03:00:00Z",
erledigt: false,
},
{
id: "a4",
typ: "keine-meldung",
titel: "Host meldet sich nicht",
beschreibung: "pve-nord-01 · letzte Meldung am 14.09.2026",
schwere: "warnung",
hostId: "h8",
kundeId: "k4",
laufzeitId: null,
seit: "2026-09-16T03:12:00Z",
erledigt: false,
},
{
id: "a5",
typ: "ohne-kunde",
titel: "Host ist keinem Kunden zugewiesen",
beschreibung: "pve-demo-neu · seit heute registriert",
schwere: "info",
hostId: "h9",
kundeId: null,
laufzeitId: null,
seit: "2026-09-17T06:05:00Z",
erledigt: false,
},
{
id: "a6",
typ: "rechnung-offen",
titel: "Proxmox-Rechnung fällig",
beschreibung: "PX-2026-14790 · 115,00 € · fällig am 24.09.2026",
schwere: "warnung",
hostId: "h1",
kundeId: "k1",
laufzeitId: "l2",
seit: "2026-09-17T06:00:00Z",
erledigt: false,
},
{
id: "a7",
typ: "kundenrechnung-fehlt",
titel: "Kundenrechnung noch nicht erstellt",
beschreibung: "pve-nord-01 · Systemhaus Nord GmbH · Einkauf bezahlt am 19.01.2026",
schwere: "warnung",
hostId: "h8",
kundeId: "k4",
laufzeitId: "l9",
seit: "2026-01-19T08:00:00Z",
erledigt: false,
},
{
id: "a8",
typ: "kundenrechnung-unbezahlt",
titel: "Kundenrechnung seit über 30 Tagen offen",
beschreibung: "RE-2025-0501 · Huber Logistik AG · 170,00 € netto",
schwere: "warnung",
hostId: "h4",
kundeId: "k2",
laufzeitId: "l6",
seit: "2026-01-05T08:00:00Z",
erledigt: false,
},
];
/** Report-Historie (gekürzt) für die Host-Detailansicht. */
export const reports: Report[] = hosts.flatMap((host) =>
Array.from({ length: 6 }, (_, i) => {
const zeitpunkt = new Date(
new Date(host.letzterReport ?? HEUTE).getTime() - i * 24 * 60 * 60 * 1000,
).toISOString();
return {
id: `${host.id}-r${i}`,
hostId: host.id,
zeitpunkt,
status: host.subscription.status,
key: host.subscription.key,
nextduedate: host.subscription.nextduedate,
agentVersion: host.agentVersion,
paketUpdates: host.paketUpdates === null ? null : Math.max(0, host.paketUpdates - i),
};
}),
);
+218
View File
@@ -0,0 +1,218 @@
import { stundenSeit, tageBis } from "@/lib/format";
import { HEUTE, alerts, hosts, kunden, laufzeiten, reports } from "./data";
import type { Host, Kunde, Laufzeit } from "./types";
export const MELDUNG_FRIST_STUNDEN = 48;
export const ABLAUF_FENSTER_TAGE = 30;
export function getKunde(id: string | null): Kunde | undefined {
if (!id) return undefined;
return kunden.find((k) => k.id === id);
}
export function getHost(id: string): Host | undefined {
return hosts.find((h) => h.id === id);
}
export function hostsVonKunde(kundeId: string): Host[] {
return hosts.filter((h) => h.kundeId === kundeId);
}
export function laufzeitenVonHost(hostId: string): Laufzeit[] {
return laufzeiten.filter((l) => l.hostId === hostId).sort((a, b) => b.start.localeCompare(a.start));
}
export function laufzeitenVonKunde(kundeId: string): Laufzeit[] {
const hostIds = new Set(hostsVonKunde(kundeId).map((h) => h.id));
return laufzeiten.filter((l) => hostIds.has(l.hostId)).sort((a, b) => b.start.localeCompare(a.start));
}
/**
* Laufzeit, die zum gemeldeten Ablaufdatum gehört: bevorzugt die Verlängerung, die am
* `nextduedate` beginnt, sonst die laufende Laufzeit. Für das Dashboard ist die Verlängerung
* der interessante Fall, weil deren Rechnungen noch offen sind.
*/
export function laufzeitZuAblauf(host: Host, jetzt: Date = HEUTE): Laufzeit | undefined {
const eigene = laufzeitenVonHost(host.id).filter((l) => !l.vorschlag);
const ablauf = host.subscription.nextduedate?.slice(0, 10);
return eigene.find((l) => l.start === ablauf) ?? aktuelleLaufzeit(host.id, jetzt);
}
/** Laufende bzw. zuletzt gekaufte Laufzeit eines Hosts. */
export function aktuelleLaufzeit(hostId: string, jetzt: Date = HEUTE): Laufzeit | undefined {
const eigene = laufzeitenVonHost(hostId).filter((l) => !l.vorschlag);
const heuteIso = jetzt.toISOString().slice(0, 10);
return eigene.find((l) => l.start <= heuteIso && l.ende >= heuteIso) ?? eigene[0];
}
export function reportsVonHost(hostId: string) {
return reports.filter((r) => r.hostId === hostId).sort((a, b) => b.zeitpunkt.localeCompare(a.zeitpunkt));
}
export function alertsVonHost(hostId: string) {
return alerts.filter((a) => a.hostId === hostId && !a.erledigt);
}
export function alertsVonKunde(kundeId: string) {
return alerts.filter((a) => a.kundeId === kundeId && !a.erledigt);
}
export function offeneAlerts() {
return alerts.filter((a) => !a.erledigt);
}
export function hostMeldetNicht(host: Host, jetzt: Date = HEUTE): boolean {
const stunden = stundenSeit(host.letzterReport, jetzt);
return stunden === null || stunden > MELDUNG_FRIST_STUNDEN;
}
export function nichtZugeordneteHosts(): Host[] {
return hosts.filter((h) => h.kundeId === null);
}
/** Offene Punkte einer Laufzeit: Proxmox-Rechnung unbezahlt bzw. Kundenrechnung fehlt. */
export function offenePunkte(laufzeit: Laufzeit): { einkaufOffen: boolean; verkaufOffen: boolean } {
const einkaufOffen = laufzeit.einkauf !== null && !laufzeit.einkauf.bezahlt;
const verkaufOffen = laufzeit.verkauf === null || !laufzeit.verkauf.erstellt;
return { einkaufOffen, verkaufOffen };
}
export function istAbgeschlossen(laufzeit: Laufzeit): boolean {
const { einkaufOffen, verkaufOffen } = offenePunkte(laufzeit);
return !einkaufOffen && !verkaufOffen;
}
/** Laufzeiten, für die noch etwas zu tun ist (Vorschläge zählen ebenfalls). */
export function offeneRechnungsLaufzeiten(): Laufzeit[] {
return laufzeiten.filter((l) => !l.vorschlag && !istAbgeschlossen(l));
}
export function dashboardKennzahlen(jetzt: Date = HEUTE) {
const aktiv = hosts.filter((h) => h.subscription.status === "active");
const bald = hosts.filter((h) => {
const tage = tageBis(h.subscription.nextduedate, jetzt);
return tage !== null && tage >= 0 && tage <= ABLAUF_FENSTER_TAGE;
});
const offeneEinkaufsrechnungen = laufzeiten.filter((l) => offenePunkte(l).einkaufOffen);
const fehlendeKundenrechnungen = laufzeiten.filter(
(l) => !l.vorschlag && l.einkauf !== null && offenePunkte(l).verkaufOffen,
);
return {
hostsGesamt: hosts.length,
aktivLizenziert: aktiv.length,
ohneSubscription: hosts.filter((h) => h.subscription.status !== "active").length,
baldAblaufend: bald.length,
ohneMeldung: hosts.filter((h) => hostMeldetNicht(h, jetzt)).length,
ohneKunde: nichtZugeordneteHosts().length,
offeneEinkaufsrechnungen: offeneEinkaufsrechnungen.length,
fehlendeKundenrechnungen: fehlendeKundenrechnungen.length,
offeneRechnungspunkte: offeneEinkaufsrechnungen.length + fehlendeKundenrechnungen.length,
offeneAlerts: offeneAlerts().length,
};
}
/** Hosts nach Ablaufdatum der Subscription, ohne Hosts ganz ohne Subscription. */
export function hostsNachAblauf(): Host[] {
return [...hosts]
.filter((h) => h.subscription.nextduedate !== null)
.sort((a, b) => (a.subscription.nextduedate ?? "").localeCompare(b.subscription.nextduedate ?? ""));
}
export function summenVonKunde(kundeId: string) {
const eigene = laufzeitenVonKunde(kundeId);
const einkauf = eigene.reduce((s, l) => s + (l.einkauf?.nettoBetrag ?? 0), 0);
const verkauf = eigene.reduce((s, l) => s + (l.verkauf?.nettoBetrag ?? 0), 0);
return { einkauf, verkauf, marge: verkauf - einkauf };
}
export function summenProJahr(laufzeitenListe: Laufzeit[]) {
const proJahr = new Map<string, { einkauf: number; verkauf: number }>();
for (const l of laufzeitenListe) {
const jahr = l.start.slice(0, 4);
const eintrag = proJahr.get(jahr) ?? { einkauf: 0, verkauf: 0 };
eintrag.einkauf += l.einkauf?.nettoBetrag ?? 0;
eintrag.verkauf += l.verkauf?.nettoBetrag ?? 0;
proJahr.set(jahr, eintrag);
}
return [...proJahr.entries()]
.sort((a, b) => b[0].localeCompare(a[0]))
.map(([jahr, werte]) => ({ jahr, ...werte, marge: werte.verkauf - werte.einkauf }));
}
/** Fortschritt „erledigte Rechnungspunkte“ je Kunde für die Dashboard-Card. */
export function rechnungsFortschrittProKunde() {
return kunden.map((kunde) => {
const eigene = laufzeitenVonKunde(kunde.id).filter((l) => !l.vorschlag);
const gesamt = eigene.length * 2;
const erledigt = eigene.reduce((summe, l) => {
const { einkaufOffen, verkaufOffen } = offenePunkte(l);
return summe + (einkaufOffen ? 0 : 1) + (verkaufOffen ? 0 : 1);
}, 0);
return {
kunde,
gesamt,
erledigt,
prozent: gesamt === 0 ? 100 : Math.round((erledigt / gesamt) * 100),
};
});
}
export function kundenUebersicht(jetzt: Date = HEUTE) {
return kunden.map((kunde) => {
const eigeneHosts = hostsVonKunde(kunde.id);
const naechsterAblauf = eigeneHosts
.map((h) => h.subscription.nextduedate)
.filter((d): d is string => d !== null)
.sort()[0];
const eigeneLaufzeiten = laufzeitenVonKunde(kunde.id).filter((l) => !l.vorschlag);
const offen = eigeneLaufzeiten.filter((l) => !istAbgeschlossen(l)).length;
return {
kunde,
hosts: eigeneHosts.length,
aktiv: eigeneHosts.filter((h) => h.subscription.status === "active").length,
naechsterAblauf: naechsterAblauf ?? null,
tageBisAblauf: tageBis(naechsterAblauf ?? null, jetzt),
offeneAlerts: alertsVonKunde(kunde.id).length,
offeneRechnungspunkte: offen,
};
});
}
/** Einfache Volltextsuche über Hosts, Kunden und Rechnungsnummern (Strg+K). */
export function suche(begriff: string) {
const q = begriff.trim().toLowerCase();
if (q.length < 2) return { hosts: [], kunden: [], rechnungen: [] };
const trefferHosts = hosts.filter((h) =>
[h.hostname, h.fqdn, h.serverId, h.subscription.key, ...h.ips]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
const trefferKunden = kunden.filter((k) =>
[k.name, k.kundennummer, k.ansprechpartner, k.email]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
const trefferRechnungen = laufzeiten.filter((l) =>
[l.einkauf?.nummer, l.verkauf?.nummer]
.filter(Boolean)
.some((wert) => String(wert).toLowerCase().includes(q)),
);
return { hosts: trefferHosts, kunden: trefferKunden, rechnungen: trefferRechnungen };
}
export const PRODUKT_NAME: Record<Host["produkt"], string> = {
pve: "Proxmox VE",
pbs: "Proxmox Backup Server",
pmg: "Proxmox Mail Gateway",
};
export const STATUS_TEXT: Record<Host["subscription"]["status"], string> = {
active: "Aktiv",
notfound: "Keine Subscription",
invalid: "Ungültig",
expired: "Abgelaufen",
suspended: "Ausgesetzt",
};
+63
View File
@@ -0,0 +1,63 @@
import { hosts, kunden, laufzeiten } from "./data";
import { PRODUKT_NAME, getKunde } from "./queries";
export interface SuchEintrag {
typ: "Host" | "Kunde" | "Rechnung";
titel: string;
untertitel: string;
href: string;
/** Alle durchsuchbaren Werte in Kleinschreibung. */
begriffe: string[];
}
/** Flacher Suchindex für die globale Suche (Strg+K) im Prototyp. */
export function suchIndex(): SuchEintrag[] {
const eintraege: SuchEintrag[] = [];
for (const host of hosts) {
const kunde = getKunde(host.kundeId);
eintraege.push({
typ: "Host",
titel: host.hostname,
untertitel: `${PRODUKT_NAME[host.produkt]} · ${kunde?.name ?? "Nicht zugeordnet"}${
host.subscription.key ? ` · ${host.subscription.key}` : ""
}`,
href: `/hosts/${host.id}`,
begriffe: [host.hostname, host.fqdn, host.serverId, host.subscription.key, ...host.ips]
.filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()),
});
}
for (const kunde of kunden) {
eintraege.push({
typ: "Kunde",
titel: kunde.name,
untertitel: `${kunde.kundennummer}${kunde.ansprechpartner ? ` · ${kunde.ansprechpartner}` : ""}`,
href: `/kunden/${kunde.id}`,
begriffe: [kunde.name, kunde.kundennummer, kunde.ansprechpartner, kunde.email]
.filter((w): w is string => Boolean(w))
.map((w) => w.toLowerCase()),
});
}
for (const laufzeit of laufzeiten) {
const host = hosts.find((h) => h.id === laufzeit.hostId);
const kunde = getKunde(host?.kundeId ?? null);
for (const [art, rechnung] of [
["Proxmox-Rechnung", laufzeit.einkauf],
["Kundenrechnung", laufzeit.verkauf],
] as const) {
if (!rechnung?.nummer) continue;
eintraege.push({
typ: "Rechnung",
titel: rechnung.nummer,
untertitel: `${art} · ${host?.hostname ?? "?"} · ${kunde?.name ?? "Nicht zugeordnet"}`,
href: `/hosts/${laufzeit.hostId}`,
begriffe: [rechnung.nummer.toLowerCase()],
});
}
}
return eintraege;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* Typen für die Beispieldaten des Prototyps (#002).
* Sie bilden das geplante Datenmodell (docs/architektur.md) vereinfacht ab und werden
* in #003 durch die von Prisma erzeugten Typen ersetzt.
*/
export type Produkt = "pve" | "pbs" | "pmg";
export type SubscriptionStatus = "active" | "notfound" | "invalid" | "expired" | "suspended";
export type AlertSchwere = "info" | "warnung" | "gefahr";
export interface Kunde {
id: string;
name: string;
kundennummer: string;
ansprechpartner: string | null;
email: string | null;
telefon: string | null;
notizen: string | null;
standorte: Standort[];
}
export interface Standort {
id: string;
kundeId: string;
name: string;
ort: string | null;
}
export interface Host {
id: string;
hostname: string;
fqdn: string | null;
kundeId: string | null;
standortId: string | null;
produkt: Produkt;
produktVersion: string | null;
kernel: string | null;
serverId: string;
clusterName: string | null;
nodeName: string | null;
ips: string[];
sockets: number | null;
cpuModell: string | null;
uptimeSekunden: number | null;
paketUpdates: number | null;
agentVersion: string;
letzterReport: string | null;
subscription: {
key: string | null;
status: SubscriptionStatus;
level: string | null;
produktname: string | null;
lizenzierteSockets: number | null;
regdate: string | null;
nextduedate: string | null;
checktime: string | null;
};
tags: string[];
notizen: string | null;
}
export interface Rechnung {
nummer: string | null;
datum: string | null;
faelligAm?: string | null;
nettoBetrag: number | null;
ustSatz: number;
rabatt?: number | null;
zahlungsart?: string | null;
bezahlt: boolean;
bezahltAm: string | null;
pdfDateiname: string | null;
}
export interface Laufzeit {
id: string;
hostId: string;
key: string;
start: string;
ende: string;
/** Vom Agent vorgeschlagen und noch nicht bestätigt. */
vorschlag: boolean;
/** Proxmox-Rechnung (Einkauf). */
einkauf: Rechnung | null;
/** Kundenrechnung (Verkauf); `erstellt = false`, solange sie noch fehlt. */
verkauf: (Rechnung & { erstellt: boolean }) | null;
}
export interface Alert {
id: string;
typ: string;
titel: string;
beschreibung: string;
schwere: AlertSchwere;
hostId: string | null;
kundeId: string | null;
laufzeitId: string | null;
seit: string;
erledigt: boolean;
}
export interface Report {
id: string;
hostId: string;
zeitpunkt: string;
status: SubscriptionStatus;
key: string | null;
nextduedate: string | null;
agentVersion: string;
paketUpdates: number | null;
}